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, flags NEXT/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_only enforcement, 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 writes QueueNotify; "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 under devices/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_event and its read/write MMIO config space.

Representative tasks

TaskDeviceFind it with
Fix descriptor-chain traversal / boundsall`rg -n "fn pop
Fix used-ring index / wrap-aroundall`rg -n "add_used
Fix feature bit advertised-but-unhonouredall`rg -n "avail_features
Fix block request handling / sector boundsblock`rg -n "RequestType
Fix net RX/TX frame handlingnet`rg -n "process_rx
Fix vsock packet/port handlingvsock`rg -n "VsockPacket

How to approach one — worked example: a virtqueue bounds bug

Illustrative of the pattern. The rg finds the real pop/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 unwrap on 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/DescriptorChain code with an rg.
  • 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.

Next: Stage 8 — Snapshot Compatibility.