NOFire.ai
Blog/Engineering

Defense in depth in a microVMM: the virtio-fs path bug our sandbox caught

Docker's macOS VMM lost the host filesystem to a virtio-fs path re-resolution bug (CVE-2026-77179). We replayed the sequence against hvi, our own microVMM. Its handle-based write path held, but one stateless read path exposed the same primitive. The deny-default Seatbelt profile stopped it before the host file could be opened. This post explains the FUSE state involved, the residual exposure, and the descriptor-relative fix.

Engineering · defense in depth virtiofsnofire · runtime
● liveGraphRAG

This post discusses CVE-2026-77179, a public and fixed vulnerability in Docker's macOS VMM. Docker describes the affected versions and remediation in its security announcement; Oren Yomtov and the Accomplish team, who reported the issue, published the original technical write-up. We also describe a narrower issue in hvi that we fixed before publishing.


TL;DR

A microVMM mediates every request made by a guest we assume is hostile. A path-resolution bug in a device backend can therefore become a host compromise. That is why the VMM itself needs a second, independent boundary.

We replayed CVE-2026-77179 against hvi, the microVMM used by brig and hull on macOS. We found three things:

  • Docker's published write primitive did not reproduce. hvi pins a host file descriptor for every successful FUSE OPEN. A later WRITE uses that descriptor, so replacing a parent directory with a symlink cannot retarget it. The bytes land in the unlinked inode, exactly as POSIX descriptor semantics require.
  • One compatibility branch in READ did reproduce the underlying path primitive. A request with fh == 0 reopened the node from its cached path string. If the guest replaced an intermediate directory with a symlink, the macOS kernel followed that link and resolved the string outside the export. The resulting exposure was one arbitrary host-file read per request—not a write and not, by itself, code execution.
  • The Seatbelt profile blocked that open. With the sandbox disabled, the probe returned the outside fixture's bytes. With the production profile enabled, the same request returned EPERM. We still fixed the path layer in hvi-vmm#167, because containment must not depend on the second line alone.

This finding is specific to the macOS path. Docker's advisory lists only macOS as affected, and brig uses a completely different filesystem stack on Linux. There, brig drives containerd through nerdctl and the urunc shim. urunc builds a private mount namespace containing the container rootfs and only the configured mounts, then starts the separate virtiofsd process against that namespaced tree. Guest FUSE requests terminate in virtiofsd, not in hvi's in-process macOS backend. The cached-path branch described here is absent, and the host paths outside the constructed namespace are not visible to the file server. Brig on Linux is therefore unaffected by this issue.

Why the boundary needs a boundary

hvi assumes that guest RAM, virtqueue descriptors, network frames and filesystem requests are attacker-controlled. Those inputs are parsed by device backends in the VMM process, which initially runs with the authority of the host user. Without process confinement, a memory-safety error or a confused-deputy bug in any backend begins with all of that authority available.

On macOS, hvi gives it up before servicing the first guest request. It installs a deny-default Seatbelt profile with one rule for each canonical virtio-fs export: file-read* for a read-only export, and file-read* file-write* for a writable one.

The profile can remain small because hvi acquires almost every host resource before entering it. Guest RAM is a POSIX shared-memory object whose name is unlinked immediately after mapping. The block backing file, event ledger, gateway socket, control sockets and stdio are already open. Seatbelt mediates the acquisition of named resources; it does not revoke access to descriptors the process already holds. Those resources therefore need no pathname exception in the profile.

virtio-fs is different. Its files and directories are necessarily opened on demand as the guest sends FUSE requests. Those lazy opens are why the profile grants access to the export roots—and nowhere else.

Brig reaches the same defense-in-depth goal differently on Linux. urunc creates a mount namespace, makes the relevant mounts private so changes cannot propagate back to the parent namespace, and assembles the monitor's view with bind mounts for the container rootfs and explicitly configured volumes. It then changes root into that view and launches virtiofsd with the namespaced rootfs as --shared-dir. The file server can resolve only what that namespace exposes.

That is a different trust boundary, not a port of the macOS implementation. The macOS path parses FUSE requests inside hvi and constrains lazy host opens with Seatbelt. The Linux path delegates FUSE to a separate, upstream file server whose entire view has already been reduced by the mount namespace. A defect in virtiofsd would still matter, but the hvi cached-string fallback—and therefore this particular primitive—does not exist in brig's Linux stack.

The primitive in Docker's VMM

The important distinction in FUSE is between a nodeid and a file handle. LOOKUP turns a name into a nodeid, an identity the server must remember. OPEN may then return an opaque fh, which subsequent READ and WRITE requests carry back to the server. A robust server can bind that fh to an already-open host descriptor; renaming or unlinking the path does not change what the descriptor references.

According to the reporters' analysis, Docker's macOS file server instead tried to recover a file first by inode through macOS volfs, then by a path string saved during lookup. The exploit kept the guest file open, unlinked it so the volfs lookup stopped resolving, removed its parent, and recreated that parent as a symlink to a host directory outside the share. The fallback string was lexically unchanged, but its meaning in the host namespace was now attacker-controlled.

For a concrete macOS target, consider a host user's SSH private key. The relevant state transition is:

cached string:         /Users/alice/project/pivot/id_ed25519
before the swap:       pivot/ is a directory inside the export
after the swap:        pivot -> /Users/alice/.ssh
host resolution:       /Users/alice/.ssh/id_ed25519

The server can check that the cached string begins with the export root and still lose: the unsafe operation is the kernel's later resolution of the string. An intermediate symlink changes the object named without changing the string being checked. This is a pathname re-resolution bug with a time-of-check/time-of-use shape, not a failure that can be repaired by another lexical-prefix test.

Docker's implementation made that primitive readable and writable with the VMM user's permissions, which is why Docker rates the issue critical and notes the possibility of host code execution. Docker fixed it in Docker Desktop 4.88.0 and Docker Sandboxes 0.42.0.

Replaying the sequence against hvi

hvi has two tables with deliberately different semantics:

  • A node table maps FUSE nodeid values to metadata and one or more remembered names. Names are necessary because FUSE operations such as LOOKUP begin with a parent node and a component.
  • A handle table maps each nonzero fh to an open Rust File, and therefore to a host descriptor. READ and WRITE through a valid handle call pread or pwrite on that descriptor; they do not look the name up again.

That difference is why the published write sequence stopped at the first line of defense. We drove it at the FUSE protocol boundary against the real device: create and open a file, unlink it, replace its parent directory with a symlink, then write through the still-live fh. The write succeeded, but it modified only the unlinked inode pinned by the descriptor. No file appeared at the symlink target.

When hvi does need to resolve a name, its normal path walks from the export's directory descriptor. It opens one component at a time with openat(..., O_NOFOLLOW). The kernel never follows a symlink on the device's behalf: hvi reads the link itself, treats an absolute target as relative to the guest's export root, and clamps .. at that root. The final operation is an *at call on the chosen parent descriptor and a single component name.

One branch had escaped that conversion. The earliest version of the backend supported a stateless READ, and its compatibility case for fh == 0 still reopened the node by cached pathname:

let path = self.node_path(node)?;
let meta = Stat::lstat(path)?;
let file = open_host_file(path, 0, false, 0).map_err(io_errno)?;

lstat avoids following a symlink in the final component; it does not prevent the kernel from following a symlink in an intermediate component. The subsequent open resolves the whole string again. Once a parent directory has been swapped, both calls can reach the host path outside the export.

We planted a disposable fixture where a sensitive file such as /Users/alice/.ssh/id_ed25519 would sit, then issued READ(nodeid, fh=0) directly. With confinement disabled, the reply contained the fixture bytes. That is the primitive. We did not use or print a real private key.

The exposure, precisely bounded

The vulnerable branch only serves READ. It opens one regular file read-only, copies at most the requested range into the FUSE reply, and closes it. WRITE has no stateless branch: it requires a valid fh and writes through the descriptor stored in the handle table. The observed exposure was therefore arbitrary read of one host file at a time with the VMM user's permissions. It was not an arbitrary write, process execution, or a general host filesystem mount.

We also instrumented hvi to count zero-handle reads. A stock Linux FUSE client, after receiving a handle from OPEN, returned that handle on every READ in a read-heavy live workload: dd, cat, cp, tar, grep, a cache drop and a complete ls -R /. We observed no fh == 0 request.

That observation is useful for understanding reachability, but it is not a mitigation. The guest kernel is inside the threat boundary. An attacker who controls it can construct a valid FUSE request with fh == 0, regardless of what an ordinary Linux client emits.

brig v0.2.0 adds another reachability constraint: it boots a signed kernel and initrd bundle, and refuses an invalid signature unless verification is explicitly disabled. Reaching the hvi branch in that release therefore required two operator changes: boot a modified guest kernel capable of sending the request, and start the VMM with its sandbox disabled. Neither is a default. The released brig configuration exposed no arbitrary host read or write through this path.

What actually stopped the read

Seatbelt did. We ran the same binary and the same FUSE request twice; the only changed condition was whether hvi had installed its production profile:

target:                         /Users/alice/.ssh/id_ed25519 (disposable fixture)
sandbox disabled, fh == 0:      READ returned the fixture bytes
sandbox enabled,  fh == 0:      READ failed with errno 1 (EPERM)

The cached string still resolved through the swapped parent in both runs. With Seatbelt enabled, however, the kernel evaluated the resolved target against the process profile. Because the SSH directory was outside every exported subtree, the attempted open was denied before hvi obtained a descriptor.

hvi also ships a sandbox-selftest command that enters the production profile in-process and probes both sides of every important rule: opens outside exports must fail, reads inside read-only exports must succeed, writes inside writable exports must succeed, and attempts to escape an export through a symlink or hard link must fail. This matters because a profile that parses and installs has not yet proved that the kernel enforces the policy we intended.

We then exercised the integrated stack: a clean brig v0.2.0 installation with hull rc28, a stock signed guest, two writable virtio-fs exports, and the published open-unlink-swap sequence plus absolute-symlink, climbing-symlink and stale-node variants. No operation reached the host canary outside the exports.

Fixing the first line

The compatibility branch now uses the same descriptor-relative machinery as every other file opener:

let (dirfd, name) = self.entry_at(node)?;
let meta = Stat::at(dirfd, &name)?;
let file = open_host_file_at(dirfd, &name, 0, false, 0)?;

entry_at walks the cached relative name from the pinned export root, returns the chosen parent as a descriptor, and returns only the final component as name. Stat::at and open_host_file_at operate on that pair. There is no absolute cached string for the kernel to resolve, and swapping a directory elsewhere in the host namespace cannot redirect an already-open parent descriptor.

The patch is hvi-vmm#167. Its regression tests drive the sequence at the FUSE wire boundary, assert that a handle-based write remains attached to the unlinked inode, assert that a zero-handle read cannot return the outside fixture, and exercise the remaining stale-node handlers so a later change cannot silently reintroduce pathname resolution.

Takeaways

Treat nodeid and fh as different kinds of authority. A node identifies filesystem state; a handle should pin the object on which I/O will operate. Turning a handle back into a name discards that guarantee and reintroduces attacker-controlled namespace resolution.

Resolve from a trusted directory descriptor. O_NOFOLLOW on the final component is not enough: the kernel may already have followed an intermediate link. Start from the export descriptor, choose every hop, and perform the final operation with an *at syscall.

Confine the component that enforces the boundary. A VMM is too exposed to run with the host user's ambient authority while parsing guest input. On macOS, hvi narrows named-resource access with Seatbelt. On Linux, brig gives a separate virtiofsd only the filesystem view assembled inside its mount namespace.

Test the failure of the first line. Defense in depth is an empirical claim. Running a matched pair—with the same request succeeding before confinement and failing after it—turns “the sandbox should catch this” into evidence.

If you want the sandbox without building it yourself, brig runs coding agents in these microVMs on macOS. hvi, hull and brig are Apache-2.0 projects under brig-sh. If you find a boundary failure, hvi's security policy provides a private reporting path.

Open source

Don't trust the sandbox. Test it.

brig runs each coding agent in its own microVM, with its own Linux kernel, on your Mac or Linux machine. Apache 2.0, no account, no gated features. Every claim comes with the command that checks it.