Isolate per-VM host paths for the shared microvm service user #23
Labels
No labels
bug
duplicate
enhancement
help wanted
invalid
question
wontfix
bug
duplicate
enhancement
help wanted
invalid
question
wontfix
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
allod/nexus#23
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Prevent one host-side microVM runner process from reading another VM's active plaintext credentials or reading and modifying another VM's writable volume images.
Primary goals:
microvm@<name>unit, read-only.Current state and specifics
At the pinned microvm.nix revision, every
microvm@.serviceruns as the samemicrovmuser with groupkvm. File ownership can exclude libvirt and ordinary host users, but normal DAC cannot distinguish two QEMU processes with the same uid. If multiple per-machine plaintext directories or writable images remain present while guests run, each runner can otherwise open its siblings' files.The host framework should generate per-unit filesystem isolation, such as a private mount namespace that hides the common credential and volume roots, bind-mounts only the target credential directory read-only, and exposes only the target writable images, or an equivalently tested per-instance service principal. Failure to establish the isolation must prevent the VM from starting. The isolation must survive restart and must not make a sibling image reachable through an alternate path.
Scope
Part of allod/strategy#20 and its whole-runtime adoption plan.
In scope: generic host-unit isolation and fixtures around the public credential-delivery and persistent-volume interfaces.
Out of scope: private paths, machine names, volume sizes, changing guest delivery contracts, running QEMU as root, encrypting non-secret VM state at rest, and broader QEMU sandbox policy.
Isolate per-VM credential paths for the shared microvm service userto Isolate per-VM host paths for the shared microvm service userIntegration review found a gap in the isolation mechanism this issue describes: a private mount namespace on its own does not close every path between two same-uid runners.
BindPaths=/BindReadOnlyPaths=/ProtectSystem=-style namespacing (or an equivalent per-%iscoped view) restricts what a unit's own path lookups can reach. It does not stop a different process running under the same sharedmicrovmuid from reading a sibling's files through/proc/<sibling-pid>/root/..., which resolves inside the sibling's mount namespace and is gated by ptrace access rules rather than by the accessing process's own namespace. Kernel access to/proc/<pid>/root,/proc/<pid>/maps,/proc/<pid>/mem, etc. goes through the same permission check asptrace(2)attach, so whether one same-uid runner can reach another's namespace through procfs depends entirely on the kernel's Yamaptrace_scope:ptrace_scope = 0(classic): any same-uid process can reach another's/proc/<pid>/root, which would let one microVM runner read a sibling's active credential or writable-volume files regardless of how tight the per-unit mount namespace is.ptrace_scope >= 1(restricted, the common distro default and NixOS's apparent default with nothing overriding it): only an ancestor or a process granted access viaPR_SET_PTRACERcan do this, which blocks the vector between unrelated sibling units.So the fix this issue needs is two-part, not one: the per-unit namespace (or equivalent per-instance principal) for direct path access, and a pinned
kernel.yama.ptrace_scope(>= 1, ideally 2 given no legitimate host workflow needs to ptrace a live microVM runner across VM boundaries) so the procfs vector doesn't silently reopen if that sysctl is ever left at its default or changed for an unrelated reason. Suggest folding akernel.yama.ptrace_scoperequirement into the host module's own config (generic, not deployment-specific), and adding a same-uid/proc/<pid>/rootcross-unit access attempt to the "prove isolation" fixture alongside the direct-path check this issue already asks for — a fixture that only checks direct paths cannot show whether the procfs vector is actually closed.Correcting my earlier comment on this issue: the
kernel.yama.ptrace_scopehalf of the two-part fix I proposed does not work, and the issue should not adopt it as the mechanism that closes the/proc/<sibling-pid>/rootpath. The vector is real; the remedy was wrong.Yama's
ptrace_access_checkhook returns success immediately for any mode that is notPTRACE_MODE_ATTACH./proc/<pid>/root,/proc/<pid>/cwd,/proc/<pid>/fdand/proc/<pid>/mapsare opened underPTRACE_MODE_READ_FSCREDS, so they never reach the scope comparison at all. At everyptrace_scopevalue, including 2 and 3, those paths are gated only by the classic same-uid credential check in__ptrace_may_access./proc/<pid>/memis the outlier: it is an attach-mode open, which is why it feels like the whole family is protected when you test it.Measured on a NixOS guest at
ptrace_scope = 1as an unprivileged uid:/proc/<pid>/root/proc/<pid>/maps/proc/<pid>/cwd/proc/<pid>/fd/proc/<pid>/mem/proc/<pid>/rootof a different-uid target (control)The end-to-end case matters more than the table. A helper process was placed in its own mount namespace with a bind mount that made a file reachable only inside that namespace — the same shape as a per-unit
BindReadOnlyPaths=view of one machine's credential directory. A direct path lookup from outside the namespace failed, which is namespacing doing its job. An accessor detached withsetsid— same uid, not an ancestor of the target, noCAP_SYS_PTRACE— then read the file's contents through/proc/<pid>/root/...on the first try. The different-uid control was denied, so the method does detect denial.So the two options this issue lists as alternatives are not equivalent, and the shared-uid one cannot be repaired by a sysctl:
ptrace_scopevalue changes this.__ptrace_may_accessfails on the credential comparison before namespaces are consulted. A private PID namespace per instance would also work, by making the sibling's pid unnameable, but it interacts with the runner's own lifecycle units and is the larger change.Suggest promoting the per-instance principal from alternative to requirement for the credential and writable-image boundary, and keeping a
kernel.yama.ptrace_scopepin only for the attach vector it genuinely covers — a sibling reading another runner's process memory, where the live credential also sits — described as such rather than as the path fix.The fixture ask changes shape too. A cross-unit
/proc/<pid>/rootread attempt is still the right check, but under a shared uid it succeeds, so it is a failing test that the per-instance principal has to make pass — not a check that a namespaced-but-shared-uid implementation can be expected to satisfy. Worth stating explicitly in the "prove isolation" goal, because the natural reaction to a red test here is to narrow it to the direct-path case or to replace it with a sysctl assertion, and both would close the issue while leaving the boundary open.