Repository navigation
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
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 fromstatxon the descriptor, read/proc/thread-self/mountinfoinSystemKernel::mount_options, and add a privileged regression test that runs the root's checks aftersys::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
28524cefadded. A live share is mounted over an occupiedstaginginstead 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
nvxcrate. The crate supports threads with a descriptor table of their own for the mapper's operations:69ab1cd5moved them to/proc/thread-self/fdfor exactly this case, anda_thread_with_a_descriptor_table_of_its_own_tears_its_mappings_downtests 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
sys::mount_idopenspathwithO_PATHand readsmnt_id:from/proc/self/fdinfo/{fd}./proc/selfis the process's first thread, so for a thread with a table of its own, that entry is the first thread's descriptor of the same number, or none. This is the case that69ab1cd5fixed for/proc/self/fd(rooted.rs#L57-L61), butmount_idstill uses/proc/self/fdinfo.SystemKernel::is_mount_pointcomparesmount_id(path)withmount_id(parent). Each call opens one descriptor and closes it, so both get the same number, read the same foreign entry, and compare equal:Ok(false).SystemKernel::mount_optionslooks that mount ID up in/proc/self/mountinfo. The privileged tests read/proc/thread-self/mountinfoinstead (privileged.rs#L128).- Callers:
check_unoccupiedruns for every mount point of an assembly (root.rs#L580-L591), which the module documentation says it refuses when occupied.stage_sharerefuses an occupiedstagingthe same way.check_overlayreads the overlay's options throughmount_options.
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 --nocaptureSuggested fix:
- In
sys::mount_id, take the mount ID of the descriptor in the calling thread's table. Reading/proc/thread-self/fdinfo/{fd}, as69ab1cd5did for/proc/self/fd, fixes it. Avoiding procfs would be better:statx(fd, "", AT_EMPTY_PATH, STATX_MNT_ID)givesstx_mnt_id, the ID thatmountinfo's first field shows, on Linux 5.8 and later. - In
SystemKernel::mount_options, read/proc/thread-self/mountinfo, as the privileged tests do, so that the table is the calling thread's. - Beside
a_thread_with_a_descriptor_table_of_its_own_tears_its_mappings_down, add a privileged test that runsis_mount_pointandmount_options, or a root's checks, aftersys::unshare_standard_descriptors().
What this pass explored
- This module: the overlay options limit (
MOUNT_OPTIONS_LIMIT= 4095 matches the NUL that the kernel forces atPAGE_SIZE - 1), the runtime directory's reserved characters, and the occupied-mount-point checks after28524cef. Alsooverlay_default, which derivesredirect_dirfrom two module parameters andxinofromxino_auto, asovl_show_optionshides defaults. The 9 ignoredguest::mappings::privilegedtests, 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
CallerFilesname rules, the removal order,Lingering::wait, pending deletion on Windows, the store's temporary names, and howaci_edge_sandboxesuses 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_linequoting, 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
--timeoutand--exec-timeout-msbounds; the CI change classifier; OpenVMM's option splitter at the pinned7ff31ed7.
Checked, not filed
deprovisionrefuses a.config.json.<hex>.tmpthat only a crash duringprovisionleaves. 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.provisionaccepts an existing, non-empty private--state-dir.startanddeprovisionthen 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. CreateProcessWrefusest.cmd::$DATAwith error 193 and runs nothing, while it runst.cmdthroughcmd.exe, sorefuse_batch_file's suffix check suffices.- Hand-edited records are parsed leniently: a
nanosecondsof 10⁹ or more, a non-nullchangedon Windows, and an uppercasefileIdare all accepted. Each still fails closed as a seal difference atstart. - 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_optionstreats only[,], and,as special, and the leadingROLE:file:PATHskipssplit_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, andbuild-openvmm-binary.ymluse, including nested ones.
Validation limits
- Finding 1 was reproduced at the kernel interface that
check_unoccupied,stage_share, andcheck_overlaycall,SystemKernel::is_mount_pointandmount_options. It was not reproduced throughassembleormount_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_idshould fail withENOENTinstead. Thencheck_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
akhil9tiet commentedon Oct 23, 2026
I'd like to work on this — please assign it to me.