{"repro_id":"REPRO-2026-00382","version":6,"title":"gVisor directfs openHandle host-FD identity TOCTOU → guest-to-host sandbox escape (follow-up to CVE-2026-96812, no separate CVE assigned)","repro_type":"security","status":"published","severity":"high","description":"CUSTOM ID — no separate CVE exists for this specific commit. Affected package: google/gvisor (go), vulnerable < release-20261005.0.","root_cause":"# Root Cause Analysis: gVisor directfs `openHandle()` Host-FD Identity TOCTOU\n\n## Summary\n\ngVisor's directfs implementation in release-20260928.0 reopens a cached dentry by name in `directfsInode.openHandle()` without checking that the newly opened host file descriptor still identifies the file and type that the sentry previously revalidated. A host-side attacker who can modify the backing directory can preserve a cached regular-file dentry, then atomically race that name to a device node between revalidation and the later `openat()`. The vulnerable sentry uses the resulting device descriptor as a regular-file backing handle. This run proved the issue through the real `runsc`/directfs/systrap product path by racing a regular file with an otherwise sandbox-inaccessible host loop block device: the guest read a host-only secret and wrote unique guest-controlled markers into host-only backing images in two attempts. release-20261005.0 rejected the same swaps and wrote no marker.\n\n## Impact\n\n- **Affected component:** `google/gvisor`, `pkg/sentry/fsimpl/gofer/directfs_inode.go`, function `(*directfsInode).openHandle()`.\n- **Affected version tested:** `release-20260928.0`, commit `6485c3fbdfe28172ced5d6aed49554cf494967db`.\n- **Fixed version tested:** `release-20261005.0`, commit `97e8e896904cb364319cd9758241d3f0bd27a6f4`.\n- **Fix commit:** `cd968a36e5557215b5dcb8989011ce0e558b5dd0`.\n- **Required attacker/environment conditions:** the attacker controls or can replace entries in a directfs-backed host directory after a guest has cached a regular-file dentry; the runsc host context can open the swapped device; the race wins between revalidation and open-by-name. This proof requires privileged host setup for an unattached loop device and uses the systrap platform.\n- **Risk:** high. A sandboxed guest obtained read/write access to a host block device that was neither present in the OCI rootfs nor mounted into the sandbox. On a real host, equivalent access to a security-relevant host block device can disclose or modify host filesystems and privileged state.\n\n## Impact Parity\n\n- **Disclosed/claimed maximum impact:** guest-to-host sandbox escape, described as host-root code execution through an unsafe host device FD.\n- **Reproduced impact:** repeatable guest-controlled read and write to an otherwise inaccessible host block device through production `runsc`. In two vulnerable attempts, the guest read `HOST_ONLY_SECRET_4ec7309a` from offset 4096 and wrote a unique 4096-byte marker at offset 8192. The host verified both markers directly in backing images outside the guest. Two fixed attempts created no markers and rejected the raced device opens.\n- **Parity:** `partial`.\n- **Not demonstrated:** no host process command was executed and no host-root shell or host executable modification was attempted. The proof establishes a concrete sandbox-boundary-crossing host storage read/write primitive, not a complete command-execution chain. The earlier CUSE path was not used as the final proof because gofer's regular-file I/O path forwards positional `preadv2`/`pwritev2`, which does not provide the normal CUSE handshake used by deterministic character-device passthrough.\n\n## Root Cause\n\nThe directfs dentry lifecycle separates identity validation from the host descriptor later used for data operations:\n\n1. The guest resolves `/race/target` while it is a stable regular file, so gVisor creates and caches a regular-file dentry/inode.\n2. Directfs revalidation examines a child reached from a parent control descriptor and validates metadata associated with that lookup.\n3. `directfsInode.openHandle()` subsequently obtains a usable handle by resolving the dentry name again:\n\n   ```go\n   flags |= hostOpenFlags\n   openFD, err := unix.Openat(parent.inode.impl.(*directfsInode).controlFD, d.name, int(flags), 0)\n   if err != nil {\n       return noHandle, err\n   }\n   return handle{fd: int32(openFD)}, nil\n   ```\n\n4. In release-20260928.0, the returned `openFD` is accepted without `fstat()` or comparison against the cached inode type/device identity. A host `renameat2(RENAME_EXCHANGE)` can therefore let revalidation observe the original regular inode while the later `openat()` observes a device node at the same name.\n5. Because the sentry still treats the file description as a regular file, reads and writes are forwarded through the gofer host handle. With the raced loop block descriptor, guest `pread()` and `pwrite()` directly affected host block storage.\n\nThe fix commit [cd968a36e5557215b5dcb8989011ce0e558b5dd0](https://github.com/google/gvisor/commit/cd968a36e5557215b5dcb8989011ce0e558b5dd0) performs `Fstat(openFD)`, checks that the file type is supported, compares the reopened type with the cached inode type, and for character devices compares major/minor identity. A mismatch is closed and rejected as stale. For this block-device replacement, the fixed build rejects the block-device replacement with `EPERM`; the separate same-input `/dev/cuse` identity control returns `ESTALE`, exactly exercising the fix’s cached S_IFREG versus reopened S_IFCHR comparison. In both cases, the guest never receives a usable host device handle.\n\n## Reproduction Steps\n\n1. Run `bundle/repro/reproduction_steps.sh` from any directory. It accepts `PRUVA_ROOT`; the default resolves the bundle directory portably.\n2. The script reads `bundle/project_cache_context.json`, reuses the prepared gVisor repository and build cache when available, and otherwise creates `bundle/artifacts/gvisor-cache`.\n3. It anchors source/build identity to vulnerable commit `6485c3f...` and fixed commit `97e8e896...`, verifies that fix commit `cd968a36...` is absent/present respectively, and checks the actual `Fstat(openFD)` patch hunk.\n4. It builds or reuses genuine optimized `runsc` binaries, compiles the static guest and host racers, and starts a privileged Docker host fixture.\n5. For each of two vulnerable and two fixed attempts, the fixture creates a fresh 16 MiB host-only backing image and unattached loop device, writes a secret at offset 4096, starts a real `runsc --directfs=true --platform=systrap` sandbox, primes the target as a regular dentry, then begins atomic regular-file/block-device swaps.\n6. The guest races `open(O_RDWR|O_TRUNC)`, reads the host secret, and attempts to write a unique marker at offset 8192. After `runsc` exits, the host reads that offset directly from the backing image.\n7. Exit code 0 requires both vulnerable attempts to read/write and create their exact markers, both fixed attempts to create no marker, and both fixed attempts to record device-open rejection.\n\nExpected summary:\n\n```text\nvulnerable_host_read_write_marker_attempts=2/2\nfixed_host_write_marker_attempts=0/2\nfixed_rejection_attempts=2/2\nvulnerable_cuse_fd_control=1/1\nfixed_cuse_estale_control=1/1\n```\n\n## Evidence\n\nPrimary current-run evidence is under `bundle/repro/results/` and is digest-bound by `bundle/repro/runtime_manifest.json`.\n\n- `results/source-identity.log`: exact vulnerable/fixed commits and patch presence.\n- `results/runsc-version-{vuln,fixed}.txt`: product versions exercised.\n- `results/guest-vuln-1.log`:\n\n  ```text\n  GUEST_HOST_BLOCK_READ: secret=HOST_ONLY_SECRET_4ec7309a opens=2 offset=4096 bytes=4096\n  GUEST_HOST_BLOCK_WRITE: marker=GUEST_HOST_MARKER_vuln_1_91d7c3ee offset=8192 bytes=4096 errno=0\n  ```\n\n- `results/guest-vuln-2.log`: an independent vulnerable process repeats the read/write after 11 opens.\n- `results/host-observation-vuln-1.log` and `host-observation-vuln-2.log`: direct host inspection reports `MATCH=true` and includes marker bytes in hexadecimal.\n- `results/host-marker-vuln-{1,2}.bin`: exact marker bytes read from host-only backing images.\n- `results/guest-fixed-1.log` and `guest-fixed-2.log`: hundreds of thousands of block-device attempts produce no win; device-node windows return `EPERM` while regular-file windows remain usable.\n- `results/cuse-guest-vuln.log`: the vulnerable build accepts the raced `/dev/cuse` descriptor (`GUEST_CUSE_FD_OPEN`).\n- `results/cuse-guest-fixed.log`: the fixed build has no CUSE win and reports large nonzero `estale` counts for the identical synchronized swap, directly demonstrating the fix’s required `ESTALE` identity failure.\n- `results/host-observation-fixed-{1,2}.log` and `host-marker-fixed-{1,2}.bin`: host-side negative controls are all zero and report `MATCH=false`.\n- `results/summary.txt`: aggregate 2/2 vulnerable effects versus 0/2 fixed markers and 2/2 fixed rejection.\n- `bundle/logs/reproduction_steps.log`: complete final product-run diagnostics.\n- `bundle/logs/final_run1_console.log` and `final_run2_console.log`: two consecutive successful top-level executions.\n- `bundle/repro/runtime_manifest.json`: source identity, runtime stack, artifact list, and SHA-256 map.\n\nThe final run used Linux `6.8.0-142-generic`, x86-64, Docker `29.1.3`, gVisor systrap, directfs enabled, and no sanitizer.\n\nThe fdwatch files are retained as diagnostics only. They intentionally scope observations to runsc-reported process trees and exact loop major/minor, but sampled no rows because the vulnerable guest won and completed I/O faster than the `/proc` watcher acquired the process tree. Unlike the previous broad watcher, no unrelated host character-device rows are treated as proof. Exact guest output plus direct host backing-image marker comparison is the primary descriptor/effect correlation.\n\n## Recommendations / Next Steps\n\n- Upgrade to `release-20261005.0` or later.\n- Preserve the fix's post-`openat()` identity checks. At minimum, compare file type against the cached inode; for device files, compare major/minor identity; close the descriptor and return `ESTALE`/an error on mismatch.\n- Add a regression test that synchronizes a cached regular dentry with an atomic replacement before `openHandle()`, covering character and block device substitutions.\n- Test all open modes that force a fresh handle, especially `O_TRUNC`, and test repeated revalidation/open races.\n- Treat any host device descriptor exposed through a cached regular dentry as a sandbox-boundary violation even if one specific device protocol is not usable through positional I/O.\n- For terminal exploit research, a host filesystem block device can be used to study controlled modification of a non-production fixture filesystem, but that escalation was deliberately not claimed here without current-run host command-execution evidence.\n\n## Additional Notes\n\n- The final script passed twice consecutively in the current runtime. Each invocation itself performs two vulnerable and two fixed process attempts.\n- The script is self-contained apart from declared source helpers and standard network/build prerequisites. It recompiles helpers and rebuilds `runsc` if compatible cached binaries are unavailable.\n- It bounds every `runsc` invocation with `timeout`, isolates each attempt with a fresh bundle/runsc root/tmpfs/backing image/loop mapping, and restores caller ownership of bind-mounted result paths.\n- The fixture is intentionally an unattached loop device rather than a live system disk. This safely proves host block read/write without risking corruption of the worker host.\n- `runtime_manifest.json` is rewritten on success and on premature failure; proof artifacts are finalized before they are hashed.\n","cve_id":"CVE-2026-96812","cwe_id":"CWE-367","source_url":"https://x.com/odinshell/status/2108308470676193290","package":{"name":"google/gvisor","ecosystem":"github","fixed_version":"release-20261005.0"},"reproduced_at":"2026-10-09T11:05:24.712620+00:00","duration_secs":11478.0,"tool_calls":578,"handoffs":3,"total_cost_usd":22.525674,"agent_costs":{"claim_matcher":0.042355,"judge":1.302011,"learning_policy":0.018134,"repro":14.205339,"support":0.073873,"vuln_variant":6.883962},"cost_breakdown":{"claim_matcher":{"gpt-5.4-mini-2026-03-17":0.042355},"judge":{"gpt-5.6-sol":1.302011},"learning_policy":{"gpt-5.4-mini-2026-03-17":0.018134},"repro":{"accounts/fireworks/models/kimi-k3":5.349169,"gpt-5.6-sol":8.85617},"support":{"accounts/fireworks/models/kimi-k3":0.073873},"vuln_variant":{"gpt-5.6-sol":6.883962}},"vulnerable_version_variant_outcome":"unknown","fix_bypass_outcome":"unknown","variant_disclosure_state":"unknown","quality":{"confidence":"high","idempotent_verified":false,"community_verifications":0},"evidence":{"workflow":{"profile":"known_vulnerability","schema_version":2,"stages":["support","claim_contract","repro","judge","vuln_variant"]}},"environment":{"sandbox_image":"ghcr.io/n3mes1s/pruva-sandbox@sha256:8096b2518d6022e13d68f885c3b8ded6b4fe607098b1a1ccbfb99abc004d1dc1"},"published_at":"2026-10-09T11:05:26.062534+00:00","retracted":false,"artifacts":[{"path":"bundle/repro/rca_report.md","filename":"rca_report.md","size":11313,"category":"analysis"},{"path":"bundle/repro/reproduction_steps.sh","filename":"reproduction_steps.sh","size":10643,"category":"reproduction_script"},{"path":"bundle/repro/results/backing-sha-fixed-1.txt","filename":"backing-sha-fixed-1.txt","size":98,"category":"other"},{"path":"bundle/repro/results/backing-sha-fixed-2.txt","filename":"backing-sha-fixed-2.txt","size":98,"category":"other"},{"path":"bundle/repro/results/backing-sha-vuln-1.txt","filename":"backing-sha-vuln-1.txt","size":97,"category":"other"},{"path":"bundle/repro/results/backing-sha-vuln-2.txt","filename":"backing-sha-vuln-2.txt","size":97,"category":"other"},{"path":"bundle/repro/results/cuse-swapper-fixed.log","filename":"cuse-swapper-fixed.log","size":0,"category":"log"},{"path":"bundle/repro/results/cuse-swapper-vuln.log","filename":"cuse-swapper-vuln.log","size":0,"category":"log"},{"path":"bundle/repro/results/fdwatch-fixed-1.log","filename":"fdwatch-fixed-1.log","size":0,"category":"log"},{"path":"bundle/repro/results/fdwatch-fixed-2.log","filename":"fdwatch-fixed-2.log","size":0,"category":"log"},{"path":"bundle/repro/results/fdwatch-vuln-1.log","filename":"fdwatch-vuln-1.log","size":0,"category":"log"},{"path":"bundle/repro/results/fdwatch-vuln-2.log","filename":"fdwatch-vuln-2.log","size":0,"category":"log"},{"path":"bundle/repro/results/guest-fixed-1.exit","filename":"guest-fixed-1.exit","size":2,"category":"other"},{"path":"bundle/repro/results/guest-fixed-2.exit","filename":"guest-fixed-2.exit","size":2,"category":"other"},{"path":"bundle/repro/results/guest-vuln-1.exit","filename":"guest-vuln-1.exit","size":2,"category":"other"},{"path":"bundle/repro/results/guest-vuln-2.exit","filename":"guest-vuln-2.exit","size":2,"category":"other"},{"path":"bundle/repro/results/host-marker-fixed-1.bin","filename":"host-marker-fixed-1.bin","size":64,"category":"other"},{"path":"bundle/repro/results/host-marker-fixed-2.bin","filename":"host-marker-fixed-2.bin","size":64,"category":"other"},{"path":"bundle/repro/results/host-observation-vuln-1.log","filename":"host-observation-vuln-1.log","size":266,"category":"log"},{"path":"bundle/repro/results/host-observation-vuln-2.log","filename":"host-observation-vuln-2.log","size":266,"category":"log"},{"path":"bundle/repro/results/racer-fixed-1.log","filename":"racer-fixed-1.log","size":118,"category":"log"},{"path":"bundle/repro/results/racer-vuln-1.log","filename":"racer-vuln-1.log","size":172,"category":"log"},{"path":"bundle/repro/results/racer-vuln-3.log","filename":"racer-vuln-3.log","size":46578,"category":"log"},{"path":"bundle/repro/results/runsc-version-fixed.txt","filename":"runsc-version-fixed.txt","size":32,"category":"other"},{"path":"bundle/repro/results/runsc-version-vuln.txt","filename":"runsc-version-vuln.txt","size":45,"category":"other"},{"path":"bundle/repro/results/setup-fixed-1.log","filename":"setup-fixed-1.log","size":238,"category":"log"},{"path":"bundle/repro/results/setup-fixed-2.log","filename":"setup-fixed-2.log","size":238,"category":"log"},{"path":"bundle/repro/results/setup-vuln-1.log","filename":"setup-vuln-1.log","size":249,"category":"log"},{"path":"bundle/repro/results/setup-vuln-2.log","filename":"setup-vuln-2.log","size":249,"category":"log"},{"path":"bundle/repro/results/source-identity.log","filename":"source-identity.log","size":250,"category":"log"},{"path":"bundle/repro/results/swapper-fixed-1.log","filename":"swapper-fixed-1.log","size":0,"category":"log"},{"path":"bundle/repro/results/swapper-fixed-2.log","filename":"swapper-fixed-2.log","size":0,"category":"log"},{"path":"bundle/repro/results/swapper-vuln-1.log","filename":"swapper-vuln-1.log","size":0,"category":"log"},{"path":"bundle/repro/results/swapper-vuln-2.log","filename":"swapper-vuln-2.log","size":0,"category":"log"},{"path":"bundle/repro/runtime_manifest.json","filename":"runtime_manifest.json","size":7206,"category":"other"},{"path":"bundle/repro/src/Dockerfile.builder","filename":"Dockerfile.builder","size":365,"category":"other"},{"path":"bundle/repro/src/cuse_identity_inside.sh","filename":"cuse_identity_inside.sh","size":1679,"category":"other"},{"path":"bundle/repro/src/guest_block_racer.c","filename":"guest_block_racer.c","size":2581,"category":"other"},{"path":"bundle/repro/src/guest_cuse_identity.c","filename":"guest_cuse_identity.c","size":1810,"category":"other"},{"path":"bundle/repro/src/guest_racer.c","filename":"guest_racer.c","size":6003,"category":"other"},{"path":"bundle/repro/src/host_block_swapper.c","filename":"host_block_swapper.c","size":2063,"category":"other"},{"path":"bundle/repro/src/host_cuse_swapper.c","filename":"host_cuse_swapper.c","size":1206,"category":"other"},{"path":"bundle/repro/src/host_swapper.c","filename":"host_swapper.c","size":2478,"category":"other"},{"path":"bundle/repro/src/race_block_inside.sh","filename":"race_block_inside.sh","size":6876,"category":"other"},{"path":"bundle/repro/src/race_inside.sh","filename":"race_inside.sh","size":4825,"category":"other"},{"path":"bundle/repro/validation_verdict.json","filename":"validation_verdict.json","size":1576,"category":"other"}]}