Repository navigation
Track sandbox resource limit bugs: provision records a --memory-mib that OpenVMM refuses, so the sandbox never starts #733
Description
Bug-hunt tracking issue for sandbox resource limits: the sizes and limits that nvx-rs sandbox (and nvx.py sandbox, which delegates to it) accepts for a sandbox: the VM's --memory-mib, which the host passes to OpenVMM as --memory, and the workload's --memory-max and --pids-max, which the host passes on the kernel command line and the guest's init writes into the container cgroup.
Baseline of this pass: origin/dev at 20dd9978. Every NVX permalink in this pass uses this SHA. OpenVMM links use the revision that this baseline pins, 7ff31ed7. The first pass, collapsed at the end, keeps its own baseline.
Hunt: reopened by a second time-boxed pass of about 45 minutes. It started at 02:00 −03:00 on 2026-10-23, and finding 2 was confirmed at about 02:28. No repository files were modified and nothing was committed. The reproduction ran as a CLI integration test, using the existing tests/cli.rs helpers and fake OpenVMM, in a scratch copy of the baseline's nvx-rs (git archive), outside the repository, in WSL 2 (Linux 6.18.33.2) with Rust 1.95.0.
Selection: --memory-mib is a sandbox resource setting that the same CLI parses two lines from --memory-max and --pids-max, with the same flaw: an unbounded Int that a downstream consumer refuses. So it belongs to this issue's component, which is reopened and updated rather than duplicated. Open #756 covers the network options, #745 the kernel command line, #757 the workload identity, and #720 the host VMM supervisor.
Findings
| # | Severity | Finding | Status |
|---|---|---|---|
| 1 | Medium (--memory-max), low (--pids-max) |
--memory-max wraps modulo 2^64, and a large --pids-max fails the boot |
Closed in the first pass (below) |
| 2 | Medium impact, low likelihood | --memory-mib takes any integer, 0, negative, or one whose byte count overflows 64 bits; provision records it, and every start hands OpenVMM a --memory that it refuses |
Confirmed; open |
- 2. Refuse an unusable
--memory-mibbeforerunlaunches OpenVMM and beforeprovisionrecords it, and havestartrefuse a recorded one, with regression tests.
2. provision records a --memory-mib that OpenVMM refuses, so every start of the sandbox fails
Severity: medium impact, low likelihood. Every start of a sandbox provisioned this way fails, with only OpenVMM exited during startup with status N, and recovery takes deprovision and a new provision. With run, OpenVMM fails at once and prints its own error, so the impact there is low. No data loss and no security impact. This is the failure class of #756's first finding, for the VM's memory size.
Where
- The CLI reads
--memory-mibas an unbounded Python-styleInt, 256 by default, and nothing checks it:cli/sandbox/mod.rs#L395, with the field and default at#L261and#L324. runpasses it on verbatim as--memory {N}M:cli/sandbox/mod.rs#L809-L810.provisionrecords it inconfig.json:cli/sandbox/state.rs#L375.startreads it back and passes it on the same way:cli/sandbox/lifecycle.rs#L503and#L523-L524. It then reports only the exit status:#L707-L712.- The library paths do check the size:
LaunchSpectakes au32and refuses 0 (memory_mib must be positive), and so does theaci_edge_sandboxesOpenVMM backend (microvm.provision.memoryMib must be positive). - OpenVMM parses
--memorywithparse_memory_config, throughvmm_cli::MemorySize::from_str. That reads the digits as au64and multiplies them by 2^20 forMwithchecked_mul. It refuses-1M, which is not au64, and any size of 2^44 = 17592186044416 MiB or more (memory size '…' overflows).
Trigger: nvx-rs sandbox provision … --memory-mib N (or nvx.py sandbox provision), then start, with N ≤ 0 or N ≥ 2^44. A unit mix-up, such as a byte count, can produce such a value. run with the same N fails at once instead.
Expected: --memory-mib must be a positive integer whose byte count OpenVMM can hold, as the library already requires (at least 1 and at most u32::MAX, whose byte count fits 64 bits). The CLI refuses anything else before it launches OpenVMM or writes config.json.
Actual: every integer passes. provision exits 0 and records the value. start and run then hand OpenVMM --memory 0M, --memory -1M, or --memory 99999999999999M. OpenVMM's parser refuses the last two. 0M gets through it as a VM with no RAM, and I did not verify what OpenVMM does next.
Reproduction output (the test below, added to a scratch copy of nvx-rs/tests/cli.rs at the baseline; cargo test --locked --all-features --test cli bughunt_memory_mib_is_not_checked -- --nocapture):
provision --memory-mib 0: exit Some(0), stderr ""
config.json memory_mib = 0
run --dry-run --memory-mib 0: exit Some(0), OpenVMM gets `--memory 0M`, stderr ""
provision --memory-mib -1: exit Some(0), stderr ""
config.json memory_mib = -1
run --dry-run --memory-mib -1: exit Some(0), OpenVMM gets `--memory -1M`, stderr ""
provision --memory-mib 99999999999999: exit Some(0), stderr ""
config.json memory_mib = 99999999999999
run --dry-run --memory-mib 99999999999999: exit Some(0), OpenVMM gets `--memory 99999999999999M`, stderr ""
test bughunt_memory_mib_is_not_checked ... ok
Reproduction test
#[test]
fn bughunt_memory_mib_is_not_checked() {
let root = populated_root();
for (state, value) in [("zero", "0"), ("negative", "-1"), ("huge", "99999999999999")] {
let mut arguments = provision(&root, state, "");
arguments.extend(["--memory-mib".to_owned(), value.to_owned()]);
let output = run(&root, &arguments);
println!("provision --memory-mib {value}: exit {:?}, stderr {:?}", output.status.code(), text(&output.stderr));
let config: Value = serde_json::from_str(
&fs::read_to_string(root.path.join(state).join("config.json")).unwrap(),
)
.unwrap();
println!(" config.json memory_mib = {}", config["memory_mib"]);
// The same launch as a one-shot dry run, which prints the OpenVMM command.
let mut dry = provision(&root, &format!("{state}-dry"), "");
dry[1] = "run".to_owned();
dry.splice(2..4, []);
dry.extend(["--memory-mib".to_owned(), value.to_owned(), "--dry-run".to_owned()]);
let output = run(&root, &dry);
let shown = text(&output.stdout);
let memory = shown.split_whitespace().skip_while(|word| *word != "--memory").take(2).collect::<Vec<_>>().join(" ");
println!(" run --dry-run --memory-mib {value}: exit {:?}, OpenVMM gets `{memory}`, stderr {:?}", output.status.code(), text(&output.stderr));
}
}Suggested fix: check --memory-mib when the CLI parses it: refuse anything outside 1..=u32::MAX with a message such as --memory-mib must be between 1 and 4294967295, and keep it as a u32, as LaunchSpec::memory_mib does. start should refuse a recorded value outside that range, which an earlier build may have written, with deprovision the sandbox and provision it again rather than launching OpenVMM. Add CLI tests of run --dry-run and provision with 0, -1, and 17592186044416 (2^44). A floor that the guest can boot with (scripts/nvx_tools/adversarial.py uses 128) is a possible follow-up.
This pass: explored, and checked but not filed
Explored at the baseline, without a confirmed bug beyond finding 2:
- The checked handoff (nvx-rs: hand the VMM the checked images of a sandbox launch #752): scratch bindings and claims on Linux (
flock) and Windows (share modes),PreparedLaunchand Windows final paths, the handle-list spawn ofrun, and the Linux descriptor allowlist. - The store lifecycle and removal (nvx-rs: move lifecycle locks and removal into the sandbox store #754), and the CLI's
deprovision. - The strict GPT reader (nvx-rs: read a GPT disk's table through the guest's block device #771) and its ioctls.
- The network proxy and native-rule parsing (nvx-rs: lower sandbox networks through one typed policy with a proxy-only mode #753), egress lowering, and address primitives, compared with the pinned OpenVMM parsers.
- The guest rooted mappings (guest: map host paths beneath root descriptors #774). The 9 ignored privileged mapper tests and all 561 guest library tests pass as root on Linux 6.18.
- The guest share scripts against the host's target checks.
- The filesystem export plan (nvx-rs: refuse read-only paths inside read-write directories on every host #672), artifact metadata, and the
aci_edge_sandboxesexec timeouts. - The recent fixes fix: discard workload output after the output cap is hit #749 and ci: recognize a nightly report only when the marker opens its body #743.
Checked, not filed:
- Low impact, confirmed at the store level: if
provisionis killed (SIGKILL or a crash; Ctrl-C is handled) between creating and renaming.config.json.<uuid4-hex>.tmpinstate::write_json, the file stays.deprovisionthen refuses it as a foreign entry on every attempt, until it is deleted by hand.aci_edge_sandboxesremoves the equivalent temporaries of its own record withinstance::remove_interrupted_writes. The CLI does not, and its UUID-named temporaries would not match that function anyway. - Message only: a lingering removal that sees a concurrent provision's
.config.json.*.tmpreports it as foreign rather than asProvisionedAgain.
Validation limits: no real OpenVMM ran. OpenVMM's refusal comes from its source at the pinned revision; the fake OpenVMM accepts any --memory that ends in M. --memory 0M was not run on a VM. The full nvx-rs --all-features suite also passed on Windows, except one test that needs the full checkout layout, which the scratch copy lacked.
First pass (baseline 6f0face, 2026-10-21): finding 1, closed
Bug-hunt tracking issue for sandbox resource limits: --memory-max and --pids-max, which the host passes on the kernel command line, and which the guest's init writes into the container cgroup.
Baseline: origin/dev at 6f0facefd035a9ca9271c51436b9686874326272. Every permalink below uses this SHA.
Hunt: time-boxed, started 2026-10-21 16:21 -03:00. A first pass on the host VMM supervisor ended early and wrongly reported no bugs in #720, which is now closed. The hunt resumed and stopped at 16:49, when this bug was confirmed. This issue records the finding and the coverage of both passes.
Findings
1. The host accepts limits the guest kernel can't store: --memory-max wraps modulo 2^64, and a large --pids-max fails the boot
Severity: Medium for --memory-max, because the wrong limit is applied silently. Low for --pids-max, which fails loudly, but late.
Where
- The host only requires a positive integer, and
Inthas no upper bound:nvx-rs/src/host/sandbox/mod.rs#L242-L246- CLI parsing:
nvx-rs/src/host/cli/sandbox/mod.rs#L378-L379 - The documented contract:
#L134-L138.
- The host passes both values through verbatim, with
pids_max + 1:nvx-rs/src/host/sandbox/mod.rs#L394-L399. - The guest's init writes them unchecked into the container cgroup:
guest/common/nvx-init-agent#L110-L119. - A golden fixture already expects a value the guest kernel always refuses,
nvx_pids_max=100000000000000000000:nvx-rs/tests/fixtures/sandbox.json#L1320.
Root cause, in the Linux cgroup v2 parsers
memory.maxgoes throughpage_counter_memparse()→memparse()→simple_strtoull(), which ignores overflow.simple_strntoull()drops theKSTRTOX_OVERFLOWflag, so the byte count wraps modulo 2^64.pids.maxuseskstrtoll()and refuseslimit >= PIDS_MAX, which isPID_MAX_LIMIT + 1= 4,194,305 on x86-64. Because the host adds one, the largest--pids-maxthe guest can apply is 4,194,303.
Trigger: nvx-rs sandbox run|create … --memory-max N, or the equivalent library settings, with N ≥ 2^64 (18446744073709551616), or --pids-max M with M ≥ 4194304.
Expected: The host refuses a limit the guest can't apply, or the guest refuses it before the workload runs. It must not apply a different limit.
Actual
--memory-max 18446744073709551616(2^64) becomesmemory.max=0, and the workload is OOM-killed as soon as it runs.- 2^64 + 512 MiB becomes 512 MiB, and 10^20 becomes 7766279631452241920.
--pids-max 4194304passes the host, then the guest's init stops the boot withfatal "invalid container process limit".
Reproduction
- Host half, with the
nvx-rsCLI built from this SHA on Windows.--dry-runwith placeholder OpenVMM, kernel, and initramfs files exits 0 and puts both values on the guest kernel command line:> nvx-rs sandbox run --layer distro,<root>\distro.erofs,11111111-1111-1111-1111-111111111111 --scratch <root>\scratch.ext4 --entrypoint /bin/server --memory-max 18446744073709551616 --pids-max 4194304 --memory-mib 512 --hypervisor whp --dry-run exit=0 … nvx_memory_max=18446744073709551616 nvx_pids_max=4194305 … - Guest half: the same
echo VALUE >writes asnvx-init-agent, as root, into a throwaway cgroup v2 child on WSL2's Linux 6.18.33. NVX pins Linux 6.18.38 inSOURCE-MANIFEST.json.memory.max <- 268435456 : accepted, reads back 268435456 memory.max <- 18446744073709551615 : accepted, reads back max memory.max <- 18446744073709551616 : accepted, reads back 0 memory.max <- 18446744073709551617 : accepted, reads back 0 memory.max <- 18446744074246422528 : accepted, reads back 536870912 memory.max <- 100000000000000000000 : accepted, reads back 7766279631452241920 pids.max <- 65 : accepted, reads back 65 pids.max <- 4194304 : accepted, reads back 4194304 pids.max <- 4194305 : write rejected: sh: 8: echo: echo: I/O error pids.max <- 100000000000000000001 : write rejected: sh: 8: echo: echo: I/O error - A process placed in a cgroup whose
memory.maxreceived 2^64:memory.max reads back: 0 Killed workload exit status: 137 memory.events: max 35 oom 1 oom_kill 1
Suggested fix
- Host (
nvx-rs/src/host/sandbox/mod.rs):- Refuse
memory_maxabovei64::MAX(9223372036854775807). The kernel stores at mostPAGE_COUNTER_MAXpages and saturates larger in-range values tomax. - Refuse
pids_maxabove 4194303, for example with--pids-max must be at most 4194303. - Add tests for 2^64 and 4194304.
- Update the
sandbox.jsongoldens that expectnvx_pids_max=100000000000000000000.
- Refuse
- Guest, as defense in depth (
guest/common/nvx-init-agent):- Require decimal digits within those bounds before writing.
- After writing, read the limit back and
fatalon a mismatch. The kernel roundsmemory.maxdown to whole pages, so compare against the page-rounded value.
Explored and verified (no bug)
Second pass, 16:34–16:49
- Guest image keys and VMM-test selection (ci: key the guest images and VMM tests on the guest binaries' sources #710:
SOURCE_PATTERN,OPENVMM_VMM_TEST_INPUT_PATHS,_check_guest_build_inputs).- 24 edge paths gave the intended results for both the classifier and the identity, including
host.rs,hostile.rs,hosts/,tests.rs,tests/,loopback.rs, the binaries,build.rs, and.cargo/. - The guest build's
--no-default-features --features guestkeepsartifacts.jsonout ofbuild.rs's watch set, so the source identity covers everything the build reads.
- 24 edge paths gave the intended results for both the classifier and the identity, including
- Persisting performance results after a history reset (performance: keep results measured before a history reset out of the series #658:
_reset_dimensions_since,_without_reset_rows).- Traced cases: resets merged from a PR branch, a measurement at the reset commit itself, deleted history files, and side branches.
- The persist action re-fetches
devon each of its three attempts, and the persist job checks out withfetch-depth: 0.
- NVXS host connection (
HostConnection::sendandwrite): an explorer suggested that a send timeout which wrote nothing should leave the connection usable.- It can't:
HostSession::sendhas already advanced the sequence number and credit, andwrite_allmay have written part of the record. - So ending the connection is required. See "Checked, not filed".
- It can't:
- Host mapping payloads (
encode_maps_payloads): the header is counted once per payload, oversized entries are refused, and the count is patched on flush. Also checked: the time-probe option diagnostics (nvx-rs: report the time probe's invalid options with one method #681).
First pass, 16:21–16:33, recorded in #720
5. Host VMM supervisor (#656): launch, commit and abandon, Vmm::stop, reattach_with, and store recovery (recover, end_unidentified).
- Every failing step ends the launch.
- An unrecorded VMM is killed through its handle first.
- Runtime records are cleared only for the same launch.
- Windows process waits (
ProcessHandle::wait) and the runtime-adapterDecoder::push(tests: serve the control session in the runtime adapter's guest agent #640). command-line-limitscenario (run_command_line_limit, openvmm: refuse kernel command lines that the guest kernel would truncate #661): the padding arithmetic is correct, and the marker extraction is line-anchored.- OpenVMM transcript streaming (
OpenvmmProcess.close, microvm tests: stream OpenVMM transcripts to disk as they are read #659): the drain runs only after the reader stops. - Windows Azure metadata snapshot (ci: recover Windows runners after VMSS image upgrades #669): the real bootstrap functions, run in Windows PowerShell 5.1, keep
dataDisksa JSON array with one disk too. Also checked:benchmarkRunnerNamematching and the CI event classification (ci: run the OpenVMM suites on dev pushes only when their inputs change #660).
Checked, not filed
These are low impact, by design, or unreachable today.
sandbox exec --timeout(default 60 s) bounds the whole exec exchange, including the workload's run time (Session::exec).- A workload that runs longer than
--timeoutfails on the host, even with--exec-timeout-ms 0. - This faithfully ports the deleted Python
ControlSession.exec("The response timeout bounds the whole exchange"), so it's a usability trap, not a regression. The help text could say so.
- A workload that runs longer than
- The
HostConnectiondocs are ambiguous. They say a send that "times out before it wrote anything" leaves the connection usable, but anywrite_alltimeout ends it (nvxs.rs#L260-L271). That behavior is correct; only the wording is unclear. LaunchedProcess::terminateandwait_timeoutcan wait forINFINITEon Windows. A timeout of exactly 4,294,967,295 ms maps toINFINITE(windows.rs#L313-L338). Every caller passes at most 10 s.OpenvmmProcesstranscript write errors (_drain_available,_record). A write error can mask the original failure and leave a gap in the transcript. It is only reachable on log I/O failure.- Release manifest checks compare with
==(_manifest_mismatches). So"format": trueor1.0passes as format 1. This predates release: name the mismatched fields in Ubuntu artifact manifest errors #664, and the build itself writes these manifests.
Validation limits
- No end-to-end sandbox boot. The hunt ran on a Windows 11 host without KVM or MSHV, and without built OpenVMM or guest artifacts for WHP. Each half of the finding was reproduced separately:
- The host half with the
nvx-rsCLI built from this SHA, through--dry-run. - The guest half with the same cgroup writes on WSL2's Linux 6.18.33, not the pinned 6.18.38 guest kernel.
- The host half with the
- Tests. The
nvx-rstests were built from this SHA (cargo test --locked --all-features --no-run), but only the CLI binary was exercised. Thevalidate-nvxPython suite ran on this host with Python 3.14, which the docs allow ("3.10 or newer"): 1,098 tests in 692 s, with 18 failures, 5 errors, and 37 skipped. The one failure in the captured output is environmental: atest_setup_azureshell script'sawkcan't open a Windows temporary path (awk: fatal: cannot open file 'C:/Users/…/grub.cfg'), most likely becausebashresolves to WSL on this host. The other failures weren't triaged, because the hunt had already stopped at its confirmed bug. CI's Windows job runs the same suite. - Out of scope: the
openvmm/submodule, guest-only Rust code beyond reading, and areas that earlierbug-huntissues cover (Track release source collection bugs: Ubuntu output junction deletes target files #374–Track host telemetry bugs: Azure metadata trickles bypass timeout #514).
Activity
ppenna commentedon Oct 23, 2026
Reopened by a second bug-hunt pass at origin/dev 20dd997: --memory-mib takes any integer, which provision records and OpenVMM refuses at every start. The body is updated with finding 2.
[-]Track sandbox resource limit bugs: --memory-max wraps modulo 2^64, large --pids-max fails boot[/-][+]Track sandbox resource limit bugs: provision records a --memory-mib that OpenVMM refuses, so the sandbox never starts[/+]