Vulnerability

PwnEscape: how a VM's outgoing packets exposed host memory

The host finishes a VM's checksum but writes the result into an IPv4 header it has already checked. Fragmentation then trusts the altered header length. In the researchers' example, it copies 32 extra bytes, sending host memory out with the packet. Fixing CVE-2026-99999 requires updating the host kernel. Updating only the system inside the VM leaves this code unchanged.

Warm-paper conceptual drawing of a continuous paper strip passing through a brass gauge inside a glass enclosure, then being changed by a red pencil, with a host machine alongside it.
In this article

Putting untrusted code in a virtual machine is meant to limit the damage it can do to the system inside that VM. Networking still requires the host to accept what the virtual network device sends. In this case, that ordinary communication path carried the failure across the isolation boundary.

Vercel's October 14 account of its Sandbox challenge confirms that researcher Paulos Yibelo started from a standard sandbox and made host memory appear in outgoing packets. Host crashes and administrator execution were demonstrated on the researcher's own KVM and Firecracker systems matching the relevant configuration. The Linux CVE team published CVE-2026-99999 on October 15. pwn.ai's PwnEscape research article explains the networking sequence behind the finding.

1 Why the host writes into a VM's packet

A network checksum detects transmission errors. A system can prepare the packet and leave the final calculation and field update to the network device, reducing the work done by the CPU. Linux marks this pending work with CHECKSUM_PARTIAL, recording a calculation start, csum_start, and an output-field offset from that start, csum_offset. A 16-bit result will eventually be written at their sum. If the device cannot do it, skb_checksum_help() can perform the work in software. The kernel's checksum-offload documentation describes that contract and explicitly distinguishes it from the IPv4 header checksum, which is not offloaded this way.

Virtual devices carry the same kind of instructions. The system inside the VM is the guest, and the system running it is the host. A malicious guest can supply packet bytes and checksum metadata to the virtual-machine monitor, which writes them to an existing host TAP interface. TAP introduces virtual Ethernet frames into the host network stack. The additional virtio_net_hdr describes device offload work, separate from an ordinary IPv4 packet on the internet. Sending a TCP request to a server does not, by itself, let an outside client choose the server's internal csum_start.

On receiving those bytes, the host's tun_get_user() creates the kernel packet object, a sk_buff. With IFF_VNET_HDR enabled, it also stores the additional header's offload instructions. TAP processing advances past Ethernet so that later code reads from the IPv4 header. That changes where reading begins, while the saved checksum position remains available for the eventual software calculation.

The attacker needs control over that device data. The AWS bulletin describes privileged access inside a guest. The Linux CNA's 9.3 scenario describes a malicious microVM tenant controlling its assigned virtio-net output without host credentials or direct access to the host's /dev/net/tun. An application with only ordinary network-request capabilities cannot thereby control the offload instructions in virtio_net_hdr. Those capabilities alone do not establish the same trigger.

2 A checked offset can still target the wrong field

First consider the packet's storage. A sk_buff can keep an initial portion contiguous while holding the remaining data in separate fragments. That contiguous, valid portion is its linear data. skb->head points to the underlying allocation, while skb->data points to the current packet data. Space reserved between them for operations such as adding headers is called headroom.

The old virtual-device input code tries to leave room for IPv4 by requiring the checksum start to be at least 20 bytes beyond the current data position. Twenty bytes is the basic IPv4 header length. But data may still point to Ethernet, with IPv4 farther ahead. Counting 20 bytes from those two origins reaches different places.

Take an illustrative layout with data pointing at an ordinary 14-byte Ethernet header, IPv4 immediately after it, and checksum calculation starting 20 bytes from data. The old check accepts 20. Measured from IPv4, though, the start is only 20 - 14 = 6 bytes in, still inside the header. Pulling Ethernet advances data without moving the saved calculation start. This example isolates the differing origins. Its offset of 6 is distinct from the original research's later first-byte mutation case.

The code is in __virtio_net_hdr_to_skb(), lines 97–117. On this IPv4 branch with VIRTIO_NET_HDR_F_NEEDS_CSUM, it makes the required data accessible, then uses skb_partial_csum_set() to install checksum state. That function checks the result field's end against linear data and adds headroom to the supplied data-relative start, saving a head-relative csum_start.

Saving the start also sets transport_header to that position. The following skb_transport_offset() subtracts current data, as shown in the offset accessor definitions. Its result still counts from data. The old check compares this distance with nh_min_len, initially 20, accepting the example's 20 without recognizing that the position remains inside IPv4.

// Existing lower-bound check in __virtio_net_hdr_to_skb()
if (skb_transport_offset(skb) < nh_min_len)
    return -EINVAL;

// Positions after this branch installs metadata, all relative to head
// D = current data, N = network_header, C = csum_start
// Old condition: C - D >= 20
// Outside the IPv4 header: C >= N + ip_hdrlen(skb)

The eventual writer, skb_checksum_help(), lines 3617–3668, likewise checks the start and result-field end against linear data, ensures the memory is writable, and stores the computed result. Those checks prevent some direct overruns. They still admit a write inside the packet at a semantically wrong position. If that position belongs to the IPv4 header, checksum completion changes bytes other code has already interpreted.

// The actual result store in skb_checksum_help()
*(__sum16 *)(skb->data + offset) = csum_fold(csum) ?: CSUM_MANGLED_0;

3 The checked 20-byte header becomes a 60-byte header

The first IPv4 byte holds both a version and a header length. Its upper four bits contain 4 for IPv4. The lower four bits, IHL, count four-byte words, so 5 means 20 bytes and 15 means 60. Extra header space holds optional fields, known as IPv4 options. Earlier parsing and routing of a 20-byte header do not establish that a 60-byte header is valid.

In the reported path, incoming IP fragments are first reassembled on the host, then sent onward. Reassembly makes the received fragments one logical datagram. It does not require all their bytes to be copied into a single contiguous buffer. This datagram is larger than 28 bytes, but its contiguous beginning, skb_headlen(skb), is only 28 bytes: the original 20-byte header and 8 data bytes retained from the first fragment. Other content can stay in separate storage. Output now requires fragmentation again, with each resulting fragment needing its own IPv4 header.

The datagram still carries CHECKSUM_PARTIAL, leaving the host a checksum to calculate. Inside the fragmenter, that calculation happens before the header length is read. Whether the previously checked header survives unchanged depends on where the calculation writes its result.

Which packet states allow this checksum calculation

ip_frag_queue() waits for first and last fragments and enough collected data to cover the datagram before invoking reassembly. If a non-final fragment ends off an eight-byte boundary, it trims the tail and clears CHECKSUM_PARTIAL to CHECKSUM_NONE. inet_frag_reasm_finish() restores data to the network header and attaches the remaining fragments. Differing checksum states also clear PARTIAL. Even when all input fragments carry that marking, it has to survive this processing for the later completion to run.

GSO, generic segmentation offload, postpones splitting a large outgoing packet into segments until later software or device processing. Input reassembly joins IP fragments that have already arrived. These are different operations here. GSO markings on input fragments are cleared when entering the reassembly queue, keeping the original fragment's offload description from being carried incorrectly into the reassembled object.

On output, the link's packet-size limit is its MTU. Reassembly also records the original fragment size in IPCB(skb)->frag_max_size. Ordinary IPv4 output's __ip_finish_output() requests fragmentation if packet length exceeds the MTU or that record is nonzero. ip_fragment() then checks the Don't Fragment bit, DF. With DF clear it proceeds directly. With DF set, ignore_df and original-fragment-size conditions still apply. If these do not permit fragmentation, the function sends an ICMP fragmentation-needed response, frees the packet, and returns -EMSGSIZE. That packet never reaches the later write and copy.

After the packet enters ip_do_fragment(), lines 760–801, that function first calls skb_checksum_help() to finish the pending checksum. Metadata specifies the calculation and output positions. The stored value comes from the checksum calculation over packet data, and the guest supplies those bytes too. In the particular case recorded by the researchers, this write changes the first byte from 0x45 to 0x4f. The upper four bits still hold IPv4's 4. The lower four change from 5 to hexadecimal f, or decimal 15, increasing the header length from 20 to 60 bytes. Once the helper returns, ip_do_fragment() reads iph->ihl and consumes that new value.

// Actual pre-fix ordering in ip_do_fragment(), unrelated statements omitted
if (skb->ip_summed == CHECKSUM_PARTIAL &&
    (err = skb_checksum_help(skb)))
    goto fail;

iph = ip_hdr(skb);
// ...
hlen = iph->ihl * 4;

The fragmenter uses the hlen it has just read to decide how much IPv4 header to copy. The original 20-byte header is now treated as 60 bytes, exceeding the 28 bytes stored contiguously at the beginning. No competing thread has to win a race. The same function writes first and reads afterward, consuming the value that its own checksum calculation changed.

Fragmentation can then reuse existing fragments or allocate new packets and copy into them. Linux checks whether the original layout permits reuse. The 28-byte linear length alone does not establish which route it takes. The extra copy described below happens in the new-packet slow path.

How fragmentation chooses between reuse and copying

After the header length changes, the function still requires the MTU to hold that header plus at least 8 data bytes, then chooses how to fragment. When a frag_list already exists, Linux tries to reuse it without allocating and copying every packet again. It checks the first portion's size, eight-byte alignment, existing fragment flags, cloning, and reserved header space, followed by each fragment's geometry and sharing. Unsuitable layouts fall back to slow_path. Reassembly does not unconditionally select the copying path.

// One fast-path geometry check in ip_do_fragment()
unsigned int first_len = skb_pagelen(skb);
if (first_len - hlen > mtu || /* remaining conditions in pinned source */
    ((first_len - hlen) & 7) || ip_is_fragment(iph) ||
    skb_cloned(skb) || skb_headroom(skb) < ll_rs)
    goto slow_path;

Here first_len comes from skb_pagelen(), which counts this skb's linear data and page fragments. The earlier 28-byte observation establishes only skb_headlen() and cannot simply be substituted here. If first_len is also 28 while the claimed header is 60, unsigned first_len - hlen becomes a large value exceeding the MTU. The fast path rejects reuse and falls back to copying. Packets without a frag_list reach copying directly. Both routes meet at the same slow_path.

In the slow path, ip_frag_init() saves the new hlen. ip_frag_next() allocates an output packet and copies its header with skb_copy_from_linear_data(..., state->hlen). This header copy reads 60 consecutive bytes from the beginning of linear data. It does not follow non-linear fragments to assemble the remaining payload. After the valid 28 bytes, it continues into the memory immediately following them.

ip_frag_next() then updates the output fields and header checksum and returns the new skb2. Its caller, the ip_do_fragment() slow path, calls output(net, sk, skb2) to send it. The extra copied bytes become IPv4 options and leave the host in the outgoing packet.

The copy treats 60 - 28 = 32 extra bytes of memory as header content. It goes past valid linear data, while the underlying allocation may still have readable tailroom. The read need not cross the entire kernel-allocated object, or slab object. It can therefore succeed without a crash even though the bytes no longer belong to the sender's packet.

Relative layout of one datagram before and after checksum completion. The network-header origin, original 20-byte header end and 28-byte linear-data end remain fixed as IHL changes from 5 to 15, expanding the copy to 60 bytes. The patched branch checks the calculation start before writing.
Figure 1: One datagram's relative layout. H is its actual IPv4 header length. The allocation end is not shown. The patched branch checks before writing. Open full-size image.

4 What connects the disclosure to an escape

Could the extra bytes simply have come from the sender? pwn.ai compared packets that kept their original header length with packets that triggered the later write. The researchers checked the final packet structure and looked for nonzero bytes beyond known input. They compared those bytes with known, sender-controlled data to determine whether the input already contained them, then checked what the receiving application actually received against raw captures to rule out construction and capture artifacts. These tests showed the guest reading host memory outside its own packet, crossing guest-host isolation.

The researchers continued beyond reading host memory on their own KVM setup. pwn.ai reports selected 64-byte kernel writes, followed by a verification program running as host root and returning a host-generated marker to the guest. It reports completing that chain in four tests starting from true cold boots. Vercel also confirms host execution demonstrations on researcher-owned systems matching its configuration. Allocator behavior, object lifetime, and adaptation to other virtualization configurations were left for a later article. The published code and explanation are not enough to reconstruct a universal, reliable administrator-execution exploit.

Where an untrusted tenant can already supply malformed offload metadata to a vulnerable host stack, treat this as a host-isolation vulnerability without waiting to reproduce root execution yourself. Installing KVM, running Linux, or exposing an ordinary website alone does not establish that entry path. pwn.ai also identifies related local TUN and AF_PACKET routes, but a complete escape has not been demonstrated for every entry point.

5 The fix checks before the deferred write

Michael S. Tsirkin's accepted patch preserves checksum offload. It adds ip_check_csum_start() in include/net/ip.h, lines 72–82, requiring the calculation start to be no earlier than the end of the current IPv4 header. It uses the actual ip_hdrlen(skb), including options. Reducing that rule to a fixed minimum of 20 bytes would lose its meaning.

static inline int ip_check_csum_start(const struct sk_buff *skb)
{
    if (unlikely(skb->csum_start < skb->network_header + ip_hdrlen(skb)))
        return -EINVAL;
    return 0;
}

The old check in section 2 counts from data even when IPv4 begins farther ahead. The new check compares csum_start with network_header directly. Both count from head, so moving data does not change the distance between them. With N = network_header, H = ip_hdrlen(skb), and C = csum_start, it requires C >= N + H. Equality allows calculation to start at the IPv4 header's end, preserving ordinary transport-checksum offload.

The result goes at C + csum_offset. The offset cannot be negative, so a start outside IPv4 also keeps the result out of that header. skb_checksum_help() still checks the calculation start and result-field end against valid linear data, then calls skb_ensure_writable(). The new check keeps writes out of IPv4. The existing checks still enforce data bounds and writable storage.

With a 20-byte IPv4 header and calculation starting exactly at its end, the new lower bound accepts the start. Existing checks must still establish room for the entire two-byte result. A start inside the header is rejected before writing even if it lies inside the overall allocation. With options extending the header to 24 bytes, merely passing the basic 20-byte boundary is insufficient. Packets without pending checksum work do not enter ip_do_fragment()'s CHECKSUM_PARTIAL branch.

// Complete replacement condition block in ip_do_fragment()
if (skb->ip_summed == CHECKSUM_PARTIAL) {
    err = ip_check_csum_start(skb);
    if (!err)
        err = skb_checksum_help(skb);
    if (err)
        goto fail;
}

The fixed fragmentation path checks whether calculation starts before the IPv4 header's end. It calculates and writes only after that check passes. Failure in either the check or skb_checksum_help() reaches fail, which frees the packet, increments fragmentation failures, and returns the error. The write that enlarged the header is stopped before it happens. This still does not validate a complete TCP or UDP structure or confirm that the destination is that protocol's checksum field. The commit message calls it defense in depth, leaving complete input validation as separate work.

Historically, the 2008 TUN change allowed userspace to supply offload metadata through IFF_VNET_HDR, the introduction the CNA associates with Linux 2.6.27. The fix also references the 2010 addition of AF_PACKET offload support. That history explains why this is not specific to one cloud platform. It does not demonstrate escapes on every historical release, device, or configuration.

As checked on October 17, the Linux CNA lists fix floors of 7.2.11 and long-term versions 6.18.57, 6.12.113, 6.6.159, 6.1.190, 5.15.223, and 5.10.272. The kernel.org release list confirms these releases are available. The CNA also lists 7.3-rc7 as the mainline fix boundary, and that tag's source contains the new check. An RC is a release candidate, so this fix is no reason to move a production system to a candidate kernel. Distribution backports require their own complete package identities.

6 Why four other call sites need the check

Protecting ip_do_fragment() alone leaves other checksum writers untouched. Bridging has its own fragmenter, NFQUEUE may finish a checksum before passing a packet to userspace, and the firewall CHECKSUM target and IPsec output can do the same work. The patch therefore changes six files, sharing one new check across five write sites. The four remaining sites have different entry conditions and handle rejected packets differently.

Bridge connection-tracking fragmentation. nf_ct_bridge_refrag() checks the bridge control block's frag_max_size first. Zero returns NF_ACCEPT. A nonzero value dispatches by IPv4 or IPv6. IPv4 enters the separate fragmenter nf_br_ip_fragment(), bypassing ordinary ip_do_fragment(), so it needs the check too.

For CHECKSUM_PARTIAL, the bridge fragmenter checks that calculation starts at or after the IPv4 header's end, then calls skb_checksum_help() to calculate and store the result. Either failure goes to blackhole, frees the current skb, and returns 0. That return value accompanies a packet already discarded. The bridge caller then returns NF_STOLEN, telling the outer hook that the skb has been taken over and must not be processed again.

NFQUEUE. The kernel puts packet content into a Netlink message for a userspace inspector, while retaining the original skb. The message builder, nfqnl_build_packet_message(), copies that content in NFQNL_COPY_PACKET mode. It first calculates the pending checksum if NFQA_CFG_F_GSO is disabled and the skb carries CHECKSUM_PARTIAL. copy_range can cap the copied length. Even sending only the packet's prefix requires that calculation first. Metadata-only copies and queues accepting offload-form packets do not use this call site.

nf_queue_checksum_help() handles the calculation. For IPv4, it calls pskb_network_may_pull(sizeof(struct iphdr)) to make the basic header readable, then checks that calculation starts outside the IPv4 header. Either check failing returns -EINVAL before writing. Passing packets keep the existing algorithm selection: the CRC32c helper for SCTP, the ordinary checksum helper otherwise.

When these checks fail, the userspace inspector receives no packet and the original packet does not continue. The message builder returns NULL. The enqueue caller records -ENOMEM and exits. As the error unwinds, __nf_queue() releases the queue entry, the record describing this queueing operation. The outer nf_queue() then frees the original packet. Even malformed offsets rejected by the start check are consequently reported as an apparent memory shortage.

Queue bypass settings do not save this packet. FAIL_OPEN handles later queueing and unicast failures, while this message has already failed to build. QUEUE_BYPASS applies only to the -ESRCH no-queue error, not this -ENOMEM. The kernel still drops the packet.

The firewall CHECKSUM target. checksum_tg() calls skb_checksum_help() only for non-GSO packets marked CHECKSUM_PARTIAL. Before that call, the patch checks the calculation start for NFPROTO_IPV4. Failure returns the firewall verdict NF_DROP. Passing packets call the checksum helper, then return XT_CONTINUE to continue processing rules. IPv6 does not use the IPv4 header-length formula. The existing call conditions exclude GSO and non-PARTIAL packets, so those packets never reach the added start check. This target still ignores skb_checksum_help()'s return value, so the added drop comes only from the position check. Later calculation failures keep their previous handling.

XFRM output. xfrm_output() serves transformations including IPsec. Packet-level hardware offload takes an earlier output path. GSO packets may go to segmentation, and devices supporting ESP transmit checksums may skip software calculation. Packets that continue to the later CHECKSUM_PARTIAL branch need skb_checksum_help() to finish the checksum.

Immediately before that call, the patch checks IPv4 packets' calculation start, requiring it to be at or after the header's end. A failed check or calculation goes to the shared error label, increments LINUX_MIB_XFRMOUTERROR, frees the skb, and returns err. Successful packets continue to xfrm_output2(). Encryption and the earlier hardware-offload paths remain in place.

7 Update the machine that actually handles the packet

For self-managed KVM, Firecracker, or platforms hosting untrusted microVMs, I would prioritize the host kernel. Updating only guest images does not replace the code receiving TAP data and fragmenting IPv4 packets in this sequence. Pausing new untrusted guests limits additional exposure, while existing guests can still transmit through their existing TAP interfaces. Addressing those entry points requires moving workloads to fixed nodes or pausing the relevant workloads. This consumes spare capacity or interrupts service, and moving tenants onto another equally vulnerable host solves nothing.

Amazon Linux provides a useful backport example. Upstream 6.12's fix floor is 6.12.113, while ALAS2023-2026-3196 ships kernel6.12-6.12.110-135.201.amzn2023. The specific AWS bulletin lists it as fixed and directs customers self-managing the relevant virtual machines to update and reboot their hosts. Comparing only 6.12.110 < 6.12.113 misses that backport. The ALAS also supplies update instructions tied to a repository snapshot. Use the advisory matching the actual product and kernel stream, without copying a package name from another branch.

AWS says its managed EC2, Lambda, and Fargate services are unaffected and require no customer action for this issue. That statement does not cover a self-managed KVM host, nor does it make every machine running Amazon Linux exempt from updates. As of October 17, the relevant Red Hat and SUSE product pages still listed affected or pending products, while multiple Ubuntu kernel streams needed evaluation. An absent fixed-package number is not a safe result.

Start verification with uname -r on the host, then match the full distribution package, its fix record, and the kernel actually booted. Downloading and installing a new package while continuing to run the old kernel leaves this path unchanged. Check normal guest networking, fragmentation-dependent workloads, and monitoring afterward. If the update fails, recover onto a host image already confirmed to contain the fix, or keep untrusted workloads on fixed nodes. Do not automatically return them to the vulnerable pool. Without an authorized isolated test system, do not send escape traffic to production as an acceptance test.