How I mitigated CVE-2026-9999 (PwnEsacape) with eBPF
A kprobe can watch a kernel write happen, but it has no authority to stop one. How I moved a CVE-2026-99999 (PwnEscape) guard from killing the sender to dropping the packet at PREROUTING before it leaves the node.
The first version of my guard for CVE-2026-99999 (PwnEscape) worked exactly as designed. The trigger process died with SIGKILL and exit code 137, and the agent logged the csum_start and IHL of the packet that set it off. It looked like a clean win until I checked the other end of the wire. Three fragments from that same packet had arrived.
A kprobe is a security camera. It can tell you that a kernel function ran, and it can tell you exactly how, but it cannot abort the function, change its return value, or touch its arguments. The packet had been admitted, the corrupting write had already happened inside skb_checksum_help(), and the kernel had forwarded the result. I had built containment, not prevention.
If you are trying to prevent a kernel-level bug with eBPF on a machine without an active BPF LSM, you will run into this shape of problem. Here is what the bug does, why the obvious hooks cannot veto it, and how a BPF_PROG_TYPE_NETFILTER program drops the packet at IPv4 PREROUTING before any of it matters.

What the bug does: a checksum that overwrites its own header
CVE-2026-99999 starts with CHECKSUM_PARTIAL. Checksum offload lets a sender defer the transport checksum to the NIC or to the receiving kernel, so the packet travels with two fields set instead of the checksum itself: skb->csum_start (where the checksum field begins) and skb->csum_offset (where within that region to write it). The kernel completes the checksum later, often right before the packet leaves or gets forwarded through fragmentation. My trigger builds a guest-style packet through a tun device, standing in for any workload that controls its own outgoing packets.
The admission path on this kernel validates csum_start against a fixed 20 bytes, the size of a minimal IPv4 header, instead of the actual header length in the packet. An IPv4 header with options can be 24 bytes or more. A packet with IHL=6 and csum_start=20 therefore passes admission while pointing inside a header the stack has already parsed.
When the deferred completion finally runs, skb_checksum_help() writes the computed checksum at csum_start + csum_offset, and with those values the write lands on the header itself, overwriting bytes such as the IHL, the addresses, or the fragmentation fields. Then ip_do_fragment() re-reads the now-corrupted header during forwarding, which hands the sender control over how the packet is fragmented and which output device it leaves through. That is the escape primitive.
ip_summed == CHECKSUM_PARTIAL
version == 4
csum_start < network_header + ihl * 4 // rejectThat is the predicate from the upstream fix, commit 6785011f8b16, which adds ip_check_csum_start() at the five callers and returns -EINVAL before the completion. It is also the exact check both of my eBPF guards implement.
First attempt: kill the sender at skb_checksum_help()
The quickest guard to write was a kprobe. It attaches at skb_checksum_help() entry, reads the four fields it needs from the sk_buff through offsets parsed from /sys/kernel/btf/vmlinux (no CO-RE), and applies the predicate above. In enforce mode, a match calls bpf_send_signal(SIGKILL) on the sending task.
It only kills when the completion is running in the sender's process context. A completion in a softirq, hardirq, or kernel thread is logged with the same fields, and no signal is sent, because the task that would receive it is not the sender. That check is the difference between a useful backstop and killing a random kernel thread.
In the lab, the sender died with exit 137 and the log looked like this:
CVE-2026-99999 KILLED: csum-trigger[92542] sent a CHECKSUM_PARTIAL skb whose
csum_start=84 points inside the IPv4 header (network_header=64, ihl=6)The three fragments were already on the wire. Killing the sender after the write is containment, and containment still has value: it stops the same process from sending the next packet. It is not prevention, and calling it prevention would be lying to myself about what is on the wire.
Four alternatives that cannot veto the write
Before moving to netfilter I ruled out the usual alternatives. Each one fails for a different reason.
bpf_override_return()andfmod_ret: these can only fault functions in the kernel's error-injection allowlist. I checked the list on the lab kernel: 519 entries, none matchingchecksum.skb_checksum_help()cannot be made to return-EINVALfrom a kprobe.- Neutralizing the skb in place: setting
ip_summed = CHECKSUM_NONEwould make the completion skip the write, and SystemTap can do exactly that. eBPF has no helper to write kernel memory, andstruct sk_buffis read-only to a BPF program. - TC or XDP: neither context exposes
csum_startorip_summed, so the predicate cannot be evaluated at all. TC egress is also too late, becauseip_do_fragment()has consumed the corrupted header before the packet reaches the qdisc. - BPF LSM: no BPF LSM is active on this kernel, and there is no security hook at the checksum completion anyway. LSM programs model security decisions, and this is a parsing bug in the IPv4 forwarding path.
All four approaches can observe the bug. The thing I needed was a hook with the authority to change the outcome, and none of these have it.
The hook that can say no: BPF netfilter at PREROUTING
Kernel 6.4 added BPF_PROG_TYPE_NETFILTER, where a BPF program runs as a netfilter hook rather than a classifier or a tracer. Its context carries the sk_buff, and its return value is a netfilter verdict, including NF_DROP. That is a different kind of power from a kprobe: the program decides whether the packet continues.
The guard attaches at NF_INET_PRE_ROUTING in the host network namespace with priority -450, which puts it before conntrack and every other PREROUTING hook. That is also before routing, ip_forward(), ip_do_fragment(), and skb_checksum_help(). A matching packet returns NF_DROP and is gone before the kernel decides where it was going. The corrupting write never runs, and there is no fragment to contain or sender to kill.
packet with CHECKSUM_PARTIAL (tun, NEEDS_CSUM)
|
v
NF_INET_PRE_ROUTING [netfilter drop]
| |
| accept +-- NF_DROP: counter++, packet ends here
v
route --> ip_forward --> ip_do_fragment --> pv1
|
+-- skb_checksum_help() <- kprobe backstop (logs, kills)Evaluating the predicate without CO-RE or a typed loader
aya 0.14 has no typed netfilter program, so the loader emits the BPF instructions directly and attaches with bpf(2): BPF_PROG_LOAD, then BPF_LINK_CREATE. The program type is 32, the expected attach type is 45, and the priority travels in the netfilter link attributes.
The sk_buff offsets come from the same BTF parsing as the kprobe and are encoded as instruction immediates, so there is no CO-RE relocating and no offsets map. If the layout cannot be resolved, the guard refuses to attach instead of guessing. On this kernel, head sits at 200, network_header at 184 as a 2-byte field, csum_start at 140 as a 2-byte field, and ip_summed is a 2-bit bitfield starting at bit 1029.
ip_summed is a bitfield and head is a pointer, so the program reads both with bpf_probe_read_kernel(). It also reads the IPv4 source byte through the helper, which keeps the verifier away from bitfield endianness and pointer typing concerns. The context is struct bpf_nf_ctx, which is a state pointer followed by the skb pointer.
/* what the emitted program does, in readable form */
skb = ctx->skb;
if (!skb)
return NF_ACCEPT;
if (skb->ip_summed != CHECKSUM_PARTIAL)
return NF_ACCEPT;
csum_start = skb->csum_start;
nh = skb->network_header;
iph = skb->head + nh;
if (iph[0] >> 4 != 4)
return NF_ACCEPT; /* IPv4 only */
if (csum_start >= nh + (iph[0] & 0x0f) * 4)
return NF_ACCEPT; /* upstream predicate */
counter++;
return NF_DROP;Packets that are not CHECKSUM_PARTIAL pay one program call and a single two-byte read. A small array map holds the drop counter, updated with a BPF_XADD after a map lookup, and the userspace agent polls it once per second:
CVE-2026-99999 REJECTED 1 packet(s) at IPv4 PREROUTING (1 total):
checksum completion never ran, nothing left the nodeI wanted the guard to be loud when it works, not only when it fails. A silent mitigation is indistinguishable from a broken one until someone shows you a counter.
What the numbers looked like
The lab runs minikube on kernel 6.8.0-117 (arm64) with Cilium, and the trigger builds a virtio-style packet: a NEEDS_CSUM header plus a 1400-byte UDP datagram, injected through a tun device and forwarded to a veth with a 576-byte MTU so ip_do_fragment() has to split it. I counted fragments on the receiving side with ip -s link.
- Default (netfilter drop + kprobe): the sender exits 0,
pv1counts 0 packets, and the agent logsREJECTED 1 packet(s). The kprobe never fires. --no-netfilter-drop(kprobe only): the sender exits 137,pv1counts 3 packets and 1490 bytes, and the log showsKILLED ... csum_start=84 ... network_header=64, ihl=6.- Control packet with no
NEEDS_CSUM: forwarded, 3 packets and 1482 bytes, no drop and no event. - Control packet with a valid
CHECKSUM_PARTIAL(csum_start=20, IHL=5): forwarded, 3 packets and 1482 bytes, no drop.
The second row is the one that matters. Disabling the drop changes nothing about the kprobe, and the fragments still arrive. The kprobe row is the reason the netfilter layer exists: without it, the sender dies, the alert fires, and the bytes are still on the wire.
The only difference between the first two rows is whether the hook has the authority to drop the packet. Detection quality did not change at all.
What this does not fix
The netfilter program is a per-netns signature check, not a kernel fix. It attaches in the host network namespace, where the DaemonSet runs with hostNetwork: true. A packet injected and forwarded entirely inside a pod or user network namespace never traverses that hook. The kprobe still detects it, but detection is not prevention. Attaching the program in every pod netns is possible, and it is not something I have done here.
- It needs kernel 6.4 or newer, plus
CAP_BPF,CAP_PERFMONforbpf_probe_read_kernel, andCAP_NET_ADMINto attach. The current manifest runs privileged instead of using that narrower set. - It checks IPv4 only.
- The kill is context-dependent. Completions in softirq, hardirq, or kthreads are logged and never killed.
- Syscall-level escapes (
unshare,mount,core_pattern, and their friends) are not addressed at all. Those need an LSM, and this kernel has no active BPF LSM. - The complete fix is the kernel patch. Upstream
6785011f8b16validatescsum_startat all five call sites, and a patched kernel does not need any of this.
I keep the kprobe anyway. It covers the netns gaps, it attributes the sender, and it is the only layer that works on kernels older than 6.4. The two layers are a backstop for an unpatched fleet, and they should be treated as temporary.
How to reproduce it on a scratch veth pair
The whole harness is a tun device and a veth pair. No kernel module, no special NIC features. On the node, as root:
ip link add pv0 type veth peer name pv1
ip link set pv0 mtu 576 up && ip link set pv1 up
ip route add 10.99.98.0/24 dev pv0
ip neigh replace 10.99.98.2 lladdr "$(cat /sys/class/net/pv1/address)" dev pv0 nud permanent
csum-trigger pescape0 none # control: 3 fragments, no guard event
csum-trigger pescape0 20 6 # valid CHECKSUM_PARTIAL: 3 fragments, no drop
csum-trigger pescape0 trigger # attack: REJECTED, 0 fragments, sender survivesTo see the kprobe-only row for yourself, add --no-netfilter-drop to the DaemonSet arguments and run the trigger again. The sender exits 137, and pv1 counts the fragments. Watching the counters change between those two runs is more convincing than any log line.
The lesson is in the counters, not the log lines. An exit code tells you that a process died. It does not tell you whether the packet left the node. If you are mitigating a packet-level bug, watch the other end of the wire. If the bytes still arrive, your hook does not have the authority you think it does.
Checksum offload, including the csum_start + csum_offset write, is documented in the kernel's checksum offload docs, and BPF_PROG_TYPE_NETFILTER has its own page in the eBPF docs. The drop program lives in patch-pwnescape-k8s/src/netfilter.rs, with the full repro in the README. If your fleet is on an unpatched kernel, read the kprobe row of the results table one more time before you trust a kill.