BREW-GITUP-CVE-2026-100689 (GHSA-59CR-6R3X-644W)
Vulnerability from osv_homebrew – Published: 2026-10-01 08:53 – Updated: 2026-10-02 08:53 – Source websiteAffected: GitPython 3.1.61 (latest release) and main — git/objects/submodule/base.py. git diff 3.1.61 origin/main -- git/objects/submodule/ is empty, so both are identical here.
The gap
The fix for GHSA-hmq2-w58f-27jc added Submodule._validated_name() and wired it into update() and five siblings, closing the .gitmodules name → .git/modules/<name> traversal. The other attacker-controlled .gitmodules field, path, is read raw:
# git/objects/submodule/base.py:172-177
def _set_cache_(self, attr):
if attr in ("path", "_url", "_branch_path"):
reader = self.config_reader()
self.path = reader.get("path") # raw .gitmodules value
and GitPython's own containment guard is applied in only two of the places that consume it:
400: def _to_relative_path(cls, parent_repo, path) # the guard (abspath + commonpath containment)
542: path = cls._to_relative_path(repo, path) # add() — guarded
1041: module_checkout_path = self._to_relative_path(self.repo, module_path) # move() — guarded
update() validates only the name and then uses the path-derived absolute location directly:
788: self._validated_name(self.name) # NAME only
801: checkout_module_abspath = self.abspath # derived from self.path — unguarded
821: os.makedirs(checkout_module_abspath, exist_ok=True)
So path = ../../../tmp/escaped in an attacker-authored .gitmodules selects the directory that gets created and, on the clone path, populated from the submodule URL. The same absolute location is what force_remove hands to shutil.rmtree.
The asymmetry is the argument: this is not a missing concept — the project wrote _to_relative_path() precisely for this, and add()/move() use it. update() does not.
Honest limits (please read before rating)
- The most common flow is not affected.
Repo.clone_from(...)→repo.submodules→sm.update(init=True)re-derivespathfrom a canonical tree lookup, and real git refuses to check out a tree containing a..component, so an evil.gitmodulesnever lands in the working tree in the first place. A reachable trigger therefore requires the victim's code to name a non-HEAD commit (a historical-commit API such assubmodule_update(previous_commit=...)). - The researcher did not build that end-to-end trigger. The researcher only verified first-hand the code above: the guard's two call sites, the name-only validation in
update(), and the unguardedabspath→os.makedirs()flow at 3.1.61 ==main.
Suggested fix
Apply the guard the project already has, wherever the path is consumed:
# in update(), before deriving abspath (and in any other consumer of self.path):
checkout_rel = self._to_relative_path(self.repo, self.path) # raises if it escapes the working tree
Better still, validate at the boundary: reject a .gitmodules entry whose path is absolute or contains a .. component when the section is first read in _set_cache_()/iter_items(), so no consumer can be added later without the check. A regression test with path = ../escaped alongside the existing name test would pin both fields.
Prior art checked
GHSA-hmq2-w58f-27jc (this is a residual of its fix, in the sibling field, not a re-report) plus the repository's 30 published advisories — none mentions the path field or _to_relative_path. Searched issues and PRs for _to_relative_path, gitmodules path and submodule traversal: no report of this.
Credit
kta1kri.
Appendix — EVIDENCE_gitpython_path_unguarded_20260901.txt (inlined; advisories accept no attachments)
=== EVIDENCE: GitPython — the .gitmodules 'path' field reaches os.makedirs()/clone unguarded ===
Mon Aug 31 18:45:22 UTC 2026
--- artifact: tag 3.1.61 (latest release); git diff 3.1.61 origin/main -- git/objects/submodule/ is empty ---
--- the containment guard GitPython owns, and its only two call sites ---
33: _to_relative_path,
400: def _to_relative_path(cls, parent_repo: "Repo", path: PathLike) -> PathLike:
407: path = _to_relative_path(parent_repo.working_tree_dir, path)
542: path = cls._to_relative_path(repo, path)
1041: module_checkout_path = self._to_relative_path(self.repo, module_path)
--- the parent fix (_validated_name) call sites: it validates the NAME ---
309: def _validated_name(cls, name: str) -> str:
321: name = cls._validated_name(name)
541: cls._validated_name(name)
788: self._validated_name(self.name)
1040: self._validated_name(self.name)
1181: self._validated_name(self.name)
1439: self._validated_name(self.name)
1440: self._validated_name(new_name)
1489: self._validated_name(self.name)
--- update(): name validated, path not; abspath -> os.makedirs ---
try:
self._validated_name(self.name)
# ENSURE REPO IS PRESENT AND UP-TO-DATE
# END early abort if init is not allowed
checkout_module_abspath = self.abspath
module_abspath = self._module_abspath(self.repo, self.path, self.name)
# ``git submodule deinit`` leaves the repository in
# ``.git/modules`` and empties the checkout. Reconnect that retained
# repository instead of trying to clone over it.
if not dry_run and osp.isdir(module_abspath):
try:
git.Repo(module_abspath)
except InvalidGitRepositoryError:
pass
else:
if osp.lexists(checkout_module_abspath) and (
osp.islink(checkout_module_abspath)
or not osp.isdir(checkout_module_abspath)
or os.listdir(checkout_module_abspath)
):
raise OSError(
"Module directory at %r does already exist and is non-empty" % checkout_module_abspath
)
os.makedirs(checkout_module_abspath, exist_ok=True)
self._write_git_file_and_module_config(checkout_module_abspath, module_abspath)
mrepo = git.Repo(checkout_module_abspath)
--- where self.path comes from (raw .gitmodules value) ---
def _set_cache_(self, attr: str) -> None:
if attr in ("path", "_url", "_branch_path"):
reader: SectionConstraint = self.config_reader()
# Default submodule values.
try:
self.path = reader.get("path")
except cp.NoSectionError as e:
{
"affected": [
{
"ecosystem_specific": {
"fix": "bump",
"range_state": "fixed",
"resource": "gitpython",
"resource_purl": "pkg:pypi/gitpython@3.1.62",
"upstream_fixed_in": "3.1.62"
},
"package": {
"ecosystem": "Homebrew",
"name": "gitup",
"purl": "pkg:brew/gitup"
},
"ranges": [
{
"events": [
{
"introduced": "0.2.3"
},
{
"fixed": "0.5.2_8"
}
],
"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": "**Affected:** `GitPython` **3.1.61** (latest release) and `main` \u2014 `git/objects/submodule/base.py`. `git diff 3.1.61 origin/main -- git/objects/submodule/` is empty, so both are identical here.\n\n---\n\n## The gap\n\nThe fix for `GHSA-hmq2-w58f-27jc` added `Submodule._validated_name()` and wired it into `update()` and five siblings, closing the `.gitmodules` **name** \u2192 `.git/modules/\u003cname\u003e` traversal. The other attacker-controlled `.gitmodules` field, **`path`**, is read raw:\n\n```python\n# git/objects/submodule/base.py:172-177\ndef _set_cache_(self, attr):\n if attr in (\"path\", \"_url\", \"_branch_path\"):\n reader = self.config_reader()\n self.path = reader.get(\"path\") # raw .gitmodules value\n```\n\nand GitPython\u0027s own containment guard is applied in only two of the places that consume it:\n\n```\n400: def _to_relative_path(cls, parent_repo, path) # the guard (abspath + commonpath containment)\n542: path = cls._to_relative_path(repo, path) # add() \u2014 guarded\n1041: module_checkout_path = self._to_relative_path(self.repo, module_path) # move() \u2014 guarded\n```\n\n`update()` validates only the name and then uses the path-derived absolute location directly:\n\n```\n788: self._validated_name(self.name) # NAME only\n801: checkout_module_abspath = self.abspath # derived from self.path \u2014 unguarded\n821: os.makedirs(checkout_module_abspath, exist_ok=True)\n```\n\nSo `path = ../../../tmp/escaped` in an attacker-authored `.gitmodules` selects the directory that gets created and, on the clone path, populated from the submodule URL. The same absolute location is what `force_remove` hands to `shutil.rmtree`.\n\nThe asymmetry is the argument: this is not a missing concept \u2014 the project wrote `_to_relative_path()` precisely for this, and `add()`/`move()` use it. `update()` does not.\n\n## Honest limits (please read before rating)\n\n- **The most common flow is not affected.** `Repo.clone_from(...)` \u2192 `repo.submodules` \u2192 `sm.update(init=True)` re-derives `path` from a canonical tree lookup, and real git refuses to check out a tree containing a `..` component, so an evil `.gitmodules` never lands in the working tree in the first place. A reachable trigger therefore requires the victim\u0027s code to name a **non-HEAD commit** (a historical-commit API such as `submodule_update(previous_commit=...)`).\n- **The researcher did not build that end-to-end trigger.** The researcher only verified first-hand the code above: the guard\u0027s two call sites, the name-only validation in `update()`, and the unguarded `abspath` \u2192 `os.makedirs()` flow at 3.1.61 == `main`.\n\n## Suggested fix\n\nApply the guard the project already has, wherever the path is consumed:\n\n```python\n# in update(), before deriving abspath (and in any other consumer of self.path):\ncheckout_rel = self._to_relative_path(self.repo, self.path) # raises if it escapes the working tree\n```\n\nBetter still, validate at the boundary: reject a `.gitmodules` entry whose `path` is absolute or contains a `..` component when the section is first read in `_set_cache_()`/`iter_items()`, so no consumer can be added later without the check. A regression test with `path = ../escaped` alongside the existing `name` test would pin both fields.\n\n## Prior art checked\n\n`GHSA-hmq2-w58f-27jc` (this is a residual of its fix, in the sibling field, not a re-report) plus the repository\u0027s 30 published advisories \u2014 none mentions the `path` field or `_to_relative_path`. Searched issues and PRs for `_to_relative_path`, `gitmodules path` and `submodule traversal`: no report of this.\n\n## Credit\n\n**kta1kri**.\n\n\n---\n\n## Appendix \u2014 `EVIDENCE_gitpython_path_unguarded_20260901.txt` (inlined; advisories accept no attachments)\n\n```text\n=== EVIDENCE: GitPython \u2014 the .gitmodules \u0027path\u0027 field reaches os.makedirs()/clone unguarded ===\nMon Aug 31 18:45:22 UTC 2026\n\n--- artifact: tag 3.1.61 (latest release); git diff 3.1.61 origin/main -- git/objects/submodule/ is empty ---\n\n--- the containment guard GitPython owns, and its only two call sites ---\n33: _to_relative_path,\n400: def _to_relative_path(cls, parent_repo: \"Repo\", path: PathLike) -\u003e PathLike:\n407: path = _to_relative_path(parent_repo.working_tree_dir, path)\n542: path = cls._to_relative_path(repo, path)\n1041: module_checkout_path = self._to_relative_path(self.repo, module_path)\n\n--- the parent fix (_validated_name) call sites: it validates the NAME ---\n309: def _validated_name(cls, name: str) -\u003e str:\n321: name = cls._validated_name(name)\n541: cls._validated_name(name)\n788: self._validated_name(self.name)\n1040: self._validated_name(self.name)\n1181: self._validated_name(self.name)\n1439: self._validated_name(self.name)\n1440: self._validated_name(new_name)\n1489: self._validated_name(self.name)\n\n--- update(): name validated, path not; abspath -\u003e os.makedirs ---\n\n try:\n self._validated_name(self.name)\n\n # ENSURE REPO IS PRESENT AND UP-TO-DATE\n # END early abort if init is not allowed\n\n checkout_module_abspath = self.abspath\n module_abspath = self._module_abspath(self.repo, self.path, self.name)\n\n # ``git submodule deinit`` leaves the repository in\n # ``.git/modules`` and empties the checkout. Reconnect that retained\n # repository instead of trying to clone over it.\n if not dry_run and osp.isdir(module_abspath):\n try:\n git.Repo(module_abspath)\n except InvalidGitRepositoryError:\n pass\n else:\n if osp.lexists(checkout_module_abspath) and (\n osp.islink(checkout_module_abspath)\n or not osp.isdir(checkout_module_abspath)\n or os.listdir(checkout_module_abspath)\n ):\n raise OSError(\n \"Module directory at %r does already exist and is non-empty\" % checkout_module_abspath\n )\n os.makedirs(checkout_module_abspath, exist_ok=True)\n self._write_git_file_and_module_config(checkout_module_abspath, module_abspath)\n mrepo = git.Repo(checkout_module_abspath)\n\n--- where self.path comes from (raw .gitmodules value) ---\n def _set_cache_(self, attr: str) -\u003e None:\n if attr in (\"path\", \"_url\", \"_branch_path\"):\n reader: SectionConstraint = self.config_reader()\n # Default submodule values.\n try:\n self.path = reader.get(\"path\")\n except cp.NoSectionError as e:\n```",
"id": "BREW-gitup-CVE-2026-100689",
"modified": "2026-10-02T08:53:00Z",
"published": "2026-10-01T08:53:13Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/gitpython-developers/GitPython/security/advisories/GHSA-59cr-6r3x-644w"
},
{
"type": "WEB",
"url": "https://github.com/gitpython-developers/GitPython/pull/2225"
},
{
"type": "WEB",
"url": "https://github.com/gitpython-developers/GitPython/commit/1ed0ebc2f2e74d979cdc367a4864a7731fdcc093"
},
{
"type": "PACKAGE",
"url": "https://github.com/gitpython-developers/GitPython"
},
{
"type": "WEB",
"url": "https://github.com/gitpython-developers/GitPython/releases/tag/3.1.62"
}
],
"schema_version": "1.7.3",
"severity": [
{
"score": "CVSS:4.0/AV:N/AC:H/AT:P/PR:N/UI:P/VC:L/VI:H/VA:L/SC:N/SI:N/SA:N",
"type": "CVSS_V4"
}
],
"summary": "GitPython submodule update path traversal can write outside the repository",
"upstream": [
"GHSA-59cr-6r3x-644w",
"CVE-2026-100689"
]
}
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.