Research/qemu-colo-l4-oob
Series0-dayNo CVEHighPublic

QEMU COLO filter-rewriter short IPv4 L4 heap overflow

QEMU v11.1.2 COLO parse_packet_early accepts IPv4 once L2 plus ip_hl fits. It never requires an L4 header. filter-rewriter then reads past a 34-byte frame. ASan heap-buffer-overflow READ. Needs COLO.

Name
QEMU COLO filter-rewriter short IPv4 L4 heap overflow
Series
QEMU 11.1.2 leftovers
Phase
0
Type
0-day analysis
CVE
n/a
CVE Risk
high
Disclosure Status
public
Vendor
QEMU
Affected
QEMU v11.1.2 (4fc49f46dc95d4a27de2509e7fceb2931e91faeb) with filter-rewriter / colo-compare enabled.
Published
16 Oct 2026
Updated
16 Oct 2026
Tags
0-day, qemu, colo, oob, local, cwe-125, cwe-787

Ethernet plus IP is not a TCP header

I am @abraxas_null. The proof of concept is on GitHub: abraxas/qemu-colo-l4-oob (loopback client). The lab stack is lab/: Dockerfile, docker-compose.yml, run.sh, poc.py. Authorized lab only. It talks to loopback.

This is leftover #8 of the QEMU 11.1.2 leftovers series. Same pin as leftover #1, xhci_complete UAF, and leftover #3, NVMe SGL BAR span. This one is the COLO proxy.

This is QEMU v11.1.2 (4fc49f46dc95d4a27de2509e7fceb2931e91faeb). No CVE yet. CWE-125 / CWE-787. 8.2 High (CVSS:3.1/AV:L/AC:L/PR:H/UI:N/S:C/C:H/I:H/A:H). Local (guest) / Remote (COLO peer). Heap OOB. Not default. Needs -object filter-rewriter. Once COLO is on, it is in the virtualization security model.

COLO is the feature you turn on when a guest is not allowed to stop. Primary and secondary VMs run in parallel. The proxy compares their outbound packets. filter-rewriter rewrites TCP sequence and ACK numbers so the secondary stack looks like the primary. It does that by treating the first byte after the IPv4 header as a struct tcp_hdr. It never checks that those twenty bytes exist.

A 34-byte IPv4 frame is Ethernet plus a minimum IP header. parse_packet_early says that is enough. fill_connection_key then reads four bytes at transport_header. ASan: heap-buffer-overflow READ in filter-rewriter. Control frames with a full TCP header leave QEMU alive.

That is the leftover.

Why QEMU

I had just finished a desktop hypervisor hunt. QEMU is the other userspace hypervisor people actually run: KVM on the host, pc / q35 for the guest, and a security chapter that is unusually honest for a C project of this age. docs/system/security.rst lists the untrusted entities: guest, VNC/SPICE/WebSocket, NBD and live migration, user-supplied files, PCI/USB passthrough. Guest-to-host into the QEMU process is the prize class.

I only wanted unpublished Critical or High. Local is in-scope. Medium stays on the floor unless it chains. TCG-only is out of the virtualization use case. Guest-root assert/abort is hardening. vhost-user backends share memory with QEMU on purpose. Unbounded host alloc is hardening. QMP is privileged. Nested L2-to-L0 via PCI assignment is a different model.

A finding on this hunt needed something else: leftover of a published 2026 CVE that only guarded one walk, or a parser that still trusts a length field the rest of net/ already clamped.

COLO is not default. Most -netdev tap boxes never load it. The docs still ship it. --enable-colo-proxy is a configure flag, not a joke. When an operator turns on filter-rewriter, guest TX/RX on that NIC is a guest-to-host path, and a colo-compare peer on primary_in / secondary_in is a network protocol. Both are untrusted on this pin.

Pinning v11.1.2

Latest GA, not master. Tag v11.1.2. Peel 4fc49f46dc95d4a27de2509e7fceb2931e91faeb, "Update version for 11.1.2 release". VERSION says 11.1.2. Debian sid was 1:11.1.2+ds-3 the day I cloned. That is the pin.

GitHub qemu/QEMU and GitLab qemu-project/qemu are the same tree. I cloned the GitHub tag and did not update it when a later commit appeared. Mixing pins is how you "discover" a bug the next tag already closed.

The lab builds qemu-system-x86_64 from that peel with --enable-colo-proxy --enable-replication --enable-asan. TCG is enough. The leftover is userspace packet parse, not KVM.

The skip list is half the hunt

Before you look for leftovers, you write down what is already public so you do not rediscover a press release. On 11.1.2 the interesting 2026 rows were already closed:

Kernel PwnEscape and Vghost are Linux net/vhost. Out of QEMU.

Twenty-four surfaces, then this parser

I split the tree. virtio core, virtio-net, virtio-blk/scsi, virtio-gpu, virtio misc, USB, NVMe, VGA, VNC/SPICE, NBD, migration, 9pfs, e1000/igb, TAP/socket/COLO, SCSI HBA, slirp glue, qemu-ga, fwcfg/VAPIC, ACPI/SMBIOS, audio/chardev, CXL/UEFI/TPM, SD/IDE/floppy, PCI/IOMMU, chains.

Most of that is QEMU doing what QEMU said it would do.

virtio packed vring and inverted IOVA are closed. virtio-net RSC/RSS/VLAN are closed. virtio-blk SCSI_CMD and zone report are closed. 9pfs FID/xattr/Treaddir are closed. balloon BH is closed. e1000/igb migrated rx_desc_len is recalculated in post_load. USB timer reentrancy is closed. VGA text 77913 is closed. QXL stride is closed. slirp is out of tree. VAPIC 81627 is closed. qga 12080 is closed. SCSI HBA #4130 is already filed. vhost-user is no boundary. TCG-only is out. QMP is privileged.

TAP, socket, bridge, l2tpv3, stream, dgram, af-xdp, netmap, vde length paths looked bounded. net_fill_rstate rejects packet_len > NET_BUFSIZE. That is a different floor. I almost treated it as COLO's floor. It is not.

The COLO hunt is where the temperature changed.

parse_packet_early never asks for L4

parse_packet_early is the shared classifier. filter-rewriter and colo-compare both call it. The size floor is Ethernet plus a VLAN header plus vnet_hdr_len. VLAN is then rejected. IPv4 is accepted once L2 plus ip_hl * 4 fits. transport_header is set at that offset. There is no TCP 20, no UDP 8, no SCTP 12, no "remaining length >= sizeof(struct tcp_hdr)".

C
if (pkt->size < sizeof(struct eth_header) + sizeof(struct vlan_header) +
    pkt->vnet_hdr_len) {
    return 1;
}
...
network_length = pkt->ip->ip_hl * 4;
if (pkt->size < l2hdr_len + network_length + pkt->vnet_hdr_len) {
    return 1;
}
pkt->transport_header = pkt->network_header + network_length;
return 0;

ip_hl is taken from whatever sits at pkt->ip after the L2 skip. A 34-byte frame (14 Ethernet + 20 IPv4, ip_hl = 5, ip_p = TCP) passes. transport_header lands on the first byte past the allocation.

fill_connection_key then does this for TCP/UDP/SCTP/ESP/DCCP/UDPLITE:

C
tmp_ports = *(uint32_t *)(pkt->transport_header);

Four bytes. No remaining-length check. AH reads eight. That is a host heap READ in the QEMU process.

filter-rewriter is worse, because it also writes. colo_rewriter_receive_iov copies the frame with g_malloc0(size), classifies with is_tcp_packet (parse plus ip_p == IPPROTO_TCP), then calls handle_primary_tcp_pkt / handle_secondary_tcp_pkt. Those paths always load th_flags at offset 13 of struct tcp_hdr. A SYN moves the connection to TCPS_SYN_RECEIVED. An ACK with conn->offset != 0 stores through the transport pointer:

C
tcp_pkt->th_ack = htonl(ntohl(tcp_pkt->th_ack) + conn->offset);

and on the secondary path:

C
tcp_pkt->th_seq = htonl(ntohl(tcp_pkt->th_seq) - conn->offset);

A later IPv4/TCP frame whose captured length ends at the transport header makes those stores land past the g_malloc0 chunk. IP options (ip_hl > 5) shift the write onto the next heap chunk. Guest-triggerable host heap WRITE. Same missing L4 floor.

colo-compare has a sibling of the same class. fill_pkt_tcp_info sets header_size from th_off << 2 with no check against pkt->size. header_size is uint8_t. payload_size is uint16_t. When th_off claims a TCP header longer than the captured tail, size - header_size underflows and colo_compare_packet_payload memcmps tens of kilobytes past both packet allocations. Crash plus host heap READ.

I did not need that path for the witness. The rewriter READ fired first.

The lab

Host is macOS. One container. Image built from the v11.1.2 peel, ASan qemu-system-x86_64, COLO proxy on. Runtime ubuntu 24.04 plus libasan8. Compose name qemu-colo-l4-oob. Bind 127.0.0.1:18770 (socket netdev / filter-rewriter) and 127.0.0.1:18771 (colo-compare primary_in). Compare secondary and outdev stay inside the container.

QEMU is -machine pc -accel tcg -m 32 -S. No guest kernel. The leftover is the rewriter sitting on the netdev. -object filter-rewriter,id=rw,netdev=hn0,queue=all plus a colo-compare object is enough to put the parse on the hot path.

CONTROL: a full-header SYN (54 bytes) then a full-header ACK (54 bytes) on one 4-tuple, and a matching pair of full-header compare frames. QEMU stays alive. If that dies, the grammar changed or ASan is already dying on a handshake. FAIL.

TRIGGER: a full-header SYN on a second 4-tuple, then a 34-byte IPv4/TCP ACK that keeps zero TCP bytes (tcp_keep=0). Ethernet plus IP. parse_packet_early still returns 0. fill_connection_key still reads four bytes.

ASan:

Plain text
ERROR: AddressSanitizer: heap-buffer-overflow
READ of size 4
sink=filter-rewriter

QEMU exit -6. Control stayed up. That is SUCCESS.

Last line:

Plain text
SUCCESS qemu-colo-l4-oob asan=heap-buffer-overflow access=READ sink=filter-rewriter control=alive QEMU-COLO-L4-OOB-WITNESS

Replay:

Plain text
git clone https://github.com/abraxas/qemu-colo-l4-oob
cd qemu-colo-l4-oob/lab
./run.sh

Bind it to loopback. The loopback client is lab/run.sh with a banner on it.

The lab does not demonstrate RIP control or a guest-to-host shell. The class is heap OOB of the QEMU process on a COLO-enabled netdev.

Wrong turns, in the order I made them

Treating TAP length as the COLO bound. net_fill_rstate rejects packet_len > NET_BUFSIZE. TAP, socket, bridge, l2tpv3, stream, dgram all looked bounded on this pin. I spent a sitting reading those copies and almost closed the net hunt. COLO's floor is in parse_packet_early. It is smaller, and it is missing L4.

Sending the overflow as the first packet. filter-rewriter always loads th_flags. The WRITE of th_ack / th_seq needs a connection that has already seen SYN, so tcp_state is TCPS_SYN_RECEIVED and conn->offset is live. A short frame with no prior SYN is a READ of th_flags / fill_connection_key. The interesting write is the ACK that follows. The lab sends SYN, then the 34-byte ACK.

colo-compare th_off as the primary witness. I wanted the underflow memcmp because it is tens of kilobytes. The rewriter READ fired first. ASan halt_on_error=1 stops the process. The compare path is still a sibling of the same missing L4 floor. It is not this SUCCESS line.

Calling this a kernel TAP bug. PwnEscape is Linux net. Vghost is vhost. This is QEMU userspace g_malloc0 of the captured size, then a parse that pretends L4 is in-buffer.

Calling this default QEMU. Without -object filter-rewriter the rewriter is not on. COLO is a HA config. Once it is on, guest packets and compare peers are in the model. Scoring it as "not security because COLO is rare" is how you miss a hypervisor heap OOB.

Inflating the READ to a demonstrated WRITE. ASan aborted on fill_connection_key READ of size 4. The th_ack / th_seq stores are the same missing bound, inferred from source. colo-compare th_off underflow is inferred from source. The lab-proven oracle is the READ, the control that stayed alive, and the abort.

A reverse shell. Theatre. The oracle is ASan aborting in filter-rewriter. Crash witness. No RIP payload.

Bundling leftover #1 and leftover #3. xHCI event-ring DMA into doorbell is a USB complete leftover of 17588. NVMe SGL that starts just before BAR0 and spans into MBAR is an incomplete 2021-3929. Those are other compose projects, other loopback ports. This pack is COLO.

What an attacker can do

Who: untrusted guest on a COLO-enabled NIC, or a hostile COLO compare peer. Guest-to-host / peer-to-host. QEMU userspace heap.

Integrity / recon (lab-proven)

Method Sink What it grants
Short IPv4/TCP ACK after a full-header SYN (34-byte frame, length ends at transport header) filter-rewriter colo_rewriter_receive_iov -> fill_connection_key ASan heap-buffer-overflow READ of 4 bytes past the g_malloc0 chunk. Control full-header TCP leaves QEMU alive.
Same missing L4 floor parse_packet_early net/colo.c:43-98 Packet classified as TCP without an L4 header in-buffer. transport_header points at or past the end of the captured frame.

Integrity (inferred, same missing bound)

Method Sink What it grants
After SYN, conn->offset != 0, short frame ending at transport header handle_primary_tcp_pkt / handle_secondary_tcp_pkt th_ack / th_seq stores at net/filter-rewriter.c:112-114 and :216-218 Host heap WRITE past the g_malloc0 chunk. IP options (ip_hl > 5) shift the write onto the next chunk.
th_off << 2 larger than the captured tail colo-compare fill_pkt_tcp_info then colo_compare_packet_payload memcmp payload_size underflow. Tens of kilobytes READ past both packet allocations. Heap infoleak / crash.

Availability (lab-proven)

QEMU abort under ASan on the rewriter READ. Without ASan this is a live heap OOB in the hypervisor process: guest-to-host crash, and inferred reuse of the chunk past the 34-byte frame.

Adjacent surface that stays closed

  • TAP / socket / bridge / l2tpv3 / stream / dgram length paths on this pin looked bounded (net_fill_rstate rejects packet_len > NET_BUFSIZE).
  • Unbounded COLO connection-table leak on hashtable reset is alloc hardening, not this leftover.
  • Kernel PwnEscape / Vghost: out of QEMU.
  • Default QEMU without COLO: rewriter is not on. This leftover needs COLO configured.
  • No RIP control. No guest-to-host shell. The class is heap OOB, not a demonstrated escape.

Reach is Local (guest) because the rewriter sits on guest TX/RX. A colo-compare peer on primary_in / secondary_in is Remote in the same leftover. CVSS is AV:L / PR:H / S:C because the guest is a high-privilege tenant of the hypervisor process and the corruption is in QEMU, not in the guest. The lab is loopback. The class is OOB.

The fix

parse_packet_early must require a full L4 header after the IP header: TCP 20, UDP 8, SCTP 12, AH 8. Reject when size ends at or before transport_header. fill_connection_key and the rewriter th_* stores must check remaining length before they touch struct tcp_hdr. colo-compare must reject a th_off << 2 that does not fit in pkt->size before computing payload_size.

Ethernet plus IP is not a TCP header. COLO already knew how to reject VLAN. It forgot to reject a missing transport.

References

Contact: GitHub · X @abraxas_null · abraxaslabs.tech · abraxas.null@proton.me

GNU Affero GPL v3.0. Loopback lab only. No warranty.