Server side template injection In gitpython
Description
GitPython: command injection via unguarded Git options in Repo.archive(), git.ls_remote(), and arbitrary file overwrite via Repo.iter_commits() / Repo.blame()
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','[email protected]','-c','user.name=a', 'commit','-q','--allow-empty','-m','init'], check=True) repo = git.Repo(d)...
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','[email protected]','-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)...
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','[email protected]','-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')...
Verbatim output:
[*] before: 'do not delete\n' [*] git err (after open+truncate): Cmd('git') failed due to: exit code(129) [+] after : '' <- truncated
Mitigation
Update Impact
Minimal update. May introduce new vulnerabilities or breaking changes.
Ecosystem | Component | Affected version | Patched versions |
|---|---|---|---|
pypi | 3.1.51 |
Aliases
References