Stage 7 — Virtio Device Issues
What class of issue this is
Stage 7 is the device model itself — the virtqueue logic, feature negotiation, and the
block/net/vsock I/O correctness that turns a guest's ring-buffer entries into real host I/O and
back. This is where a guest driver and Firecracker's emulated device meet, over the virtio-MMIO
transport (virtio-PCI exists behind --enable-pci — verify on your branch; note CVE-2026-5747
was in the PCI transport, fixed in 1.14.4/1.15.1). The bugs are some of the most demanding in the
codebase: a descriptor chain walked wrong, an available/used ring index handled incorrectly, a
feature bit advertised but not honoured, a partial write acknowledged as complete.
Concretely, a Stage 7 PR is one of:
- A virtqueue handling bug: descriptor-chain traversal (
next,flagsNEXT/WRITE/INDIRECT), available-ring vs used-ring index handling, a wrap-around or bounds bug, a missing length check. - A feature-negotiation bug: a feature bit (
VIRTIO_F_VERSION_1,EVENT_IDX,INDIRECT_DESC,RING_PACKED, device-specific bits) offered but not implemented, or the status state machine (ACKNOWLEDGE → DRIVER → FEATURES_OK → DRIVER_OK) handled wrong. - A block I/O correctness bug: request type handling (read/write/flush/get-id), sector bounds,
is_read_onlyenforcement, the io_engine (Sync vs io_uring) completion handling. - A net I/O correctness bug: RX/TX descriptor handling against the TAP device, frame size/segmentation, the rate-limiter interaction on the data path.
Why it's at this difficulty
Virtqueue code is shared-memory concurrency with an untrusted peer: the guest driver writes the
rings, and Firecracker must treat every index, length, and address as hostile. An off-by-one in the
used-ring index corrupts the guest's view of completions; a missing bounds check on a descriptor
addr/len is a guest-memory read/write primitive — a security bug, not just a correctness one. You
need the full virtio model in your head and the discipline to validate everything the guest controls.
Maps to Level 7; the virtqueues,
virtio transport, virtio-block,
and virtio-net & TAP deep dives are required reading.
What you must already understand
- The split virtqueue. Descriptor table (
virtq_desc{addr,len,flags,next}), available ring (driver→device), used ring (device→driver). "Kick" = guest writesQueueNotify; "interrupt" = device updates the used ring and raises the IRQ. Find Firecracker's queue logic:
rg -n "struct Queue|struct DescriptorChain|fn pop|fn add_used|avail_idx|used_idx|next_descriptor" \
src/vmm/src/devices/virtio/ | head
- rust-vmm has
virtio-queue, but Firecracker historically maintains its own ring logic in-tree underdevices/virtio/— know which your branch uses:
rg -n "virtio_queue|virtio-queue" Cargo.toml src/vmm/Cargo.toml src/vmm/src/devices/virtio/ | head
- The MMIO transport register map and the status/feature negotiation:
rg -n "0x074726976|MagicValue|QueueNotify|InterruptStatus|Status|FEATURES_OK|DRIVER_OK|ack_features|set_status" \
src/vmm/src/devices/virtio/ | head
- Per-device specifics (block request format, net TAP RX/TX). Read the device's
process_queue/handle_eventand itsread/writeMMIO config space.
Representative tasks
| Task | Device | Find it with |
|---|---|---|
| Fix descriptor-chain traversal / bounds | all | `rg -n "fn pop |
| Fix used-ring index / wrap-around | all | `rg -n "add_used |
| Fix feature bit advertised-but-unhonoured | all | `rg -n "avail_features |
| Fix block request handling / sector bounds | block | `rg -n "RequestType |
| Fix net RX/TX frame handling | net | `rg -n "process_rx |
| Fix vsock packet/port handling | vsock | `rg -n "VsockPacket |
How to approach one — worked example: a virtqueue bounds bug
Illustrative of the pattern. The
rgfinds the realpop/descriptor code; do not trust paths or line numbers — this is hot, frequently-refactored code.
Symptom: an issue reports that a malicious or buggy guest driver can supply a descriptor whose
addr + len overflows or points outside guest memory, and Firecracker reads/writes based on it
without a full bounds check — at best a crash, at worst an out-of-bounds access.
Step 1 — read the descriptor handling and the write path in one diagram
sequenceDiagram
participant G as Guest driver
participant Q as Queue (avail/used rings)
participant D as Device (process_queue)
participant M as GuestMemoryMmap
G->>Q: write descriptors + bump avail_idx, kick QueueNotify
D->>Q: pop() -> DescriptorChain {addr,len,flags,next}
D->>M: read/write at addr..addr+len %% MUST be bounds-checked
D->>Q: add_used(head_index, bytes_written)
D->>G: raise IRQ (used ring updated)
rg -n "fn pop|fn checked|GuestMemory|read_slice|write_slice|checked_offset|addr|len" \
src/vmm/src/devices/virtio/queue.rs | head
git log --oneline -n 8 -- src/vmm/src/devices/virtio/queue.rs
Step 2 — comment your reading on the issue, then fix the validation at the source
The right place to validate is where the descriptor is turned into a memory access — vm-memory's
GuestMemoryMmap already returns errors for out-of-range access, so the bug is usually a path that
bypasses it or unwraps it. Validate once, where the chain is produced:
--- a/src/vmm/src/devices/virtio/queue.rs
+++ b/src/vmm/src/devices/virtio/queue.rs
@@ impl DescriptorChain {
- // builds a descriptor from guest-written ring memory
+ // The guest controls addr/len; reject a descriptor whose region is not fully
+ // contained in guest memory before any device reads or writes it.
+ let region_end = desc.addr
+ .checked_add(u64::from(desc.len))
+ .ok_or(Error::DescriptorOverflow)?;
+ if !mem.address_in_range(GuestAddress(region_end.saturating_sub(1))) {
+ return Err(Error::DescriptorOutOfBounds { addr: desc.addr, len: desc.len });
+ }
Warning: This is one of the most security-sensitive regions in Firecracker. Treat every guest-supplied index, length, and address as hostile. A missing check here is a guest→host memory primitive — it can be a CVE-class issue, in which case it goes to AWS Security privately, not a public PR (see Stage 12 and
SECURITY.md). When in doubt about whether a virtqueue bug is exploitable, ask privately first.
Step 3 — test with a crafted ring, not a real guest
Firecracker's virtio tests build a queue over a mock guest memory and let you write arbitrary descriptors — exactly what you need to exercise the malicious case deterministically:
rg -n "fn .*queue.*test|VirtQueue|build_desc_chain|MockSplitQueue|mock|GuestMemoryMmap::from_ranges" \
src/vmm/src/devices/virtio/ | head
#![allow(unused)] fn main() { #[test] fn test_descriptor_out_of_bounds_is_rejected() { let mem = single_region_mem(0x10000); // a test helper over a small region let vq = MockSplitQueue::new(&mem, 16); // descriptor with addr near the top and a length that runs off the end: vq.add_desc(/*addr*/ 0xFFF0, /*len*/ 0x1000, /*flags*/ VIRTQ_DESC_F_WRITE, /*next*/ 0); let mut q = vq.create_queue(); assert!(q.pop(&mem).is_err()); // must NOT hand back an OOB chain } }
cargo test -p vmm devices::virtio::queue
# end-to-end functional test (real guest doing block/net I/O):
tools/devtool test -- -k "block or net or virtio"
A second pattern — feature negotiation
Illustrative.
Symptom: a device advertises a feature bit in avail_features but ack_features/the data path
doesn't actually implement it, so a guest that negotiates it gets wrong behaviour.
rg -n "avail_features|ack_features|acked_features|VIRTIO_F|set_acked_features" \
src/vmm/src/devices/virtio/block/device.rs | head
The fix is either to stop advertising the bit (the conservative, minimal-device-model choice) or to
implement it correctly — and the status state machine
(ACKNOWLEDGE→DRIVER→FEATURES_OK→DRIVER_OK) must reject out-of-order transitions. Which way to go is
a maintainer call: removing surface area is usually preferred over adding it.
What a good PR looks like
- Every guest-controlled value is validated before it drives a memory access or a host I/O —
index, length, address, descriptor count. No
unwrapon guest-derived data. - The fix lives at the queue/transport seam so all devices benefit, when the bug is in shared ring
logic; device-specific bugs are fixed in that device's
process_queue. - A crafted-ring unit test reproduces the malicious/edge case deterministically, plus an end-to-end functional test that real block/net I/O still works.
- Feature changes prefer removing surface area over adding it; the status state machine rejects illegal transitions.
- Security-sensitive findings go private first. If the bug is a guest→host primitive, report to AWS Security before any public discussion.
- Discussion preceded the diff; CHANGELOG entry; both functional and unit tests.
Graduation criteria — ready for Stage 8 when
- You have one merged virtio PR — a virtqueue handling fix, a feature-negotiation correction, or a block/net/vsock I/O correctness fix — with a crafted-ring unit test and a passing functional test.
- You can draw the split virtqueue (descriptor table + avail ring + used ring), the kick/interrupt
cycle, and name Firecracker's
Queue/DescriptorChaincode with anrg. - You treat every guest-supplied index/length/address as hostile by reflex, and you know which findings are CVE-class and must go to AWS Security privately.
- You can explain the feature-negotiation handshake and why removing an unimplemented feature bit is usually preferred over implementing it.
Devices hold state. Stage 8 is about saving and restoring that state —
the Persist trait, MicrovmState versioning, and making a snapshot taken on one binary restore on
another.