Summary
Dulwich's stash.py:pop() function is vulnerable to symlink directory traversal, allowing an attacker to write arbitrary files outside the repository worktree when a victim pops a stash in a malicious repository.
Root Cause
The pop() function at dulwich/stash.py:236 uses os.path.exists(parent_dir) to check if a parent directory exists before writing stashed files. os.path.exists() follows symlinks, so when an intermediate directory in the path is a symlink pointing outside the worktree (e.g., link → ../../.git/hooks), the check passes and subsequent file writes resolve through the symlink.
The validate_path() function (line 228) only validates path component names against INVALID_DOTNAMES — it performs zero filesystem symlink detection. On dulwich 1.2.7 (latest release), build_file_from_blob() has no symlink protection whatsoever.
Impact
An attacker can craft a malicious repository that, when a victim clones it and performs a stash pop operation, writes attacker-controlled content to arbitrary filesystem locations. Writing to .git/hooks/post-checkout achieves Remote Code Execution on the victim's machine on the next git checkout operation.
Attack Scenario
- Attacker creates a repository with branch
main containing link (symlink → ../../.git/hooks) and branch feature containing link/post-checkout (executable payload)
- Victim clones the repository (landing on
main — symlink link exists in worktree)
- Victim checks out
feature, makes changes, runs stash.push()
- Victim checks out
main (restoring the link symlink)
- Victim runs
stash.pop(0) — stash contains link/post-checkout
os.path.exists("link") returns True (symlink to existing directory), os.makedirs skipped
build_file_from_blob(blob, mode, "link/post-checkout") → open("link/post-checkout", "wb") follows the intermediate symlink → payload written to .git/hooks/post-checkout
- Next checkout operation triggers the hook → RCE
Suggested Fix
Before writing any file, verify that no component of the target path resolves through a symlink outside the worktree. Use os.path.realpath(parent_dir) and confirm it stays within the repository root. Alternatively, use os.open() with O_NOFOLLOW on each path component.
Reported by zx (Jace)
References
Summary
Dulwich's
stash.py:pop()function is vulnerable to symlink directory traversal, allowing an attacker to write arbitrary files outside the repository worktree when a victim pops a stash in a malicious repository.Root Cause
The
pop()function atdulwich/stash.py:236usesos.path.exists(parent_dir)to check if a parent directory exists before writing stashed files.os.path.exists()follows symlinks, so when an intermediate directory in the path is a symlink pointing outside the worktree (e.g.,link → ../../.git/hooks), the check passes and subsequent file writes resolve through the symlink.The
validate_path()function (line 228) only validates path component names againstINVALID_DOTNAMES— it performs zero filesystem symlink detection. On dulwich 1.2.7 (latest release),build_file_from_blob()has no symlink protection whatsoever.Impact
An attacker can craft a malicious repository that, when a victim clones it and performs a stash pop operation, writes attacker-controlled content to arbitrary filesystem locations. Writing to
.git/hooks/post-checkoutachieves Remote Code Execution on the victim's machine on the next git checkout operation.Attack Scenario
maincontaininglink(symlink →../../.git/hooks) and branchfeaturecontaininglink/post-checkout(executable payload)main— symlinklinkexists in worktree)feature, makes changes, runsstash.push()main(restoring thelinksymlink)stash.pop(0)— stash containslink/post-checkoutos.path.exists("link")returns True (symlink to existing directory),os.makedirsskippedbuild_file_from_blob(blob, mode, "link/post-checkout")→open("link/post-checkout", "wb")follows the intermediate symlink → payload written to.git/hooks/post-checkoutSuggested Fix
Before writing any file, verify that no component of the target path resolves through a symlink outside the worktree. Use
os.path.realpath(parent_dir)and confirm it stays within the repository root. Alternatively, useos.open()withO_NOFOLLOWon each path component.Reported by zx (Jace)
References