{"repro_id":"REPRO-2026-00155","version":8,"title":"gitoxide (gix-fs): symlink worktree escape on checkout writes files outside the worktree","repro_type":"security","status":"published","severity":"high","description":"During worktree checkout, the `gix-fs` crate processes **symlink index\nentries** in a way that lets a malicious git tree **reuse a path prefix** to\nwrite an attacker-controlled symlink into an existing directory the user can\nwrite to.\n\nA later index entry is then resolved *through* that planted symlink, so files\nare written outside the intended worktree directory. An attacker who controls\nthe contents of a git tree the victim checks out can therefore place files\nanywhere the victim user can write.","root_cause":"# RCA Report: CVE-2026-44471\n\n## Summary\nCVE-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.\n\n## Impact\n- **Package/component affected**: `gix-fs` crate (used transitively by `gix-worktree` and `gix` during checkout)\n- **Affected versions**: `gix-fs < 0.21.1`\n- **Fixed versions**: `gix-fs 0.21.1`\n- **Risk level**: High (CVSS 7.8)\n- **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.\n\n## Root Cause\nThe 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)`. \n\nThe `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.\n\nThe 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.\n\n## Reproduction Steps\nThe reproduction script is `repro/reproduction_steps.sh`. It performs the following steps:\n\n1. 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.\n2. The binary creates a worktree directory and a \"forbidden\" directory outside it.\n3. It instantiates a `gix_worktree::Stack` configured for checkout (`stack::State::for_checkout`).\n4. 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.\n5. It calls `cache.at_path(\"link/file\", IS_FILE)` — this exercises the vulnerable leaf-to-directory promotion.\n6. 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.\n7. If the call errors with `AlreadyExists` (fixed), the script confirms no file was written outside the worktree.\n\n### Expected evidence of reproduction\n- **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.\n- **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.\n\n## Evidence\n- `logs/vulnerable.log` — captures the vulnerable run showing the file was written to the forbidden directory:\n  ```\n  VULNERABLE: file escaped worktree to .../forbidden/file\n  Exit code: 0\n  Forbidden:\n  -rw-r--r-- 1 root root 7 ... file\n  ```\n- `logs/fixed.log` — captures the fixed run showing the collision was blocked:\n  ```\n  FIXED: symlink collision blocked, no escape\n  Exit code: 1\n  Forbidden:\n  (empty)\n  ```\n- `logs/summary.log` — confirms the differential outcome:\n  ```\n  Vulnerable exit code: 0\n  Fixed exit code: 1\n  CONFIRMED: Symlink worktree escape reproduced on vulnerable version and blocked on fixed version\n  ```\n- `logs/runtime_manifest.json` — structured record of the two runs.\n- Environment: Rust 1.94.1 on Linux x86_64.\n\n## Recommendations / Next Steps\n1. **Upgrade**: All consumers of `gix-fs` should upgrade to `>= 0.21.1`.\n2. **Audit callers**: Any custom delegates using `gix_fs::Stack` should ensure their `push()` implementation performs security validation on all components, not just leaf components.\n3. **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.\n\n## Additional Notes\n- The reproduction was confirmed idempotent: running `repro/reproduction_steps.sh` twice produced identical results.\n- 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.\n","ghsa_id":"GHSA-f89h-2fjh-2r9q","cve_id":"CVE-2026-44471","cwe_id":"CWE-59 (Link Following)","package":{"name":"gix-fs","ecosystem":"cargo","affected_versions":"gix-fs <= 0.21.0 (gitoxide < 0.21.1)","fixed_version":"gix-fs 0.21.1"},"reproduced_at":"2026-05-22T18:45:00.633041+00:00","duration_secs":2002.9110703468325,"tool_calls":334,"turns":294,"handoffs":2,"total_cost_usd":4.1752024,"agent_costs":{"repro":2.853378440000002,"support":0.11785192999999998,"vuln_variant":1.2039720300000003},"cost_breakdown":{"repro":{"accounts/fireworks/models/kimi-k2p6":2.853378440000002},"support":{"accounts/fireworks/models/kimi-k2p6":0.11785192999999998},"vuln_variant":{"accounts/fireworks/models/kimi-k2p6":1.2039720300000003}},"quality":{"idempotent_verified":false,"community_verifications":0},"published_at":"2026-05-22T18:45:02.788720+00:00","retracted":false,"artifacts":[{"path":"repro/rca_report.md","filename":"rca_report.md","size":5082,"category":"analysis"},{"path":"repro/reproduction_steps.sh","filename":"reproduction_steps.sh","size":8761,"category":"reproduction_script"},{"path":"vuln_variant/rca_report.md","filename":"rca_report.md","size":7972,"category":"analysis"},{"path":"vuln_variant/reproduction_steps.sh","filename":"reproduction_steps.sh","size":9828,"category":"reproduction_script"},{"path":"bundle/context.json","filename":"context.json","size":3005,"category":"other"},{"path":"bundle/metadata.json","filename":"metadata.json","size":631,"category":"other"},{"path":"bundle/ticket.md","filename":"ticket.md","size":3444,"category":"ticket"},{"path":"repro/src/main.rs","filename":"main.rs","size":2664,"category":"other"},{"path":"repro/Cargo.toml","filename":"Cargo.toml","size":377,"category":"other"},{"path":"repro/validation_verdict.json","filename":"validation_verdict.json","size":1030,"category":"other"},{"path":"repro/Cargo.lock","filename":"Cargo.lock","size":24965,"category":"other"},{"path":"vuln_variant/root_cause_equivalence.json","filename":"root_cause_equivalence.json","size":988,"category":"other"},{"path":"vuln_variant/patch_analysis.md","filename":"patch_analysis.md","size":4316,"category":"documentation"},{"path":"vuln_variant/variant_manifest.json","filename":"variant_manifest.json","size":2890,"category":"other"},{"path":"vuln_variant/validation_verdict.json","filename":"validation_verdict.json","size":2345,"category":"other"},{"path":"logs/variant_status_like_vuln.log","filename":"variant_status_like_vuln.log","size":61,"category":"log"},{"path":"logs/variant_checkout_like_fixed.log","filename":"variant_checkout_like_fixed.log","size":58,"category":"log"},{"path":"logs/summary.log","filename":"summary.log","size":156,"category":"log"},{"path":"logs/variant_summary.log","filename":"variant_summary.log","size":1352,"category":"log"},{"path":"logs/variant_checkout_like_vuln.log","filename":"variant_checkout_like_vuln.log","size":63,"category":"log"},{"path":"logs/variant_checkout_like_nested_vuln.log","filename":"variant_checkout_like_nested_vuln.log","size":70,"category":"log"},{"path":"logs/runtime_manifest.json","filename":"runtime_manifest.json","size":370,"category":"other"},{"path":"logs/vulnerable.log","filename":"vulnerable.log","size":549,"category":"log"},{"path":"logs/variant_checkout_like_nested_fixed.log","filename":"variant_checkout_like_nested_fixed.log","size":65,"category":"log"},{"path":"logs/variant_status_like_fixed.log","filename":"variant_status_like_fixed.log","size":56,"category":"log"},{"path":"logs/fixed.log","filename":"fixed.log","size":428,"category":"log"}]}