Skip to content

soo: stream capsule snapshots, and release what a capsule leaves behind - #326

Merged
daniel-rossier merged 10 commits into
mainfrom
fix/capsule-snapshot-guard
Sep 7, 2026
Merged

soo: stream capsule snapshots, and release what a capsule leaves behind#326
daniel-rossier merged 10 commits into
mainfrom
fix/capsule-snapshot-guard

Conversation

@daniel-rossier

Copy link
Copy Markdown
Contributor

Saving or restoring a capsule made the agency allocate a DMA buffer as large as
the whole snapshot -- 128 MB of physically contiguous memory on virt64, taken
from a 160 MB CMA zone while the EMISO engine held a copy of the same snapshot
in user space. cma_alloc() failed to migrate the movable pages out of the way,
the fallback asked the buddy allocator for an order-16 block, and the agency
died on a BUG_ON() in an ioctl with the interrupts off.

The staged protocol already moves the capsule memory 4 MB at a time, so only one
chunk is ever in flight: the snapshot is now streamed through a bounce buffer of
that size, reserved once at init while the CMA zone is still pristine. Neither
pause nor resume depends on a large contiguous allocation any more, and the
snapshot is no longer held twice in RAM.

Pulling that thread surfaced a family of related weaknesses, each fixed here:

  • sizes coming from user space were unbounded and their allocations BUG_ON()d,
    on both the snapshot and the injection paths; the capsule image was read with
    a plain memcpy() from a user pointer;
  • a snapshot which failed halfway left the capsule suspended for good;
  • the console focus kept pointing at a capsule which could not take the input,
    and the send path slept in the serial interrupt waiting for a frontend that
    was gone -- freezing the console;
  • a capsule which cannot answer the DC_SHUTDOWN handshake (suspended, stopped,
    or killed after a fault) left its backends registered and its vbstore entries
    behind, so its slot could not be reused;
  • AVZ never freed the grant table of a domain destroyed before it revoked its
    grants, and the recycled memory took the next domain's list links with it.

A snapshot can also be taken without resuming the capsule
(AVZ_STAGE_FINALIZE_HOLD), for a caller which shuts it down right after --
waking it up only to kill it lets it run, and diverge from the snapshot just
taken, for nothing.

Validated end to end on virt64, driven through the EMISO Docker API: six
consecutive pause/resume cycles, no BUG(), no oops, engine healthy
throughout.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Di7T124oudbKPpE28mJnb4

write_snapshot() dereferenced the user-space buffer directly to pick up
the snapshot size, then fed that value to dma_alloc_coherent() and
BUG_ON()'d the result. A caller passing an uninitialized or corrupted
buffer therefore killed the agency: the size read back was garbage
(1.7 GB in the observed case), the allocation warned in the page
allocator and returned NULL, and the kernel died in an ioctl with the
interrupts off.

Read the size with copy_from_user(), reject a size that no snapshot
could ever have, and return -EINVAL/-ENOMEM/-EFAULT instead of taking
the machine down. The error is propagated through the WRITE_SNAPSHOT
ioctl so that user space can report it.

Observed with the EMISO engine restoring a snapshot whose archive held
no capsule payload.
Saving or restoring a capsule made the agency allocate a DMA buffer as
large as the whole snapshot, i.e. the capsule slot plus its header. On
virt64 that is 128 MB of physically contiguous memory taken from a
160 MB CMA zone, and the EMISO engine holds a copy of the same snapshot
in user space at that very moment. cma_alloc() then has to migrate the
movable pages sitting in the way, fails with -EBUSY, and the fallback to
the buddy allocator asks for an order-16 block which cannot exist.

The staged protocol already moves the capsule memory 4 MB at a time, so
only one chunk is ever in flight: pass a bounce buffer of that size
instead of the whole snapshot. AVZ now reads and writes each chunk at
the beginning of the buffer, bounded by the size the agency advertises,
and the agency copies it to or from user space as the stages progress.
The buffer is reserved once, at init time, while the CMA zone is still
pristine.

The snapshot is no longer held twice in RAM, and neither path depends on
a large contiguous allocation any more.
The injection path had the same weaknesses as the snapshot one: the size
came straight from user space with no bound, dma_alloc_coherent() was
followed by a BUG_ON(), and the image itself was read with a plain
memcpy() from a user pointer.

Reject an implausible size, report -ENOMEM instead of dying when the CMA
zone cannot serve the request, copy the image with copy_from_user(), and
propagate the failure through the INJECT_CAPSULE ioctl. The staged loop
also gives up if AVZ stops advancing the cursor, as on the snapshot
paths.

The ITB is parsed as a whole by AVZ, so unlike a snapshot it still needs
one contiguous allocation -- a few MB, taken once per injection.
The console focus is a plain index over the slots, so it keeps pointing
at a capsule which is being snapshotted or has been shut down, and every
keystroke is then handed to s3c_cons_sendc() for that slot.

That path is not prepared for it. It runs from the serial interrupt and
goes through vdevback_processing_begin(), which waits for the frontend
to be connected: a capsule suspended for a snapshot, resuming, or gone
has none, so the keystroke slept in the interrupt and hung the console
for good. get_console() returning NULL for a vanished domain was
dereferenced right away as well.

Skip, when cycling the focus, every slot whose capsule is not living --
any other state is a capsule in transit or gone. Bail out of the send
path when no console device answers for the domain, and take the new
non-blocking vdevback_processing_try_begin(), which reports the frontend
is not usable rather than waiting for it.

vdevfront_is_connected() also read the device private data before
testing the pointer for NULL; do it the other way round.
Snapshotting a capsule suspends it, reads its memory and resumes it: a
capsule which was living stays living, which is the whole point of
snapshotting a running one. A caller which shuts the capsule down right
after -- pausing it, in the EMISO engine -- gets the opposite of what it
wants, though: the capsule is woken up only to be killed, and runs for a
moment, diverging from the snapshot just taken.

Add an AVZ_STAGE_FINALIZE_HOLD stage, which completes the snapshot and
leaves the capsule suspended, and an AGENCY_IOCTL_READ_SNAPSHOT_HOLD
reaching it. AGENCY_IOCTL_READ_SNAPSHOT is untouched and still resumes.

shutdown_capsule() has to know about that state: it asks the capsule to
terminate through the DC_SHUTDOWN handshake, and do_sync_dom() waits for
the answer without any deadline. A suspended capsule is not scheduled
any more and can never answer, exactly like the stopped and killed ones
the handshake is already skipped for, so reap it directly as well.

While at it, a snapshot which fails halfway no longer leaves the capsule
suspended for good: every error path now goes through the resuming
stage, whichever flavour was asked for.
Every snapshot of a living capsule printed its state on the console,
which is debug output that outlived its purpose.
Restoring a snapshot re-implanted the capsule memory four bytes too
early, so the capsule resumed on shifted contents and drowned in
synchronous exceptions -- or jumped into an unmapped vector and got
killed, depending on what the shift landed on.

The size AVZ stores at the beginning of a snapshot does not count
itself: only the value it hands back to the agency includes those four
bytes. Deriving the header size from the stored value therefore misses
them, and every chunk was read from the wrong offset. Add the size field
back where the header is computed, and read one more byte than the
stored size says when handing the header over.

AVZ also reads the domain context out of the buffer again at the
FINALIZE stage, to restore the EL2 frame of the capsule; the CHUNK
stages having filled the buffer with capsule memory by then, put the
header back before finalizing.

Validated end to end on virt64: two consecutive pause/resume cycles
through the EMISO engine, no contiguous allocation involved.
sudo_session_start() gated the build on `sudo -v`, which validates the
user through the 'validate' pseudo-command. A sudoers file granting
NOPASSWD to commands may still refuse that one, and the build then
stopped on "failed to acquire sudo credentials" while every privileged
task it was about to run needed no authentication at all.

Probe with a harmless command instead, and only fall back to `sudo -v`
when that fails -- which is where a password prompt genuinely belongs.
A capsule closes its frontends and drops its vbstore entries as part of
the DC_SHUTDOWN handshake. One which is suspended, stopped or already
dead never gets there, and everything it owned outlives it: the backends
stay registered, so the next capsule landing in that slot makes
vbus_dev_changed() BUG() on a device which already exists, and the
vbstore entries stay too, so a capsule restored there comes up on top of
what the previous one left.

Do it for such a capsule, in shutdown_capsule(), where the handshake is
skipped: unregister the backends bound to that domain, and remove its
vbstore subtrees -- backend/<type>/<domID>, device/<domID> and
soo/s3c/<domID>, mirroring what vbus_probe_backend() walks and what the
capsule removes for itself.

The bus walk reports one device at a time, holding it, so that
device_unregister() runs outside of it.

This already applied to a capsule killed after a fault, whose slot could
not be reused afterwards.
complete_domain_destroy() released the shared page, the stack and the
domain structure, but never walked d->gnttab. A domain revokes its
grants one by one as its frontends close, so the list is empty by then
and the leak never showed -- except for a domain destroyed before it
gets there: suspended for a snapshot, crashed, or killed outright.

Its entries then outlive the domain they describe, and the memory is
handed out again to the next one, which inherits list links pointing
into it.

Add gnttab_destroy(), symmetric with gnttab_init(), and call it where
the rest of the domain is released.
@daniel-rossier
daniel-rossier force-pushed the fix/capsule-snapshot-guard branch from eaeb51b to fc6c9e8 Compare September 7, 2026 15:27
@daniel-rossier
daniel-rossier merged commit 186e04a into main Sep 7, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant