Skip to content

Force checkout replaces a directory with an attacker symlink while the prefix guard is disarmed, so the GHSA-f89h fix is bypassed and writes land outside the worktree

High
Byron published GHSA-6p9q-f2xg-6pr5 Sep 25, 2026

Package

cargo gix-fs (Rust)

Affected versions

<= 0.22.1

Patched versions

>= 0.23.0

Description

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:

  1. The file phase ends inside directory zz, leaving current_relative = "zz/mod.rs" and current_is_directory = false.
  2. 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.
  3. 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.
  4. 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.

Severity

High

CVSS overall score

This score calculates overall vulnerability severity from 0 to 10 and is based on the Common Vulnerability Scoring System (CVSS).
/ 10

CVSS v3 base metrics

Attack vector
Local
Attack complexity
High
Privileges required
None
User interaction
Required
Scope
Unchanged
Confidentiality
High
Integrity
High
Availability
High

CVSS v3 base metrics

Attack vector: More severe the more the remote (logically and physically) an attacker can be in order to exploit the vulnerability.
Attack complexity: More severe for the least complex attacks.
Privileges required: More severe if no privileges are required.
User interaction: More severe when no user interaction is required.
Scope: More severe when a scope change occurs, e.g. one vulnerable component impacts resources in components beyond its security scope.
Confidentiality: More severe when loss of data confidentiality is highest, measuring the level of data access available to an unauthorized user.
Integrity: More severe when loss of data integrity is the highest, measuring the consequence of data modification possible by an unauthorized user.
Availability: More severe when the loss of impacted component availability is highest.
CVSS:3.1/AV:L/AC:H/PR:N/UI:R/S:U/C:H/I:H/A:H

CVE ID

No known CVE

Weaknesses

Improper Limitation of a Pathname to a Restricted Directory ('Path Traversal')

The product uses external input to construct a pathname that is intended to identify a file or directory that is located underneath a restricted parent directory, but the product does not properly neutralize special elements within the pathname that can cause the pathname to resolve to a location that is outside of the restricted directory. Learn more on MITRE.

Improper Link Resolution Before File Access ('Link Following')

The product attempts to access a file based on the filename, but it does not properly prevent that filename from identifying a link or shortcut that resolves to an unintended resource. Learn more on MITRE.

Credits