# GitPython command wrappers omit unsafe protocol and option checks

GitPython's ls_remote, merge_base and IndexFile.move omit the unsafe-protocol/option guards used elsewhere, letting attacker URLs reach git argv and execute commands via ext:: transport.

- Payout: Research
- Severity: high
- Program: GitPython
- Found by: wirsbf
- Disclosed: 2026-10-27
- Write-up quality: 8/10
- Original: https://github.com/advisories/GHSA-w8jc-g24h-crhw
- Library page: https://bugbountylibrary.com/r/ghsa%3AGHSA-w8jc-g24h-crhw
- Tags: area:oss domain:oss impact:rce kind:research platform:ghsa sev:high tech:python technique:chained technique:incomplete-fix technique:source-review vuln:command-injection vuln:rce

**Target:** Python git wrapper library (Git.ls_remote, Repo.merge_base, IndexFile.move)
**Root cause:** Three wrapper methods build git argv from caller-controlled values without calling the existing check_unsafe_protocols/check_unsafe_options helpers, so ext:: remote-helper URLs and denylisted option spellings pass through unchecked.
**Steps:**
1. Call `repo.git.ls_remote('ext::/tmp/gp_touch.sh')` with stock git config: git refuses (rc 128) but GitPython raises no UnsafeProtocolError.
2. Set victim config `protocol.ext.allow=always` and repeat the same call: the ext:: transport executes the script, creating the marker file.
3. Call `repo.merge_base('main','side', upload_pack=...)`: option reaches argv as `git merge-base --upload-pack=... main side`, rc 129 silently converted to `[]`.
4. Call `repo.index.move(['f1','f2'], output=...)`: option reaches `git mv` unguarded (rc 129).
**How found:** Audited the 2022-2026 rollout of check_unsafe_protocols/check_unsafe_options method-by-method, built a coverage matrix of call sites, and noticed ls_remote had the options guard but not the protocol guard while guarded siblings (iter_commits, remove) sat lines away from merge_base and move; then dynamically verified each site on main.
**Exploitation:** Attacker supplies an `ext::sh -c ...` URL to a victim application that probes remotes with `Git(...).ls_remote(attacker_url)` before cloning; when the victim git permits the ext transport (protocol.ext.allow=always, or git < 2.38.1 stock config), arbitrary commands execute as the victim process.
**Escalation:** Same ext:: primitive previously scored critical on the clone path (CVE-2022-24439); here it resurfaces on a sibling API lacking the guard, and unguarded kwargs on merge_base/move extend the argument-injection surface.
**Insight:** When a project rolls out a security guard method-by-method, enumerate every wrapper that builds argv and diff its guard set against guarded siblings in the same file; residual gaps after a bundled advisory (CVE-2026-67323) are prime targets, and exception handlers that swallow non-128 statuses can mask the injection.

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

**ls_remote ext:: URL executes command**

```
repo.git.ls_remote('ext::/tmp/gp_touch.sh')`, stock config | git refuses (rc 128); **no `UnsafeProtocolError` from GitPython** |
| T1b | same + victim config `protocol.ext.allow=always` | **marker created - arbitrary command executed
```

**merge_base upload_pack option injection**

```
repo.merge_base('main','side', upload_pack=...)` | **not blocked**; argv `git merge-base --upload-pack=... main side`; rc 129 silently converted to `[]`
```

**IndexFile.move output option injection**

```
repo.index.move(['f1','f2'], output=...)` | **not blocked**; option reaches `git mv` (rc 129)
```

