h3llh0und

CVE-2026-100419: the checkout that replaced a directory it was still trusting

gitoxide caches which leading path components it has already validated during checkout. On the force-checkout path a collision deletes one of those directories and plants an attacker symlink in its place, and nothing tells the cache. The next entry trusts the stale belief, the guard added by the previous advisory is skipped, and the write lands outside the worktree. Git had this exact bug in 2020; the fix commit cites it by hash.

Disclaimer. This is personal security research, carried out on my own equipment against a public open-source project. It has no connection to any current or former employer, and no client, employer or production environment was involved at any point. Every checkout in it ran against crates built on my own machine, writing to throwaway directories created for the test. The issue was reported privately through the project’s own security advisory process, and is written up here only after the advisory, the fixed release and the CVE record were public.

gitoxide is a pure-Rust implementation of Git, published as the gix family of crates. Checking out a tree means writing a lot of files into a lot of directories, and the machinery that does it keeps a small optimisation: a stack that remembers which leading directories it has already confirmed are real directories on disk, so it does not lstat a/b/c/ again for every file it writes under it. That stack is gix_fs::Stack, and one boolean in it — current_is_directory — is the whole of this write-up.

The stack had already been the subject of one advisory. GHSA-f89h-2fjh-2r9q (CVE-2026-44471), fixed in April in gix-fs 0.21.1, closed a way to desynchronise the cache during a clone so that a later component was written through a symlink. That fix added a guard. I went looking for it, confirmed it held on the clone path, and then found a second path where the same class of desync happens — and where the new guard is disarmed by the line right above it.

The result is CVE-2026-100419, assigned by VulnCheck, and GHSA-6p9q-f2xg-6pr5. The two records do not link to each other and neither will acquire the other on its own — a repository advisory only carries a cve_id when the identifier came through GitHub as CNA, and this one came from a third party afterwards — so cite both: the advisory for the affected range and the detail, the CVE record for the identifier a scanner keys on.

The boolean, and what it is supposed to mean

Stack::make_relative_path_current walks from wherever the stack currently sits to a new relative path. It shares the leading components that match, pops the ones that do not, and pushes the new ones. current_is_directory records whether the component the stack currently rests on is being held as a directory, so that push_directory and pop_directory on the delegate stay balanced.

Here is the pop loop, in gix-fs/src/stack.rs at the affected tag gix-fs-v0.22.1:

// gix-fs/src/stack.rs:185-195
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-f89h guard
    delegate.push(false, self)?;
    // ...
}

The guard on the last line is the one CVE-2026-44471 added: if the stack is resting on something that is not a directory and there is more path to descend, push it properly first — which runs the delegate’s leading-directory creation and its symlink validation. It is exactly the check you want before you walk through a component.

Now read the line four above it. Every iteration of the pop loop ends with self.current_is_directory = true, unconditionally. The comment on the field says the flag means “assume current is a directory”. After any pop, the stack simply asserts that its new resting component is a directory — without looking. If the pop loop runs at least once, current_is_directory is true on the way out, and the guard, which only fires when it is false, cannot.

That is the whole disarming. The guard is real, correct, and never gets to run in the state that needs it.

The state that needs it

The force-checkout path is the one where overwrite_existing = true. It is not reachable from the gix CLI — the string does not appear anywhere in the repository and gix clone refuses a non-empty destination — but it is a documented public option, and consumers set it. lu-zero/portage-cli sets it, with destination_is_initially_empty = false, to implement git reset --hard over a repository it already fetched.

On that path, gix-worktree-state delays every symlink to the very end of the checkout. function.rs runs the ordinary entries first, then this:

// gix-worktree-state/src/checkout/function.rs:134
for (entry, entry_path) in delayed_symlinks {
    // ... checkout each symlink now, reusing the same path stack
}

The delayed phase reuses the same ctx, and therefore the same path_cache, that the ordinary phase left behind. So the stack that processes the delayed symlinks starts wherever the last regular file left it — and if that file was inside the directory a symlink is about to replace, the stack is resting inside a directory that is about to stop existing.

When the symlink entry collides with the directory already on disk, this runs, in entry.rs at gix-worktree-state-v0.34.1:

// gix-worktree-state/src/checkout/entry.rs:229
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()
}

It remove_dir_alls the directory and creates the symlink in its place. The directory the stack still believes in is gone, replaced by a symlink, and nothing invalidates the cache. The delegate comment two files over, in gix-worktree/src/stack/delegate.rs, states the assumption out loud: “this stack only protects leading components in that mode.” The leading component is precisely what is now a symlink, and precisely what is not re-checked.

Put in order, on a single malicious tree containing a blob zz (a symlink to .git/hooks), a tree zz holding mod.rs, and inside it a symlink post-checkout pointing at ../../payload.sh:

  1. The ordinary phase writes zz/mod.rs and ends inside zz, leaving current_is_directory = false.
  2. The delayed phase processes the symlink zz. Its components all match the cached prefix, the pop loop runs once and sets current_is_directory = true, and — with no components left to descend — the guard is skipped. Delegate::push is never called: no directory creation, no symlink validation.
  3. The collision handler remove_dir_alls zz and makes it a symlink to .git/hooks. The cache is untouched.
  4. The delayed phase processes zz/post-checkout. One component matches, the pop loop runs zero times, current_is_directory is still true, the guard is skipped again, and symlink(2) resolves zz through the symlink and writes post-checkout into .git/hooks — outside the worktree, where the next git operation runs it.

Reproduced on the published crates

I built the harness against crates.io releases only — gix 0.87.1, gix-fs 0.22.1, gix-worktree 0.56.0, gix-worktree-state 0.34.1, each the version the advisory would later name as the last affected — and called gix_worktree_state::checkout directly. The run:

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

The silence in step 4 is the point: no collision reported, no error raised, the checkout returns success, and the symlink is sitting in .git/hooks. Two more oracles on the same crates showed the same primitive doing the other two things a planted symlink can do — clobbering a file outside the worktree and deleting a directory tree outside it:

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

recursive deletion outside the worktree:
  BEFORE  /tmp/VICTIMDIR/important/{notes.txt,deeper/secrets.txt}
  AFTER   /tmp/VICTIMDIR/important is a symlink, contents gone

The controls decide what this is, and what it is not

The negative controls are the argument that this is a gitoxide defect and not “git does that too.” Upstream git refuses the crafted tree outright as duplicateEntries (step 0), and in the one case where it does accept a clone it stays contained (step 1). Turning off overwrite_existing contains it (step 2). Running multi-threaded on more than one CPU contains it (step 3) — in that mode the delayed phase starts from a fresh stack, zz is pushed with current_is_directory = false, and the guard fires.

The one I care most about is the positive control on the detector, not the target. Before trusting that my harness could see this class of bug at all, I reverted only the five lines of the GHSA-f89h fix in stack.rs and re-ran the clone payload: it wrote the hook again. So the harness is not blind to the axis it is meant to tell apart from this one — when the old bug is present it reports it, and when the fixed clone payload runs it stays quiet. A probe that cannot fail proves nothing; this one fails on the known-bad case.

The precondition I had to measure, not assume

“Single-threaded” sounds like a configuration nobody runs, and that impression is wrong. gitoxide picks its thread count with available_parallelism(), which honours the cgroup CPU quota. On any host the scheduler reports as one CPU, the checkout runs single-threaded regardless of configuration — and the delayed-symlink phase then runs on the stack the file phase left mid-directory. docker run --cpus=1 reproduced the escape at 3, at 23 and at 4,003 index entries, all with the default thread limit. A CI runner, a small container, a constrained build agent: each is one CPU as far as this code is concerned. That is why the score is AC:H rather than “practically unreachable,” and why it is not AC:L either — success still depends on deployment properties the attacker does not set.

The 2020 git bug this is, exactly

When the maintainer fixed it, the commit message pointed at where the answer had been for five years. gix-fs reuses a design git itself uses: an lstat cache of leading directories, reset lazily because checkout normally proceeds in index order so a stale entry is never consulted. Git learned in 2020 that some checkout paths do not proceed in index order — checkout-index and delayed checkout among them — and that an out-of-order entry could consult a cache invalidated by a path collision and follow a symlink out of the repository. That is CVE-2021-21300, fixed by Matheus Tavares in git commit 684dd4c2, whose message reads, of the exact mechanism:

if the directory was replaced by a symlink, checkout could actually end up following the symlink and writing the file at a wrong place, even outside the repository. Since delayed checkout is affected by this bug, it could be used by an attacker to write arbitrary files during the clone of a maliciously crafted repository.

That is this bug, in a different language, delayed symlinks and all. gitoxide’s fix commit cites 684dd4c2 by hash and follows its reasoning: git’s remedy was to reset the lstat cache after every directory removal rather than argue about which removals are dangerous. A reimplementation had reproduced the cache design faithfully enough to reproduce the design’s original vulnerability, in a phase — delayed symlinks under force checkout — the reimplementation added on its own.

The fix

gix-fs 0.23.0 teaches the pop loop what git’s patch taught the lstat cache. When a cached component turns out to be the terminal entry the caller is about to replace, the stack now stops treating it as a leading directory and revalidates it with its current mode:

// gix-fs/src/stack.rs, after the pop loop
if matching_components != 0 && components.peek().is_none() {
    // The caller may replace this entry, so it must no longer be cached as a leading directory.
    if self.current_is_directory {
        delegate.pop_directory();
        self.current_is_directory = false;
    }
    return delegate.push(true, self);
}

The terminal component is now pushed — and validated — even when it was cached, so replacing it can no longer slip past the guard, and a later descent through the replaced path re-checks it. Shared leading directories stay cached, so the optimisation the whole stack exists for is intact. This is the “close it in the cache, not at the call site” shape git chose, and either that or invalidating the cache inside the collision handler would have closed the sequence; the maintainer did the former.

The fix shipped on 25 September in gix-fs 0.23.0, carried by gix-worktree 0.57.0, gix-worktree-state 0.35.0, gix 0.88.0 and gitoxide 0.59.0. If you depend on any of them and you force-check-out repositories you did not author — a mirror, a reset-to-fetched, anything with overwrite_existing = true — take the bump.

What I did not claim

Not a clone-path bypass. Fifteen crafted trees — absolute and relative prefix escapes, the interleaved a / a. / a/x shape the index code names, deep and nested paths, gitlink prefixes, the .git / git~1 / .git. spellings, unsorted trees, duplicate symlinks and a 4,003-entry multi-threaded run — all stayed contained at HEAD and on the released binary. Not multi-threaded: control 3. Not arbitrary file content outside the worktree: regular files are written in the ordinary phase, before the parent becomes a symlink, with O_NOFOLLOW; the primitive is a symlink plant plus deletion of whatever sits at the target, the same primitive as the axis advisory. And not gix archive, gix clean, fetch ref names, .git-component validation or the gix-status read side — each tested or read and rejected, because none of them turns a cached directory into a symlink, so the stale-cache state never arises there.

The score reflects that restraint. I filed CVSS:3.1/AV:L/AC:H/PR:N/UI:R/S:U/C:H/I:H/A:H, 7.0 High, which the advisory kept; the CVE record adds a CVSS 4.0 vector at 7.3. Both sit just under the 7.8 the earlier axis advisory carries, and the gap is one metric — AC:H against AC:L — which is the honest place to put a bug that needs a single-CPU deployment to land.


Reported 19 September through GitHub’s private vulnerability reporting; accepted by Byron on 20 September, 15 hours later; advisory published 25 September, crediting me as reporter. GitHub is the CNA for the repository and the maintainer did not request an identifier, so — as with several findings before this one — the identifier came from VulnCheck, which published CVE-2026-100419 the same evening, citing the advisory, the fix commit and the release. Advisory: GHSA-6p9q-f2xg-6pr5. VulnCheck advisory.

Eurico Nicacio — @h3llh0und

← all posts Eurico Nicacio · h3llh0und