Summary
The fix for GHSA-f89h-2fjh-2r9q holds for the clone path — I verified that first, with fifteen crafted trees and a reverted-fix control. It does not hold in force checkout (overwrite_existing = true), where the checkout itself replaces a directory with an attacker symlink and the guard is disarmed exactly in that state.
Reproduced on the published crates, including the ones the advisory names as patched: gix 0.87.1, gix-fs 0.22.1, gix-worktree 0.56.0, gix-worktree-state 0.34.1.
Root cause
// gix-fs/src/stack.rs:175-183
for _ in 0..self.valid_components - matching_components {
self.current.pop();
self.current_relative.pop();
if self.current_is_directory { delegate.pop_directory(); }
self.current_is_directory = true; // unconditional
}
self.valid_components = matching_components;
if !self.current_is_directory && components.peek().is_some() { // the GHSA fix
delegate.push(false, self)?;
...
}
// gix-worktree-state/src/checkout/entry.rs
match op(path) {
Ok(res) => Ok(res),
Err(err) if gix_fs::symlink::is_collision_error(&err) => {
try_unlink_path_recursively(path, &std::fs::symlink_metadata(path)?)?; // remove_dir_all
op(path) // then symlink()
}
The sequence:
- The file phase ends inside directory
zz, leaving current_relative = "zz/mod.rs" and current_is_directory = false.
- The delayed symlink entry
zz matches the cached prefix in every component, so the pop loop runs once and sets current_is_directory = true at line 181. components is now empty, so the line-185 guard is !true and is skipped, and the while loop below never runs. Delegate::push() is never called — no create_leading_directory, no validate_last_component.
try_op_or_unlink hits EEXIST, remove_dir_alls <worktree>/zz and creates the symlink in its place. The directory the stack still believes in is now a symlink, and nothing invalidates the cache.
- The delayed entry
zz/post-checkout matches one component, the pop loop runs zero times, current_is_directory is still true, so the guard is skipped again. Only post-checkout is pushed, with is_last_component = true, which returns early at gix-worktree/src/stack/delegate.rs:169. symlink(2) then resolves through zz and writes outside the worktree.
The comment at entry.rs:202-205 says "this stack only protects leading components in that mode". The leading component is precisely what is unprotected.
Proof
Harness built against crates.io releases only, calling gix_worktree_state::checkout directly. Malicious tree: README.md, payload.sh, a blob zz (symlink → .git/hooks) and a tree zz containing mod.rs and a symlink post-checkout → ../../payload.sh.
0. upstream git's view of the tree
error in tree 9c99006c…: duplicateEntries: contains duplicate file entries
1. UPSTREAM GIT, same repo, clone + checkout -f
zz/mod.rs zz/post-checkout >>> contained (no hook)
2. NEGATIVE CONTROL overwrite_existing = FALSE, single-threaded
files_updated=3 collisions=["zz"] >>> contained (no hook)
3. NEGATIVE CONTROL overwrite_existing = TRUE, 8 threads
files_updated=3 collisions=[] >>> contained (no hook)
4. FINDING overwrite_existing = TRUE, single-threaded
files_updated=3 collisions=[] errors=0 <- silent: no error, no collision
lrwxrwxrwx zz -> .git/hooks
lrwxrwxrwx .git/hooks/post-checkout -> ../../payload.sh
>>> ESCAPED hook body: #!/bin/sh touch /tmp/E/RCE-PROOF
5. POSITIVE CONTROL benign repo, identical options
files_updated=2 collisions=[] errors=0 README.md src/mod.rs src/alias
Upstream git refuses the tree outright, and in the one case where it accepts a clone it stays contained — so this is not "git does it too".
Two further oracles on the same released crates:
clobber a file outside the worktree:
BEFORE -rw-r--r-- /tmp/VICTIM/canary ("IMPORTANT-ORIGINAL-CONTENT")
AFTER lrwxrwxrwx /tmp/VICTIM/canary -> /tmp/imp/attacker-script
(upstream git on the same repo: victim intact)
recursive deletion outside the worktree:
BEFORE /tmp/VICTIMDIR/important/{notes.txt,deeper/secrets.txt}
AFTER /tmp/VICTIMDIR/important is a symlink, contents gone
Control that the harness detects the class: reverting only the five-line fix in stack.rs:185-193 makes the clone payload write the hook again, so the harness is not blind to the axis bug it is meant to distinguish from.
Preconditions, measured rather than assumed
overwrite_existing = true. Not reachable from the gitoxide CLI — the string appears nowhere in this repo, and gix clone refuses a non-empty destination (verified). It is a documented public option and consumers do set it: lu-zero/portage-cli, src/gix_ext/reset.rs:344, sets overwrite_existing = true with destination_is_initially_empty = false to implement git reset --hard on fetched repositories.
Single-threaded checkout, so the delayed-symlink phase runs on a stack the file phase left inside the malicious directory. That happens with checkout.workers=1, without gix-features/parallel, with a small index, or on any single-CPU host: docker run --cpus=1 escaped at 3, 23 and 4,003 entries with the default thread_limit, because available_parallelism() honours the cgroup quota. Multi-threaded on ≥2 CPUs is not vulnerable, which is control 3 above.
Unix. Windows is protected by enable_terminal_symlink_check.
Affected versions
gix-fs <= 0.22.1, gix-worktree <= 0.56.0, gix-worktree-state <= 0.34.1, gix <= 0.87.1 — every published version, including those patched for GHSA-f89h-2fjh-2r9q — and HEAD 77c8cd9.
Impact
CVSS:3.1/AV:L/AC:H/PR:N/UI:R/S:U/C:H/I:H/A:H — 7.0 High.
Same impact triad as the axis advisory (which you scored 7.8): symlink plant leading to code execution through hooks, plus arbitrary recursive deletion outside the worktree. AC:H rather than AC:L because success depends on deployment properties the attacker does not control. Where a consumer is known to force-checkout on a single-CPU runner it is effectively AC:L → 7.8, the axis score; I am not claiming that as the headline.
Suggested fix
Do not let the pop loop assert a directory it has not re-checked. Setting current_is_directory from the popped component rather than unconditionally would keep the line-185 guard armed in exactly this state.
Independently, try_op_or_unlink should invalidate the stack after it replaces a path, since the cache's belief about <worktree>/zz outlives the directory it described. Either change alone closes the sequence above; both together also cover the symmetric case where a later entry re-enters the replaced prefix.
What this is not
Not a clone-path bypass. Fifteen crafted trees — absolute and relative prefix escape, the interleaved a / a. / a/x shape named in gix-index/src/init.rs, deep and nested paths, gitlink prefixes, .git / git~1 / .git. spellings, unsorted trees, duplicate symlinks, and an 8-thread run with 4,003 entries — all blocked at HEAD and on the released binary.
Not multi-threaded. In MT the delayed phase starts from a fresh stack, zz is pushed, current_is_directory is false, and the guard fires. Verified contained.
Not arbitrary file content outside the worktree. Regular files are written in phase 1, before the parent becomes a symlink, and use O_NOFOLLOW. The primitive is symlink plant plus deletion of whatever sits at the target — the same primitive as the axis advisory.
Not gix archive, gix clean, fetch ref names, .git component validation, or the gix-status read side. All tested or read and rejected: archive refuses .. and .git through State::from_tree as git does; clean classifies symlinks with symlink_metadata and unlinks them without touching the target; ref names go through gix_validate::reference::name; protect_ntfs catches the .git spellings on every platform; and nothing in the read path turns a cached directory into a symlink, so the stale-cache state never arises there.
Summary
The fix for
GHSA-f89h-2fjh-2r9qholds for the clone path — I verified that first, with fifteen crafted trees and a reverted-fix control. It does not hold in force checkout (overwrite_existing = true), where the checkout itself replaces a directory with an attacker symlink and the guard is disarmed exactly in that state.Reproduced on the published crates, including the ones the advisory names as patched:
gix 0.87.1,gix-fs 0.22.1,gix-worktree 0.56.0,gix-worktree-state 0.34.1.Root cause
The sequence:
zz, leavingcurrent_relative = "zz/mod.rs"andcurrent_is_directory = false.zzmatches the cached prefix in every component, so the pop loop runs once and setscurrent_is_directory = trueat line 181.componentsis now empty, so the line-185 guard is!trueand is skipped, and thewhileloop below never runs.Delegate::push()is never called — nocreate_leading_directory, novalidate_last_component.try_op_or_unlinkhitsEEXIST,remove_dir_alls<worktree>/zzand creates the symlink in its place. The directory the stack still believes in is now a symlink, and nothing invalidates the cache.zz/post-checkoutmatches one component, the pop loop runs zero times,current_is_directoryis stilltrue, so the guard is skipped again. Onlypost-checkoutis pushed, withis_last_component = true, which returns early atgix-worktree/src/stack/delegate.rs:169.symlink(2)then resolves throughzzand writes outside the worktree.The comment at
entry.rs:202-205says "this stack only protects leading components in that mode". The leading component is precisely what is unprotected.Proof
Harness built against crates.io releases only, calling
gix_worktree_state::checkoutdirectly. Malicious tree:README.md,payload.sh, a blobzz(symlink →.git/hooks) and a treezzcontainingmod.rsand a symlinkpost-checkout→../../payload.sh.Upstream git refuses the tree outright, and in the one case where it accepts a clone it stays contained — so this is not "git does it too".
Two further oracles on the same released crates:
Control that the harness detects the class: reverting only the five-line fix in
stack.rs:185-193makes the clone payload write the hook again, so the harness is not blind to the axis bug it is meant to distinguish from.Preconditions, measured rather than assumed
overwrite_existing = true. Not reachable from the gitoxide CLI — the string appears nowhere in this repo, andgix clonerefuses a non-empty destination (verified). It is a documented public option and consumers do set it:lu-zero/portage-cli,src/gix_ext/reset.rs:344, setsoverwrite_existing = truewithdestination_is_initially_empty = falseto implementgit reset --hardon fetched repositories.Single-threaded checkout, so the delayed-symlink phase runs on a stack the file phase left inside the malicious directory. That happens with
checkout.workers=1, withoutgix-features/parallel, with a small index, or on any single-CPU host:docker run --cpus=1escaped at 3, 23 and 4,003 entries with the defaultthread_limit, becauseavailable_parallelism()honours the cgroup quota. Multi-threaded on ≥2 CPUs is not vulnerable, which is control 3 above.Unix. Windows is protected by
enable_terminal_symlink_check.Affected versions
gix-fs <= 0.22.1,gix-worktree <= 0.56.0,gix-worktree-state <= 0.34.1,gix <= 0.87.1— every published version, including those patched forGHSA-f89h-2fjh-2r9q— and HEAD77c8cd9.Impact
CVSS:3.1/AV:L/AC:H/PR:N/UI:R/S:U/C:H/I:H/A:H— 7.0 High.Same impact triad as the axis advisory (which you scored 7.8): symlink plant leading to code execution through hooks, plus arbitrary recursive deletion outside the worktree.
AC:Hrather thanAC:Lbecause success depends on deployment properties the attacker does not control. Where a consumer is known to force-checkout on a single-CPU runner it is effectivelyAC:L→ 7.8, the axis score; I am not claiming that as the headline.Suggested fix
Do not let the pop loop assert a directory it has not re-checked. Setting
current_is_directoryfrom the popped component rather than unconditionally would keep the line-185 guard armed in exactly this state.Independently,
try_op_or_unlinkshould invalidate the stack after it replaces a path, since the cache's belief about<worktree>/zzoutlives the directory it described. Either change alone closes the sequence above; both together also cover the symmetric case where a later entry re-enters the replaced prefix.What this is not
Not a clone-path bypass. Fifteen crafted trees — absolute and relative prefix escape, the interleaved
a/a./a/xshape named ingix-index/src/init.rs, deep and nested paths, gitlink prefixes,.git/git~1/.git.spellings, unsorted trees, duplicate symlinks, and an 8-thread run with 4,003 entries — all blocked at HEAD and on the released binary.Not multi-threaded. In MT the delayed phase starts from a fresh stack,
zzis pushed,current_is_directoryisfalse, and the guard fires. Verified contained.Not arbitrary file content outside the worktree. Regular files are written in phase 1, before the parent becomes a symlink, and use
O_NOFOLLOW. The primitive is symlink plant plus deletion of whatever sits at the target — the same primitive as the axis advisory.Not
gix archive,gix clean, fetch ref names,.gitcomponent validation, or thegix-statusread side. All tested or read and rejected: archive refuses..and.gitthroughState::from_treeas git does; clean classifies symlinks withsymlink_metadataand unlinks them without touching the target; ref names go throughgix_validate::reference::name;protect_ntfscatches the.gitspellings on every platform; and nothing in the read path turns a cached directory into a symlink, so the stale-cache state never arises there.