# REPRO-2026-00155: gitoxide (gix-fs): symlink worktree escape on checkout writes files outside the worktree ## Summary Status: published Severity: high Type: security Confidence: Unknown ## Identifiers REPRO ID: REPRO-2026-00155 GHSA: GHSA-f89h-2fjh-2r9q CVE: CVE-2026-44471 ## Package Name: gix-fs Ecosystem: cargo Affected: gix-fs <= 0.21.0 (gitoxide < 0.21.1) Fixed: gix-fs 0.21.1 ## Root Cause # RCA Report: CVE-2026-44471 ## Summary CVE-2026-44471 is a symlink worktree escape vulnerability in the `gix-fs` crate (part of the gitoxide project). During worktree checkout, when the `gix-fs::Stack` reuses a previously cached leaf path component (e.g., a symlink) as a directory parent for a subsequent path, it skips re-invoking the delegate's `push()` callback. This bypasses collision detection and allows a malicious symlink planted earlier in the checkout to redirect later file writes outside the intended worktree directory. ## Impact - **Package/component affected**: `gix-fs` crate (used transitively by `gix-worktree` and `gix` during checkout) - **Affected versions**: `gix-fs < 0.21.1` - **Fixed versions**: `gix-fs 0.21.1` - **Risk level**: High (CVSS 7.8) - **Consequences**: An attacker who controls a git repository can cause files to be written to arbitrary locations writable by the victim user during checkout, via a malicious tree containing a symlink entry followed by a file entry that reuses the symlink's path prefix. ## Root Cause The bug is in `gix_fs::Stack::make_relative_path_current()` (in `gix-fs/src/stack.rs`). When a previously cached leaf component needs to be promoted to a directory parent (because a later path has additional child components), the old code called `delegate.push_directory()` directly without first calling `delegate.push(false, self)`. The `push()` callback is where `gix-worktree`'s delegate performs collision detection: it checks whether the path already exists as a symlink or file and either errors out or unlinks it. By skipping this callback during leaf-to-directory transitions, a symlink could persist and later paths would resolve through it. The fix (commit `93d0ff6` in the gitoxide repository) adds `delegate.push(false, self)?;` before `delegate.push_directory(self)?;` when promoting a cached leaf, and also adds proper state restoration if either callback fails. ## Reproduction Steps The reproduction script is `repro/reproduction_steps.sh`. It performs the following steps: 1. Creates a minimal Rust binary that depends on `gix-worktree = "=0.52.0"` and pins `gix-fs` to either `=0.21.0` (vulnerable) or `=0.21.1` (fixed). A local patched copy of `gix-hash = "=0.25.0"` is used via `[patch.crates-io]` to resolve a compilation incompatibility with modern Rust. 2. The binary creates a worktree directory and a "forbidden" directory outside it. 3. It instantiates a `gix_worktree::Stack` configured for checkout (`stack::State::for_checkout`). 4. It calls `cache.at_path("link", IS_SYMLINK)` to get the path where a symlink would be created, then manually creates a symlink at that path pointing to the forbidden directory. 5. It calls `cache.at_path("link/file", IS_FILE)` — this exercises the vulnerable leaf-to-directory promotion. 6. If the call succeeds (vulnerable), the script writes a file to the returned path and verifies it appears inside the forbidden directory, proving the escape. 7. If the call errors with `AlreadyExists` (fixed), the script confirms no file was written outside the worktree. ### Expected evidence of reproduction - **Vulnerable (`gix-fs 0.21.0`)**: the binary prints `VULNERABLE: file escaped worktree to .../forbidden/file` and exits 0. The forbidden directory contains the escaped file. - **Fixed (`gix-fs 0.21.1`)**: the binary prints `FIXED: symlink collision blocked, no escape` and exits 1 (indicating the bug is no longer present). The forbidden directory remains empty. ## Evidence - `logs/vulnerable.log` — captures the vulnerable run showing the file was written to the forbidden directory: ``` VULNERABLE: file escaped worktree to .../forbidden/file Exit code: 0 Forbidden: -rw-r--r-- 1 root root 7 ... file ``` - `logs/fixed.log` — captures the fixed run showing the collision was blocked: ``` FIXED: symlink collision blocked, no escape Exit code: 1 Forbidden: (empty) ``` - `logs/summary.log` — confirms the differential outcome: ``` Vulnerable exit code: 0 Fixed exit code: 1 CONFIRMED: Symlink worktree escape reproduced on vulnerable version and blocked on fixed version ``` - `logs/runtime_manifest.json` — structured record of the two runs. - Environment: Rust 1.94.1 on Linux x86_64. ## Recommendations / Next Steps 1. **Upgrade**: All consumers of `gix-fs` should upgrade to `>= 0.21.1`. 2. **Audit callers**: Any custom delegates using `gix_fs::Stack` should ensure their `push()` implementation performs security validation on all components, not just leaf components. 3. **Regression testing**: The fix commit includes regression tests in `gix-worktree/tests/worktree/stack/create_directory.rs` that should be maintained and run in CI. ## Additional Notes - The reproduction was confirmed idempotent: running `repro/reproduction_steps.sh` twice produced identical results. - The reproduction requires a patched `gix-hash 0.25.0` to compile on Rust 1.94+ due to upstream `match self` exhaustiveness lint issues; this is a build-time incompatibility unrelated to the vulnerability under test. ## Reproduction Details Reproduced: 2026-05-22T18:45:00.633Z Duration: 2003 seconds Tool calls: 334 Turns: 294 Handoffs: 2 ## Quick Verification Run one of these commands to verify locally: pruva-verify REPRO-2026-00155 pruva-verify GHSA-f89h-2fjh-2r9q pruva-verify CVE-2026-44471 Or open in GitHub Codespaces (zero-friction, auto-runs): https://github.com/codespaces/new?ref=repro/REPRO-2026-00155&repo=N3mes1s/pruva-sandbox Or download and run the script manually: curl -O https://api.pruva.dev/v1/reproductions/REPRO-2026-00155/artifacts/repro/reproduction_steps.sh chmod +x reproduction_steps.sh ./reproduction_steps.sh WARNING: Run in a sandboxed environment. This exploits a real vulnerability. ## References - GitHub Advisory: https://github.com/advisories/GHSA-f89h-2fjh-2r9q - NVD: https://nvd.nist.gov/vuln/detail/CVE-2026-44471 ## Artifacts - repro/rca_report.md (analysis, 5082 bytes) - repro/reproduction_steps.sh (reproduction_script, 8761 bytes) - vuln_variant/rca_report.md (analysis, 7972 bytes) - vuln_variant/reproduction_steps.sh (reproduction_script, 9828 bytes) - bundle/context.json (other, 3005 bytes) - bundle/metadata.json (other, 631 bytes) - bundle/ticket.md (ticket, 3444 bytes) - repro/src/main.rs (other, 2664 bytes) - repro/Cargo.toml (other, 377 bytes) - repro/validation_verdict.json (other, 1030 bytes) - repro/Cargo.lock (other, 24965 bytes) - vuln_variant/root_cause_equivalence.json (other, 988 bytes) - vuln_variant/patch_analysis.md (documentation, 4316 bytes) - vuln_variant/variant_manifest.json (other, 2890 bytes) - vuln_variant/validation_verdict.json (other, 2345 bytes) - logs/variant_status_like_vuln.log (log, 61 bytes) - logs/variant_checkout_like_fixed.log (log, 58 bytes) - logs/summary.log (log, 156 bytes) - logs/variant_summary.log (log, 1352 bytes) - logs/variant_checkout_like_vuln.log (log, 63 bytes) - logs/variant_checkout_like_nested_vuln.log (log, 70 bytes) - logs/runtime_manifest.json (other, 370 bytes) - logs/vulnerable.log (log, 549 bytes) - logs/variant_checkout_like_nested_fixed.log (log, 65 bytes) - logs/variant_status_like_fixed.log (log, 56 bytes) - logs/fixed.log (log, 428 bytes) ## API Access - JSON: https://api.pruva.dev/v1/reproductions/REPRO-2026-00155 - Script: https://api.pruva.dev/v1/reproductions/REPRO-2026-00155/artifacts/repro/reproduction_steps.sh - Web: https://pruva.dev/r/REPRO-2026-00155 ## For AI Assistants To help users with this reproduction: 1. The `pruva-verify` command is the simplest way to run the reproduction 2. The script downloads and runs automatically in an isolated temp directory 3. Always recommend running in a VM, container, or disposable environment 4. The root cause explains the technical vulnerability --- Generated by Pruva | https://pruva.dev