CVE-2026-96812: Verified Reproduction
CVE-2026-96812: gVisor directfs openHandle host-FD identity TOCTOU → guest-to-host sandbox escape follow-up to CVE-2026-96812, no separate CVE assigned
CVE-2026-96812 is verified against google/gvisor · github. Fixed in release-20261005.0. This high reproduction includes runnable sandbox proof, artifacts, and a plain-text agent view under REPRO-2026-00382.
What Is CVE-2026-96812?
CVE-2026-96812 is a high-severity vulnerability affecting google/gvisor. Pruva has independently reproduced it and publishes a verified, runnable proof-of-concept (reproduction REPRO-2026-00382).
CVE-2026-96812 Severity
CVE-2026-96812 is rated high severity.
High — serious impact or readily exploitable. Prioritize remediation.
How to Reproduce CVE-2026-96812
pruva-verify REPRO-2026-00382 curl -O https://www.pruva.dev/api/v1/reproductions/REPRO-2026-00382/artifacts/bundle/repro/reproduction_steps.sh && chmod +x reproduction_steps.sh && ./reproduction_steps.sh Proof of Reproduction for CVE-2026-96812
- reached the target end-to-end
- full exploit chain demonstrated
- on the real production code path
- high confidence
- the upstream fix blocks the same trigger
After the guest primes a regular directfs dentry, a host-side attacker atomically RENAME_EXCHANGEs its backing name with a selected host block-device node while guest O_TRUNC opens race revalidation against openHandle's open-by-name.
- production runsc --directfs=true --platform=systrap guest open(/race/target, O_RDWR|O_TRUNC)
- directfsInode.openHandle() unchecked openat()
- gofer preadv2/pwritev2 on the swapped host loop block descriptor
How the agent worked
Root Cause and Exploit Chain for CVE-2026-96812
gVisor'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.
- Affected component:
google/gvisor,pkg/sentry/fsimpl/gofer/directfs_inode.go, function(*directfsInode).openHandle(). - Affected version tested:
release-20260928.0, commit6485c3fbdfe28172ced5d6aed49554cf494967db. - Fixed version tested:
release-20261005.0, commit97e8e896904cb364319cd9758241d3f0bd27a6f4. - Fix commit:
cd968a36e5557215b5dcb8989011ce0e558b5dd0. - 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.
- 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.
Impact Parity
- Disclosed/claimed maximum impact: guest-to-host sandbox escape, described as host-root code execution through an unsafe host device FD.
- Reproduced impact: repeatable guest-controlled read and write to an otherwise inaccessible host block device through production
runsc. In two vulnerable attempts, the guest readHOST_ONLY_SECRET_4ec7309afrom 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. - Parity:
partial. - 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.
Root Cause
The directfs dentry lifecycle separates identity validation from the host descriptor later used for data operations:
The guest resolves
/race/targetwhile it is a stable regular file, so gVisor creates and caches a regular-file dentry/inode.Directfs revalidation examines a child reached from a parent control descriptor and validates metadata associated with that lookup.
directfsInode.openHandle()subsequently obtains a usable handle by resolving the dentry name again:flags |= hostOpenFlags openFD, err := unix.Openat(parent.inode.impl.(*directfsInode).controlFD, d.name, int(flags), 0) if err != nil { return noHandle, err } return handle{fd: int32(openFD)}, nilIn release-20260928.0, the returned
openFDis accepted withoutfstat()or comparison against the cached inode type/device identity. A hostrenameat2(RENAME_EXCHANGE)can therefore let revalidation observe the original regular inode while the lateropenat()observes a device node at the same name.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()andpwrite()directly affected host block storage.
The fix 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.
Reproduction Steps
- Run
bundle/repro/reproduction_steps.shfrom any directory. It acceptsPRUVA_ROOT; the default resolves the bundle directory portably. - The script reads
bundle/project_cache_context.json, reuses the prepared gVisor repository and build cache when available, and otherwise createsbundle/artifacts/gvisor-cache. - It anchors source/build identity to vulnerable commit
6485c3f...and fixed commit97e8e896..., verifies that fix commitcd968a36...is absent/present respectively, and checks the actualFstat(openFD)patch hunk. - It builds or reuses genuine optimized
runscbinaries, compiles the static guest and host racers, and starts a privileged Docker host fixture. - 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=systrapsandbox, primes the target as a regular dentry, then begins atomic regular-file/block-device swaps. - The guest races
open(O_RDWR|O_TRUNC), reads the host secret, and attempts to write a unique marker at offset 8192. Afterrunscexits, the host reads that offset directly from the backing image. - 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.
Expected summary:
vulnerable_host_read_write_marker_attempts=2/2
fixed_host_write_marker_attempts=0/2
fixed_rejection_attempts=2/2
vulnerable_cuse_fd_control=1/1
fixed_cuse_estale_control=1/1
Evidence
Primary current-run evidence is under bundle/repro/results/ and is digest-bound by bundle/repro/runtime_manifest.json.
results/source-identity.log: exact vulnerable/fixed commits and patch presence.results/runsc-version-{vuln,fixed}.txt: product versions exercised.results/guest-vuln-1.log:GUEST_HOST_BLOCK_READ: secret=HOST_ONLY_SECRET_4ec7309a opens=2 offset=4096 bytes=4096 GUEST_HOST_BLOCK_WRITE: marker=GUEST_HOST_MARKER_vuln_1_91d7c3ee offset=8192 bytes=4096 errno=0results/guest-vuln-2.log: an independent vulnerable process repeats the read/write after 11 opens.results/host-observation-vuln-1.logandhost-observation-vuln-2.log: direct host inspection reportsMATCH=trueand includes marker bytes in hexadecimal.results/host-marker-vuln-{1,2}.bin: exact marker bytes read from host-only backing images.results/guest-fixed-1.logandguest-fixed-2.log: hundreds of thousands of block-device attempts produce no win; device-node windows returnEPERMwhile regular-file windows remain usable.results/cuse-guest-vuln.log: the vulnerable build accepts the raced/dev/cusedescriptor (GUEST_CUSE_FD_OPEN).results/cuse-guest-fixed.log: the fixed build has no CUSE win and reports large nonzeroestalecounts for the identical synchronized swap, directly demonstrating the fix’s requiredESTALEidentity failure.results/host-observation-fixed-{1,2}.logandhost-marker-fixed-{1,2}.bin: host-side negative controls are all zero and reportMATCH=false.results/summary.txt: aggregate 2/2 vulnerable effects versus 0/2 fixed markers and 2/2 fixed rejection.bundle/logs/reproduction_steps.log: complete final product-run diagnostics.bundle/logs/final_run1_console.logandfinal_run2_console.log: two consecutive successful top-level executions.bundle/repro/runtime_manifest.json: source identity, runtime stack, artifact list, and SHA-256 map.
The final run used Linux 6.8.0-142-generic, x86-64, Docker 29.1.3, gVisor systrap, directfs enabled, and no sanitizer.
The 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.
Recommendations / Next Steps
- Upgrade to
release-20261005.0or later. - 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 returnESTALE/an error on mismatch. - Add a regression test that synchronizes a cached regular dentry with an atomic replacement before
openHandle(), covering character and block device substitutions. - Test all open modes that force a fresh handle, especially
O_TRUNC, and test repeated revalidation/open races. - 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.
- 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.
Additional Notes
- The final script passed twice consecutively in the current runtime. Each invocation itself performs two vulnerable and two fixed process attempts.
- The script is self-contained apart from declared source helpers and standard network/build prerequisites. It recompiles helpers and rebuilds
runscif compatible cached binaries are unavailable. - It bounds every
runscinvocation withtimeout, isolates each attempt with a fresh bundle/runsc root/tmpfs/backing image/loop mapping, and restores caller ownership of bind-mounted result paths. - 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.
runtime_manifest.jsonis rewritten on success and on premature failure; proof artifacts are finalized before they are hashed.
CVE-2026-96812 Reproduction Transcript
The agent's step-by-step process — every tool call, every handoff, the moment the exploit fired.
Full session Replay every step — scrub the timeline or play it back.
ls -la /dev/kvm /dev/cuse 2>&1; cat /proc/sys/kernel/randomize_va_space 2>/dev/null; uname -a; docker ps 2>&1 | head -3; which bazel bazelisk go 2>&1; nproc; free -g | head -2crw------- 1 root root 10, 203 Oct 6 21:08 /dev/cuse
crw-rw---- 1 root kvm 10, 232 Oct 6 21:08 /dev/kvm
2
Linux pruva-beef-01 6.8.0-142-generic #142-Ubuntu SMP PREEMPT_DYNAMIC Wed Sep 2 14:24:27 UTC 2026 x86_64 x86_64 x86_64 GNU/Linux
WARNING: Error loading config file: open /home/pruva/.docker/config.json: permission denied
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
8
total used free shared buff/cache availablels -la /data/pruva/project-cache/.run-views/c9464895c4fab7bb76237d97a86fc2a24c4f1cc928905770ac96a8f412e4c837/ 2>&1; echo ---; ls -la /data/pruva/runs/cd137c5d-1a98-4ab9-a1cf-0989f9cb6d93/bundle/.pruva /data/pruva/runs/cd137c5d-1a98-4ab9-a1cf-0989f9cb6d93/bundle/learning /data/pruva/runs/cd137c5d-1a98-4ab9-a1cf-0989f9cb6d93/bundle/support /data/pruva/runs/cd137c5d-1a98-4ab9-a1cf-0989f9cb6d93/bundle/repro 2>&1{"cwd":"/data/pruva/runs/cd137c5d-1a98-4ab9-a1cf-0989f9cb6d93","duration_ms":6,"exit_code":0,"stderr":"","stdout":"total 16\ndrwx------ 4 pruva pruva 4096 Oct 9 07:11 .\ndrwx------ 5 pruva pruva 4096 Oct 9 07:11 ..\ndrwx------ 2 pruva pruva 4096 Oct 9 07:11 .pruva\ndrwx------ 2 pruva pruva 4096 Oct 9 07:11 repo-mirrors\n---\n/data/pruva/runs/cd137c5d-1a98-4ab9-a1cf-0989f9cb6d93/bundle/.pruva:\ntotal 12\ndrwx------ 2 pruva pruva 4096 Oct 9 07:11 .\ndrwx------ 8 pruva pruva 4096 Oct 9 07:12 ..\n-rw------- 1 pruva pruva 228 Oct 9 07:11 run_attempt.json\n\n/data/pruva/runs/cd137c5d-1a98-4ab9-a1cf-0989f9cb6d93/bundle/learning:\ntotal 28\ndrwx------ 2 pruva pruva 4096 Oct 9 07:12 .\ndrwx------ 8 pruva pruva 4096 Oct 9 07:12 ..\n-rw------- 1 pruva pruva 853 Oct 9 07:12 exploit_knowledge_preflight.json\n-rw------- 1 pruva pruva 5683 Oct 9 07:11 policy_inputs.json\n-rw------- 1 pruva pruva 4473 Oct 9 07:11 policy_result.json\n\n/data/pruva/runs/cd137c5d-1a98-4ab9-a1cf-0989f9cb6d93/bundle/repro:\ntotal 8\ndrwx------ 2 pruva pruva 4096 Oct 9 07:12 .\ndrwx------ 8 pruva pruva 4096 Oct 9 07:12 ..\n\n/data/pruva/runs/cd137c5d-1a98-4ab9-a1cf-0989f9cb6d93/bundle/support:\ntotal 8\n… [truncated]sudo -n true && echo SUDO_OK; for r in 20260928 20261005; do echo "== $r =="; curl -sI "https://storage.googleapis.com/gvisor/releases/release/$r/x86_64/runsc" | head -5; done== 20260928 == HTTP/2 404 content-type: application/xml; charset=UTF-8 x-guploader-uploadid: AP6rU81Jss3YkkUy8mteKk75MHGIfQZ2VWz9pJyN5Lb8QxoV5p9oleqYxs4DMAohlHq852F7 content-length: 207 date: Fri, 09 Oct 2026 07:15:50 GMT == 20261005 == HTTP/2 404
id; echo ---; curl -sI https://github.com/google/gvisor | head -3; echo ---; git ls-remote --tags https://github.com/google/gvisor 'refs/tags/release-20260928*' 'refs/tags/release-20261005*' 2>&1uid=1000(pruva) gid=1001(pruva) groups=1001(pruva),27(sudo),993(kvm),1000(docker)
---
HTTP/2 200
date: Fri, 09 Oct 2026 07:16:27 GMT
content-type: text/html; charset=utf-8
---
6882e7c944eedfeac97f0663a583c159b8f127a2 refs/tags/release-20260928.0
6485c3fbdfe28172ced5d6aed49554cf494967db refs/tags/release-20260928.0^{}docker images 2>&1 | head -10; echo ---; docker run --rm --privileged --device /dev/kvm debian:bookworm-slim bash -c 'id; ls -l /dev/kvm /dev/cuse 2>&1' 2>&1 | tail -8{"cwd":"/data/pruva/runs/cd137c5d-1a98-4ab9-a1cf-0989f9cb6d93","duration_ms":12324,"exit_code":0,"stderr":"","stdout":"WARNING: Error loading config file: open /home/pruva/.docker/config.json: permission denied\nWARNING: This output is designed for human readability. For machine-readable output, please use --format.\nIMAGE ID DISK USAGE CONTENT SIZE EXTRA\nalpine:3.22 14358309a308 12.8MB 3.88MB \ndocker:27-dind-rootless@sha256:e2ac8e8f66ae21a060b0a8e3005c70f6ed9441aabf409434463d1f6eecd38026 e2ac8e8f66ae 607MB 159MB \nghcr.io/n3mes1s/pruva-sandbox@sha256:8096b2518d6022e13d68f885c3b8ded6b4fe607098b1a1ccbfb99abc004d1dc1 8096b2518d60 2.16GB 553MB \ngradle:4.10.3-jdk8 393cd6c24d69 1.1GB 324MB \nhermes-r4-repro:1 b34f5cb… [truncated]mkdir -p /data/pruva/runs/cd137c5d-1a98-4ab9-a1cf-0989f9cb6d93/bundle/repro/src; cd /data/pruva/runs/cd137c5d-1a98-4ab9-a1cf-0989f9cb6d93/bundle/repro/src; for t in release-20260928.0 release-20261005.0; do curl -sf "https://raw.githubusercontent.com/google/gvisor/$t/pkg/sentry/fsimpl/gofer/directfs_inode.go" -o "directfs_inode.$t.go" && echo "fetched $t"; done; echo ---; sed -n '215,275p' directfs_inode.release-20260928.0.go{"cwd":"/data/pruva/runs/cd137c5d-1a98-4ab9-a1cf-0989f9cb6d93","duration_ms":461,"exit_code":0,"stderr":"","stdout":"fetched release-20260928.0\nfetched release-20261005.0\n---\n\n\ttemp.d.init()\n\t// If this dentry adopted a cached inode that already carries a bound-socket\n\t// endpoint (a hard link to, or re-walk of, an existing bound socket), hold\n\t// the extra reference that dentry.refs keeps for endpoint-bearing dentries.\n\t// The bind path takes this reference itself, over a fresh inode.\n\tif temp.d.inode.endpoint != nil {\n\t\ttemp.d.IncRef()\n\t}\n\tfs.syncMu.Lock()\n\tfs.syncableDentries.PushBack(&temp.d.syncableListEntry)\n\tfs.syncMu.Unlock()\n\treturn &temp.d, nil\n\n}\n\n// Precondition: fs.renameMu is locked.\nfunc (i *directfsInode) openHandle(ctx context.Context, flags uint32, d *dentry) (handle, error) {\n\tparent := d.parent.Load()\n\tif parent == nil {\n\t\t// This is a mount point. We don't have parent. Fallback to using lisafs.\n\t\tif !i.controlFDLisa.Ok() {\n\t\t\tpanic(\"directfsInode.controlFDLisa is not set for mount point dentry\")\n\t\t}\n\t\topenFD, hostFD, err := i.controlFDLisa.OpenAt(ctx, flags)\n\t\tif err != nil {\n\t\t\treturn noHandle, err\… [truncated]Artifacts and Evidence for CVE-2026-96812
Scripts, logs, diffs, and output captured during the reproduction.
How to Fix CVE-2026-96812
Upgrade google/gvisor · github to release-20261005.0 or later.
FAQ: CVE-2026-96812
Is CVE-2026-96812 exploitable?
How severe is CVE-2026-96812?
What type of vulnerability is CVE-2026-96812?
Is there a fix for CVE-2026-96812?
How can I reproduce CVE-2026-96812?
Is the CVE-2026-96812 reproduction verified?
References for CVE-2026-96812
Authoritative sources for CVE-2026-96812 — official vulnerability databases and the upstream advisory. Pruva's reproduction verifies the issue firsthand; these are the primary records to corroborate it.