Skip to content

Track guest layered root bugs: from a thread with its own descriptor table, the root's mount-point checks read another descriptor's mount #795

Open

Description

Bug-hunt tracking issue for the guest layered root, nvx::guest::root in nvx-rs/src/guest/root.rs and root/: the assembly of a guest's root, its live shares, and the kernel interface through which they check mount points. It arrived with #774 and #775.

Baseline: origin/dev at c406f80d. Every permalink uses this SHA.

Hunt: one time-boxed pass of about 45 minutes. It started at 05:00 −03:00 on 2026-10-23, and finding 1 was confirmed at about 05:26. No repository files were modified and nothing was committed. The reproduction ran on a Windows 11 host under WSL 2 (6.18.33.2-microsoft-standard-WSL2), with the pinned Rust 1.95.0. It used a git archive copy of the baseline's nvx-rs, outside the repository, with one added test module. It ran as root in a private mount namespace (unshare --mount --propagation private).

Selection: no bug-hunt issue covers this module, so this is a new issue. Open #789 covers the nvx::exec engine, #787 sandbox block device paths, #757 the workload identity, #756 the network options, #745 the kernel command line, #733 resource limits, and #720 the host VMM supervisor. Closed #486 covered live shares in the former shell agent (guest/common/nvx-init-agent), #736 guest boot and the test harness, and #737 the managed agent's output limit.

Findings

# Severity Finding Status
1 Low-to-medium impact, low current exposure From a thread with a descriptor table of its own, sys::mount_id reads another descriptor's mount, so the root's mount-point checks pass an occupied mount point, and the overlay check reads another mount's options Confirmed; open
  • 1. Resolve sys::mount_id's descriptor in the calling thread's table, or take the mount ID from statx on the descriptor, read /proc/thread-self/mountinfo in SystemKernel::mount_options, and add a privileged regression test that runs the root's checks after sys::unshare_standard_descriptors().

1. From a thread with a descriptor table of its own, the root's mount-point checks read the mount of another descriptor

Severity: low-to-medium impact, low current exposure.

  • Impact: when it triggers, the root's refusals of occupied mount points are silently skipped. This includes the refusal of an upper of either kind that a failed teardown left mounted, which 28524cef added. A live share is mounted over an occupied staging instead of being refused. The overlay check verifies the options of whatever mount the process-wide descriptor names, not the overlay's. Depending on the module defaults, it then refuses a correct overlay or passes without checking it.
  • Exposure: the root operations are public API of the nvx crate. The crate supports threads with a descriptor table of their own for the mapper's operations: 69ab1cd5 moved them to /proc/thread-self/fd for exactly this case, and a_thread_with_a_descriptor_table_of_its_own_tears_its_mappings_down tests it. The root's documented preconditions (root.rs#L23-L26) are the root user and the initial mount and cgroup namespaces, which such a thread meets. No in-tree caller runs the root's operations from such a thread today, so no shipped path reaches this yet.

Where

Trigger: call assemble or Root::mount_share from a thread whose descriptor table isn't the process's first thread's, for example after unshare(CLONE_FILES) or close_range(..., CLOSE_RANGE_UNSHARE), while the first thread's table holds a descriptor at the number that mount_id's open gets in the calling thread.

Expected: is_mount_point reports a mount point from any thread of the process, and mount_id returns the mount of the descriptor that it opened, as the mapper's operations do since 69ab1cd5.

Actual: the reproduction below mounts a tmpfs and queries it from two threads. The thread that shares the process's table gets Ok(true) and rw. After sys::unshare_standard_descriptors(), the crate's own helper for such a thread, mount_id returns 538 for both the mount point and its parent. 538 is the mount of the process-wide descriptor 3, /; the real IDs are 639 and 637. is_mount_point then returns Ok(false), and mount_options returns the root ext4's options:

running 1 test
tmpfs mounted at /tmp/.tmp223NMD/scratch: mount ID 639; its parent's: 637
descriptor held in the process-wide table: 3
thread that shares the process's table: is_mount_point = Ok(true)
thread with a table of its own: is_mount_point = Ok(false); mount_id(point) = Ok(538); mount_id(parent) = Ok(538)
mount_options(point), sharing the process's table: Ok("rw")
mount_options(point), with a table of its own: Ok("rw,discard,errors=remount-ro,data=ordered")
test guest::root::hunt::hunt_mount_point_checks_from_a_thread_with_a_table_of_its_own ... FAILED

thread 'guest::root::hunt::hunt_mount_point_checks_from_a_thread_with_a_table_of_its_own' (824) panicked at src/guest/root/hunt.rs:51:5:
assertion `left == right` failed: from a thread with a descriptor table of its own, the tmpfs mount point is not a mount point
  left: Ok(false)
 right: Ok(true)

For comparison, the repository's guest::mappings::privileged::a_thread_with_a_descriptor_table_of_its_own_tears_its_mappings_down passes in the same build and namespace. So do all 9 guest::mappings::privileged tests.

Reproduction

I added this as src/guest/root/hunt.rs, with #[cfg(test)] mod hunt; appended to src/guest/root.rs, in a git archive copy of the baseline's nvx-rs:

use std::os::fd::AsRawFd;

use super::kernel::{Kernel, SystemKernel};
use crate::guest::sys::{self, MountFlags};

#[test]
#[ignore = "needs root"]
fn hunt_mount_point_checks_from_a_thread_with_a_table_of_its_own() {
    let directory = tempfile::tempdir().unwrap();
    let point = directory.path().join("scratch");
    std::fs::create_dir(&point).unwrap();
    let target = sys::path_c_string(&point).unwrap();
    sys::mount(Some(c"tmpfs"), &target, Some(c"tmpfs"), MountFlags::empty(), None).unwrap();
    let point_id = sys::mount_id(&target).unwrap();
    let parent_id = sys::mount_id(&sys::path_c_string(directory.path()).unwrap()).unwrap();
    let parent = directory.path().to_owned();
    let thread_point = point.clone();
    let (shared, own, own_point_id, own_parent_id, held, options) = std::thread::spawn(move || {
        let mut kernel = SystemKernel;
        let shared = Kernel::is_mount_point(&mut kernel, &thread_point).map_err(|e| e.to_string());
        let shared_options =
            Kernel::mount_options(&mut kernel, &thread_point).map_err(|e| e.to_string());
        // A descriptor of the process-wide table, on another mount than the tmpfs, which the
        // thread's own table will not hold.
        let held = std::fs::File::open("/").unwrap();
        let number = held.as_raw_fd();
        std::mem::forget(held);
        sys::unshare_standard_descriptors().unwrap();
        let own = Kernel::is_mount_point(&mut kernel, &thread_point).map_err(|e| e.to_string());
        let own_point_id = sys::mount_id(&sys::path_c_string(&thread_point).unwrap());
        let own_parent_id = sys::mount_id(&sys::path_c_string(&parent).unwrap());
        let own_options =
            Kernel::mount_options(&mut kernel, &thread_point).map_err(|e| e.to_string());
        (shared, own, own_point_id, own_parent_id, number, (shared_options, own_options))
    })
    .join()
    .unwrap();
    sys::unmount(&target).unwrap();
    println!("tmpfs mounted at {}: mount ID {point_id}; its parent's: {parent_id}", point.display());
    println!("descriptor held in the process-wide table: {held}");
    println!("thread that shares the process's table: is_mount_point = {shared:?}");
    println!(
        "thread with a table of its own: is_mount_point = {own:?}; \
         mount_id(point) = {own_point_id:?}; mount_id(parent) = {own_parent_id:?}"
    );
    println!("mount_options(point), sharing the process's table: {:?}", options.0);
    println!("mount_options(point), with a table of its own: {:?}", options.1);
    assert_eq!(shared, Ok(true));
    assert_eq!(
        own,
        Ok(true),
        "from a thread with a descriptor table of its own, the tmpfs mount point is not a mount point"
    );
}
cd nvx-rs
NVX_BUNDLE=skip CARGO_TARGET_DIR=../target cargo +1.95.0 test --locked --all-features --lib --no-run
sudo unshare --mount --propagation private ../target/debug/deps/nvx-<hash> \
  --ignored --exact guest::root::hunt::hunt_mount_point_checks_from_a_thread_with_a_table_of_its_own --nocapture

Suggested fix:

  1. In sys::mount_id, take the mount ID of the descriptor in the calling thread's table. Reading /proc/thread-self/fdinfo/{fd}, as 69ab1cd5 did for /proc/self/fd, fixes it. Avoiding procfs would be better: statx(fd, "", AT_EMPTY_PATH, STATX_MNT_ID) gives stx_mnt_id, the ID that mountinfo's first field shows, on Linux 5.8 and later.
  2. In SystemKernel::mount_options, read /proc/thread-self/mountinfo, as the privileged tests do, so that the table is the calling thread's.
  3. Beside a_thread_with_a_descriptor_table_of_its_own_tears_its_mappings_down, add a privileged test that runs is_mount_point and mount_options, or a root's checks, after sys::unshare_standard_descriptors().

What this pass explored

  • This module: the overlay options limit (MOUNT_OPTIONS_LIMIT = 4095 matches the NUL that the kernel forces at PAGE_SIZE - 1), the runtime directory's reserved characters, and the occupied-mount-point checks after 28524cef. Also overlay_default, which derives redirect_dir from two module parameters and xino from xino_auto, as ovl_show_options hides defaults. The 9 ignored guest::mappings::privileged tests, which no CI job runs, all pass as root on Linux 6.18.
  • Sandbox store lifecycle locks and removal (nvx-rs: move lifecycle locks and removal into the sandbox store #754): the CallerFiles name rules, the removal order, Lingering::wait, pending deletion on Windows, the store's temporary names, and how aci_edge_sandboxes uses the store.
  • Checked handoff on Windows (nvx-rs: hand the VMM the checked images of a sandbox launch #752): scratch claims through share modes, final paths, refuse_batch_file, command_line quoting, and the one-shot spawn. The batch-file and rename checks ran on this host.
  • Proxy-only network lowering (nvx-rs: lower sandbox networks through one typed policy with a proxy-only mode #753) and the plan's serde form; CLI --timeout and --exec-timeout-ms bounds; the CI change classifier; OpenVMM's option splitter at the pinned 7ff31ed7.

Checked, not filed

  • deprovision refuses a .config.json.<hex>.tmp that only a crash during provision leaves. Before nvx-rs: move lifecycle locks and removal into the sandbox store #754 it refused the same file. It names the file in its error.
  • provision accepts an existing, non-empty private --state-dir. start and deprovision then overwrite or remove files there that have a store file's name. This matches the former Python _prepare_state_directory.
  • NTFS refused to rename the parent directory of an image while any of these was open: the distro guard, the scratch guard, the claim's handle, or the guard and the claim together (ERROR_ACCESS_DENIED). The handoff's final paths hold.
  • CreateProcessW refuses t.cmd::$DATA with error 193 and runs nothing, while it runs t.cmd through cmd.exe, so refuse_batch_file's suffix check suffices.
  • Hand-edited records are parsed leniently: a nanoseconds of 10⁹ or more, a non-null changed on Windows, and an uppercase fileId are all accepted. Each still fails closed as a seal difference at start.
  • Nothing compares the identity of the distro layer with the scratch image. Windows share modes refuse the same file as both. On Linux, the guest's ext4 mount of an EROFS image fails.
  • OpenVMM's split_options treats only [, ], and , as special, and the leading ROLE:file:PATH skips split_kv. So =, parentheses, and braces in a final path are safe. Brackets are Track sandbox block device path bugs: an image path with an unbalanced bracket passes validation, but OpenVMM refuses it on Windows #787.
  • The CI classifier's input patterns name every local action that run-nvx-microvm-tests.yml, run-platform.yml, and build-openvmm-binary.yml use, including nested ones.

Validation limits

  • Finding 1 was reproduced at the kernel interface that check_unoccupied, stage_share, and check_overlay call, SystemKernel::is_mount_point and mount_options. It was not reproduced through assemble or mount_share, which need the guest's virtio-mmio block and virtio-fs devices.
  • When the first thread's table holds no descriptor at that number, mount_id should fail with ENOENT instead. Then check_unoccupied(.., true) would take an occupied mount point for a missing one. This is inferred from the code, not reproduced.
  • Only WSL 2 (Linux 6.18) was used. No NVX guest and no hypervisor (KVM, MSHV, or WHP) were run.

Activity

  1. akhil9tiet commented on Oct 23, 2026

    @akhil9tiet
    Contributor

    I'd like to work on this — please assign it to me.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Labels

bug-huntFindings from focused bug huntsconfirmedIssue affects multiple people.good first issueGood for newcomers

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions