Mitigating CVE-2026-98432 with XDP: when kernel.modules_disabled=1 got in your way

date
read
20 min

Introduction

CVE-2026-98432 is a critical vulnerability in the Linux kernel IPv4 TCP networking stack, found by Anthropic with Claude. Upstream fixed it on 15th October, Debian shipped the fix the next day and Ubuntu released it 6th November. And again, you still have to reboot your fleet of servers to apply the new kernel.

While waiting for the new kernel (or the reboot), every advisory was giving the same mitigation: one nftables rule dropping TCP packets with an IPv4 header length (hdrlength) > 5, i.e. packets carrying IP options. Just copy, paste, test, automate and it’s done… or maybe not ? What if your servers run with kernel.modules_disabled=1, and the nftables modules weren’t loaded before it was set ? Then the rule won’t even load. And while this vulnerability can be livepatched, kernel.modules_disabled=1 will block that too, as a livepatch is a kernel module.

In this article, we’ll see how to mitigate this CVE even with kernel module loading disabled (and without iptables/nftables), what XDP is and how to write and load your own XDP program.

CVE-2026-98432

How does it work ?

When a TCP packet arrives with SYN flag and IP options (IHL > 5), the kernel keeps it inside a shared buffer between the connection request and the socket created during the handshake. If the attacker restransmit the SYN at the right moment, one CPU can free this buffer while another CPU is still reading it to answer the retransmitted SYN: that’s a use-after-free bug type, triggerred remotely, before any auth. That’s also why this CVE is so highly ranked.

Sequence diagram of the CVE-2026-98432 race: an attacker sends a SYN with IP options, then retransmits it. CPU 0 frees the shared options buffer while CPU 1 is still reading it to build a SYN-ACK, causing a use-after-free Sequence diagram of the CVE-2026-98432 race: an attacker sends a SYN with IP options, then retransmits it. CPU 0 frees the shared options buffer while CPU 1 is still reading it to build a SYN-ACK, causing a use-after-free

The fix use kfree_rcu() instead of kfree(), to defer the freeing until all readers finished looking at that data. But for us, we need to work on the trigger: no IP options on SYN, less chance of triggering the bug. Hence the rule every advisory published:

table inet cve_2026_98432 {
    chain input {
        type filter hook input priority -310; policy accept;
        ip protocol tcp ip hdrlength > 5 counter drop
    }
}

Limitations

There is few limitations about patching this issue with nftables:

  • CIPSO/NetLabel: with labeling enabled, the kernel fills the buffer by itself, options on the wire or not, so the filter doesn’t help
  • IPv4 options: some very rare TCP services relying on them
  • not persistent: needs a proper nftables.conf setup otherwise it’s not persistent and should be removed once the kernel is fixed anyway
  • a band-aid: removes the trigger, not the bug, the only real fix is a patched kernel

Patching it

It’s just a rule to load, easy right ?

# nft -f CVE-2026-98432.nft
mnl.c:60: Unable to initialize Netlink socket: Protocol not supported

This error means the netlink socket nft uses to talk to the kernel couldn’t be opened, because nfnetlink (which nf_tables depends on) could not be loaded. The most common cause would be kernel.modules_disabled set to 1. Other causes might exist (seccomp policies, CONFIG_NETFILTER_NETLINK=n, running inside a sandbox that does not implement NETLINK_NETFILTER), but are rarer. (here, we have a case of kernel.modules_disabled=1)

My other idea was to use the good old tc, basically converting the nft rule:

# tc qdisc add dev ens6 handle ffff: ingress
# tc filter add dev ens6 parent ffff: protocol ip prio 1 u32 match u8 0x46 0xfe at 0 match ip protocol 6 0xff action drop
# tc filter add dev ens6 parent ffff: protocol ip prio 2 u32 match u8 0x48 0xf8 at 0 match ip protocol 6 0xff action drop

Splitting up the rules:

  • qdisc: qdisc, aka queuing discipline, decides how packets are treated when leaving an interface (egress): pfifo_fast or fq_codel for example, can be seen with ip link show <iface> or sysctl net.core.default_qdisc
  • qdisc add dev ens6 handle ffff: ingress: as there is no real qdisc on ingress, we hook on a special handle ffff: that is reserved for the ingress qdisc
  • filter add dev ens6 parent ffff:: attach our filter to this hook
  • protocol ip: only look at ipv4, ipv6 remains untouched
  • prio 1, prio 2: the order of evaluation of the filters, doesn’t matter much here as both filters drop
  • u32: this is the tc u32 classifier, a hash-table-based packet filter, pretty powerful, allows us to match on raw offsets inside the packet header
  • match u8 0x46 0xfe at 0: take the first byte of the IP header (version + IHL), the mask 0xfe ignores the last bit, then compare with 0x46: version = ipv4, IHL 6 or 7
  • match u8 0x48 0xf8 at 0: same byte, the mask 0xf8 ignores the last 3 bits, then compare with 0x48: version = ipv4, IHL 8 to 15
  • match ip protocol 6 0xff: match on all ip packets where the protocol field is set to 6 (for TCP)
  • action drop: drop everything that matched our rule

As usual with my naïve approach, I hit a wall: sch_ingress was not loaded either, resulting in error:

# tc qdisc add dev ens6 ingress
Error: Specified qdisc kind is unknown.

And as expected, everything was built as module and not loaded:

# grep -E 'CONFIG_NF_TABLES=|CONFIG_NET_SCH_INGRESS=|CONFIG_NET_CLS_U32=|CONFIG_NET_CLS_BPF=|CONFIG_NET_CLS_ACT=' /boot/config-$(uname -r)
CONFIG_NF_TABLES=m
CONFIG_NET_SCH_INGRESS=m
CONFIG_NET_CLS_U32=m
CONFIG_NET_CLS_BPF=m
CONFIG_NET_CLS_ACT=y

My last thought was: how to run code in the kernel without loading any module ? Here comes eBPF and XDP. And it’s enabled on my kernel:

# grep -E 'CONFIG_BPF_SYSCALL=|CONFIG_XDP' /boot/config-$(uname -r)
CONFIG_BPF_SYSCALL=y
CONFIG_XDP_SOCKETS=y
CONFIG_XDP_SOCKETS_DIAG=m

XDP

XDP or eXpress Data Path is a hook that runs an eBPF program on each frame that an interface receives. This comes as early as possible in the receive path (in native mode, before the sk_buff is even allocated), which makes it a good entry point for our mitigation as we want to avoid the packet reaching the bogus code. It was introduced in the kernel 4.8. It uses eBPF, so an XDP program is basically a tiny C code compiled with -target bpf. It’s mostly used for filtering, ddos protection, load balancing, routing or even firewalling. The latter is what we want to achieve here.

How does it work ?

The XDP program is attached to an interface. Then for each frame that is received, the kernel runs it before anything else and the program returns a verdict:

  • XDP_DROP: drop the packet immediately
  • XDP_PASS: let the packet continue through the network stack
  • XDP_TX: send the packet back out of the same interface
  • XDP_REDIRECT: send the packet to another interface or directly to a user socket with AF_XDP
We are here

An XDP program can be loaded in three different modes, the generated code stays the same, only the hook differs:

  • native: xdpdrv (or xdp), running in the NIC driver (the driver has to support it)
  • generic: xdpgeneric, in the core stack, after the sk_buff is allocated but still before any IP processing and reassembly (the most versatile, works on any interface, but bad if we need to drop early e.g to mitigate a ddos, due to the sk_buff allocation for example)
  • offloaded: xdpoffload, on the NIC itself (the NIC has to support it)

Requirements

XDP doesn’t have many requirements, as it only needs CONFIG_BPF_SYSCALL=y. To use AF_XDP, it needs CONFIG_XDP_SOCKETS=y.

# grep -E 'CONFIG_BPF_SYSCALL=|CONFIG_BPF_JIT=' /boot/config-$(uname -r)
CONFIG_BPF_SYSCALL=y
CONFIG_BPF_JIT=y

For the userland side, to build an XDP program, we need a compiler that can target BPF and the libbpf headers, on ubuntu 22.04 this is enough:

# apt-get install -y clang llvm libbpf-dev

Limitations

XDP can work only on ingress. It sees what the interface receives, not what is sent.

Only one XDP program can run on an interface. To run multiple XDP programs on the same interface, you need to use a dispatcher like xdp-loader.

Your code is considered unsafe, and you have to prove it is safe, otherwise even valid C code will be refused. Before loading your program, the kernel runs the eBPF verifier that walks through the code and accepts it only if it’s safe. For example, it will check that every memory read is preceded by a bounds check, or that a loop properly ends.

If you try to use XDP on the veth of a container, you will need privileges, as bpf() runs inside the host kernel and is not namespaced. To load it, it needs privileges on the host (CAP_BPF and CAP_NET_ADMIN, or CAP_SYS_ADMIN). By default, docker’s seccomp profile blocks bpf() from unprivileged containers too.

Writing an XDP program by hand

Anatomy of an XDP program

As an example program that is close to our final goal, we will use a small program that drops all incoming ICMPv4 packets and lets everything else through.

Click here to display full code
// xdp_noping.c
#include <linux/bpf.h>
#include <linux/if_ether.h>
#include <linux/ip.h>
#include <linux/in.h>
#include <bpf/bpf_helpers.h>
#include <bpf/bpf_endian.h>

SEC("xdp")
int xdp_noping(struct xdp_md *ctx)
{
    void *data = (void *)(long)ctx->data;
    void *data_end = (void *)(long)ctx->data_end;

    struct ethhdr *eth = data;
    void *eth_end = data + sizeof(struct ethhdr);
    if (eth_end > data_end)
        return XDP_PASS;

    if (eth->h_proto != bpf_htons(ETH_P_IP))
        return XDP_PASS;

    struct iphdr *ip = eth_end;
    void *ip_end = eth_end + sizeof(struct iphdr);
    if (ip_end > data_end)
        return XDP_PASS;

    if (ip->protocol == IPPROTO_ICMP)
        return XDP_DROP;

    return XDP_PASS;
}

char LICENSE[] SEC("license") = "GPL";

To build it and run it:

# clang -O2 -g -target bpf -I/usr/include/$(uname -m)-linux-gnu -c xdp_noping.c -o xdp_noping.o
# ip link set dev ens6 xdpgeneric obj xdp_noping.o sec xdp
# ip link set dev ens6 xdpgeneric off

The last line removes it.

Includes:

#include <linux/bpf.h>
#include <linux/if_ether.h>
#include <linux/ip.h>
#include <linux/in.h>
#include <bpf/bpf_helpers.h>
#include <bpf/bpf_endian.h>

The first four headers are just plain kernel headers, the ones that a userland network tool would use:

  • linux/bpf.h: struct xdp_md and XDP_* return codes
  • linux/if_ether.h: struct ethhdr and ETH_P_IP
  • linux/ip.h: struct iphdr
  • linux/in.h: IPPROTO_* constants

Then the libbpf-specific ones (as we don’t have libc there, the program runs in the kernel):

  • bpf/bpf_endian.h: bpf_htons() instead of the libc htons from arpa/inet.h
  • bpf/bpf_helpers.h: SEC() macro

The section and the entry point:

SEC("xdp")
int xdp_noping(struct xdp_md *ctx)

SEC() is a macro telling the compiler to put the function xdp_noping() inside the xdp ELF section. As we can put multiple xdp programs in a single .o, the loader needs to know which section to load. By convention, xdp is loaded as an XDP program, kprobe as a kprobe etc.. Then the function is compiled to BPF bytecode, not to x86 machine code, by using -target bpf.

The context:

void *data = (void *)(long)ctx->data;
void *data_end = (void *)(long)ctx->data_end;

struct xdp_md *ctx is the only argument passed to our function, and contains a few fields:

struct xdp_md {
    __u32 data;             // start of the packet data
    __u32 data_end;         // end of the packet data
    __u32 data_meta;        // start of the metadata prepended to the packet
    __u32 ingress_ifindex;  // ingress network interface index
    __u32 rx_queue_index;   // receive queue index on the NIC
    __u32 egress_ifindex;   // egress interface index (devmap programs only)
};

The fields are declared as __u32, but the verifier rewrites them into real pointers when loading the program, that’s why we need the (void *)(long) cast to use them. In our example, the only fields that matter are data and data_end, the first byte of the frame and the byte after the last one. You only get raw bytes including the ethernet header. There is no sk_buff telling where the IP header is, we need to walk through the headers ourselves. This is what data should look like and what we will check:

Ethernet and IPv4 header Ethernet and IPv4 header
  • h_proto: defines that the frame is ipv4
  • protocol: defines what proto the packet carries
  • IHL: says how long the IP header is, in 32-bit words (5 means 20 bytes, no options)

Parsing the ethernet header:

struct ethhdr *eth = data;
void *eth_end = data + sizeof(struct ethhdr);
if (eth_end > data_end)
    return XDP_PASS;

if (eth->h_proto != bpf_htons(ETH_P_IP))
    return XDP_PASS;

As the ethernet header starts at the very first byte of the frame, we just make eth point at data, and read it as struct ethhdr. The struct size is 14 bytes (6 bytes eth addr dest, 6 bytes eth addr src, 2 bytes h_proto), so eth_end is at data+14. Before accessing eth, we need to check that the end of eth is still inside the frame, otherwise the verifier will refuse to load the program with an invalid access to packet error. h_proto will tell us what comes next, as we only want to match on IPv4 packets. Anything else is left untouched.

Parsing the ipv4 header:

struct iphdr *ip = eth_end;
void *ip_end = eth_end + sizeof(struct iphdr);
if (ip_end > data_end)
    return XDP_PASS;

Same goes for the ipv4 header, we start reading at the end of the eth header with a total size of sizeof(struct iphdr) (20 bytes). We check again that the end of the ip header is still in the frame before reading ip.

The drop:

if (ip->protocol == IPPROTO_ICMP)
    return XDP_DROP;

return XDP_PASS;

There is our core feature, dropping every packet that is ICMP. All other packets that do not match this filter are just accepted with XDP_PASS.

License:

char LICENSE[] SEC("license") = "GPL";

Some helpers are only usable by GPL-compatible programs, so the kernel checks this license section.

An XDP program can be loaded in different ways. Here we saw ip link, but there are other tools that can do it. Whatever the tool used, the steps are the same: read the “.o”, then run the eBPF verifier, JIT and attach. The difference is how the program is attached and who owns it at the end.

ip link loads the object and attaches through netlink. One program per interface, that’s our main limitation. Nothing more to manage and it stays there until you detach it.

bpftool splits it: load and pin the program in /sys/fs/bpf then attach it:

# bpftool prog load xdp_ipopt.o /sys/fs/bpf/ipopt
# bpftool net attach xdp pinned /sys/fs/bpf/ipopt dev ens6
# bpftool net detach xdp dev ens6

Note that pinning doesn’t make anything persistent: /sys/fs/bpf lives in memory, so after a reboot both the pin and the XDP attachment are gone. Same goes for ip link, nothing in this post survives a reboot.

It comes with a bunch of useful commands for inspection, for example bpftool net show, bpftool prog show:

# bpftool net show
xdp:
ens6(3) driver id 75

tc:

flow_dissector:

netfilter:
# bpftool prog show
[...]
75: xdp  name drop_tcp_ipopts  tag e96632e92abfa7a9  gpl
	loaded_at 2026-11-10T19:41:22+0000  uid 0
	xlated 264B  jited 163B  memlock 4096B  map_ids 4
	btf_id 89

But bpftool needs the linux-tools for your kernel version installed:

# apt-get -y install linux-tools-$(uname -r)

xdp-loader (from xdp-tools) installs a small dispatcher program, so multiple XDP programs can share one interface and run one after the other.

Other custom libbpf loaders (Cilium for example) attach through bpf_link. It belongs to the process that created it or to a pin in /sys/fs/bpf, so other tools won’t be able to remove it.

For a one-shot mitigation, ip link should be enough and bpftool is still a good swiss-army knife for more information about what is attached and how.

Mitigating CVE-2026-98432 with XDP

The program

The mitigation is exactly the same skeleton that we used previously, with one condition changed: instead of dropping ICMP packets, we drop TCP packets where their IHL > 5. And since XDP runs way before conntrack and defrag, it filters earlier than the -310 hook, which is a nice bonus.

// xdp_ipopt.c
#include <linux/bpf.h>
#include <linux/if_ether.h>
#include <linux/ip.h>
#include <linux/in.h>
#include <bpf/bpf_helpers.h>
#include <bpf/bpf_endian.h>

struct {
    __uint(type, BPF_MAP_TYPE_PERCPU_ARRAY);
    __uint(max_entries, 1);
    __type(key, __u32);
    __type(value, __u64);
} drop_cnt SEC(".maps");

SEC("xdp")
int drop_tcp_ipopts(struct xdp_md *ctx)
{
    void *data = (void *)(long)ctx->data;
    void *data_end = (void *)(long)ctx->data_end;

    struct ethhdr *eth = data;
    void *eth_end = data + sizeof(struct ethhdr);   /* byte 14 */
    if (eth_end > data_end)
        return XDP_PASS;

    if (eth->h_proto != bpf_htons(ETH_P_IP))
        return XDP_PASS;

    struct iphdr *ip = eth_end;
    void *ip_end = eth_end + sizeof(struct iphdr);  /* byte 34 */
    if (ip_end > data_end)
        return XDP_PASS;

    if (ip->protocol == IPPROTO_TCP && ip->ihl > 5) {
        __u32 key = 0;
        __u64 *count = bpf_map_lookup_elem(&drop_cnt, &key);
        if (count)
            *count += 1;
        return XDP_DROP;
    }

    return XDP_PASS;
}

char LICENSE[] SEC("license") = "GPL";

Here we are only working on:

  • TCP only: if other ipv4 packets with ihl > 5 were dropped, it would also kill IGMP (Router Alert option) and RSVP, whilst the bug sits in TCP’s SYN handling
  • every TCP packet with options not only SYN: to trigger the bug it needs a SYN, but XDP runs before ip_defrag(). If a SYN is split into tiny fragments, you might end up with the options on one fragment and the TCP flags end up in the second one. If we were filtering only on SYN, none of those tiny packets would have matched SYN + ip->ihl > 5 and the kernel would have reassembled the fragments after the XDP and eventually reached the vulnerable code. Our filter does not care about TCP flags, if the first fragment carries TCP and IHL > 5 it’s dropped, so the second fragment alone is useless and kernel throws it away when reassembly times out.
  • no VLAN parsing: tagged frames are unchecked because my interface does not carry any, see here for working code with VLAN support.

Compared to the ping example, there is one new feature: a counter. This allows us to count how many packets our rule actually dropped:

struct {
    __uint(type, BPF_MAP_TYPE_PERCPU_ARRAY);
    __uint(max_entries, 1);
    __type(key, __u32);
    __type(value, __u64);
} drop_cnt SEC(".maps");
[...]
    if (ip->protocol == IPPROTO_TCP && ip->ihl > 5) {
        __u32 key = 0;
        __u64 *count = bpf_map_lookup_elem(&drop_cnt, &key);
        if (count)
            *count += 1;
        return XDP_DROP;
    }
[...]

This counter lives in a BPF map drop_cnt, a small piece of memory shared between the program and the userland:

  • BPF_MAP_TYPE_PERCPU_ARRAY: an array where each CPU has its own copy, avoiding locking and thus no race when two cpus drop a packet at the same time (if you have 8 cpus, you will have 8 copies of the counter)
  • max_entries: we only count one thing, so 1 slot
  • key and value: a map works like a dict, each entry has a key/value attached, to read the value, we give the key (__u32 key = 0; as we only have one value)
  • SEC(".maps"): same as SEC("xdp"), it adds the map definition in a .maps section in the ELF, so the loader knows it has to create it

We also need to perform a check on count in case bpf_map_lookup_elem() returns a NULL pointer. The eBPF verifier refuses to dereference a pointer if it cannot prove that it’s valid.

For a VM running a regular ubuntu 22.04:

# apt-get install clang llvm libbpf-dev
# clang -O2 -g -target bpf -I/usr/include/$(uname -m)-linux-gnu -c xdp_ipopt.c -o xdp_ipopt.o
# ip link set dev ens6 xdp obj xdp_ipopt.o sec xdp
# ip link show dev ens6
3: ens6: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 xdp qdisc fq_codel state UP mode DEFAULT group default qlen 1000
    link/ether 02:00:00:24:32:db brd ff:ff:ff:ff:ff:ff
    prog/xdp id 75 tag e96632e92abfa7a9 jited
    altname enp0s6

xdp is used, not xdpgeneric, because this is inside a virtual machine and virtio_net supports native mode, so the XDP program will run in the driver. If your driver does not, xdpgeneric can be used instead.

Is it really dropping ?

We can test our XDP program using nping from the nmap package, to add IP options to a SYN. So from another machine:

# nping --tcp -p 22 --flags syn --ip-options R -c 3 SERVER_PATCHED
# nping --tcp -p 22 --flags syn -c 3 SERVER_PATCHED

The first command carries the record route option (IHL=15), the second one is our control.

Which returns:

root@server2:~# nping --tcp -p 22 --flags syn -c 3 172.16.16.2

Starting Nping 0.7.98 ( https://nmap.org/nping ) at 2026-11-10 17:54 UTC
SENT (0.0100s) TCP 172.16.16.3:12677 > 172.16.16.2:22 S ttl=64 id=54749 iplen=40  seq=970463648 win=1480
RCVD (0.0111s) TCP 172.16.16.2:22 > 172.16.16.3:12677 SA ttl=64 id=0 iplen=44  seq=3694993931 win=64240 <mss 1460>
SENT (1.0102s) TCP 172.16.16.3:12677 > 172.16.16.2:22 S ttl=64 id=54749 iplen=40  seq=970463648 win=1480
RCVD (1.0109s) TCP 172.16.16.2:22 > 172.16.16.3:12677 SA ttl=64 id=0 iplen=44  seq=3710621172 win=64240 <mss 1460>
SENT (2.0121s) TCP 172.16.16.3:12677 > 172.16.16.2:22 S ttl=64 id=54749 iplen=40  seq=970463648 win=1480
RCVD (2.0127s) TCP 172.16.16.2:22 > 172.16.16.3:12677 SA ttl=64 id=0 iplen=44  seq=3726273526 win=64240 <mss 1460>

Max rtt: 0.998ms | Min rtt: 0.588ms | Avg rtt: 0.740ms
Raw packets sent: 3 (162B) | Rcvd: 3 (132B) | Lost: 0 (0.00%)  <----- no packet loss, we dropped nothing
Nping done: 1 IP address pinged in 2.02 seconds

root@server2:~# nping --tcp -p 22 --flags syn --ip-options R -c 3 172.16.16.2

Starting Nping 0.7.98 ( https://nmap.org/nping ) at 2026-11-10 17:54 UTC
SENT (0.0155s) TCP 172.16.16.3:50576 > 172.16.16.2:22 S ttl=64 id=21844 iplen=80 ipopts={ NOP RR{#0.0.0.0 0.0.0.0 0.0.0.0 0.0.0.0 0.0.0.0 0.0.0.0 0.0.0.0 0.0.0.0 0.0.0.0}} seq=3386723784 win=1480
SENT (1.0158s) TCP 172.16.16.3:50576 > 172.16.16.2:22 S ttl=64 id=21844 iplen=80 ipopts={ NOP RR{#0.0.0.0 0.0.0.0 0.0.0.0 0.0.0.0 0.0.0.0 0.0.0.0 0.0.0.0 0.0.0.0 0.0.0.0}} seq=3386723784 win=1480
SENT (2.0170s) TCP 172.16.16.3:50576 > 172.16.16.2:22 S ttl=64 id=21844 iplen=80 ipopts={ NOP RR{#0.0.0.0 0.0.0.0 0.0.0.0 0.0.0.0 0.0.0.0 0.0.0.0 0.0.0.0 0.0.0.0 0.0.0.0}} seq=3386723784 win=1480

Max rtt: N/A | Min rtt: N/A | Avg rtt: N/A
Raw packets sent: 3 (282B) | Rcvd: 0 (0B) | Lost: 3 (100.00%)  <----- server running xdp program is not responding syn ack after our syn with options record route
Nping done: 1 IP address pinged in 3.03 seconds

Run the test before attaching the XDP program. You need to be sure that the network didn’t already drop the IP-option packet during transit (which happened when I pinged from my own pc, but two of my instances were able to ping each with the RR option). If the SYN with options already gets no answer without the filter, something on the network path is doing the job and you won’t be able to prove anything.

On our server, don’t expect to see the dropped packets with tcpdump: in native mode, XDP drops them way before tcpdump can see them, so this capture stays empty:

# tcpdump -nvi ens6 'tcp and ip[0] & 0xf > 5'

Instead, we can check the drops with the driver counters and with our own map:

# ethtool -S ens6 | grep -i xdp
     rx_queue_0_xdp_packets: 9
     rx_queue_0_xdp_tx: 0
     rx_queue_0_xdp_redirects: 0
     rx_queue_0_xdp_drops: 9  <---- this should increase
     tx_queue_0_xdp_tx: 0
     tx_queue_0_xdp_tx_drops: 0
# bpftool map dump name drop_cnt
[{
        "key": 0,
        "values": [{
                "cpu": 0,
                "value": 0
            },{
                "cpu": 1,
                "value": 0
            },{
                "cpu": 2,
                "value": 0
            },{
                "cpu": 3,
                "value": 3
            }
        ]
    }
]

Why 9 drops on one side and 3 on the other ? The driver counter is cumulative since boot and counts every XDP_DROP (including my previous tests) while the map is created from scratch each time the program is loaded and only counts what our rule matched. That’s why using bpftool map dump to read drop_cnt lets us check that it’s actually our XDP program that dropped the packets.

Rolling back

Just run the command of your choice, as long as you detach it with the same mode used when attaching:

# ip link set dev ens6 xdpdrv off  # shown as xdp in ip link
or
# bpftool net detach xdpdrv dev ens6

If you attached it with bpftool, once detached, a simple rm cleans up the pin. It’s only cleanup though: the attachment holds its own reference to the program, so rm alone won’t detach anything:

# rm /sys/fs/bpf/ipopt

If you get Can't replace active BPF XDP link, it means the program was loaded through a bpf_link by a tool like bpfilter or xdp-loader, and only the tool that owns it can remove it. Detaching is instant, you won’t notice a thing.

Conclusion

So, should you rely on it ? Honestly, once you wrote your XDP program, tested it, and checked that it doesn’t degrade performance, for a few days you can. It’s a few lines of code, and it runs before any netfilter hook. For fixing this CVE, it’s still a band-aid.

Don’t forget to set the two sysctls that make the race harder to hit. Canonical says that the bug is not MPTCP specific, so it will lower the risk without totally closing it.

# sysctl net.ipv4.tcp_migrate_req net.mptcp.enabled
# sysctl -w net.ipv4.tcp_migrate_req=0 net.mptcp.enabled=0

As usual, the real fix is patching the kernel. Update your servers frequently and reboot.

PS: kernel.modules_disabled=1 sucks, especially if you cannot reboot your server ASAP when needed: it blocks every module, including the ones you trust. Check out better alternatives to harden your system while keeping some room for security fixes:

  • module.sig_enforce=1: load modules only if they are signed with a trusted key
  • kernel lockdown (integrity mode): enforces module signatures too, blocks other ways of modifying the running kernel from userland (/dev/mem, unsigned kexec…)
  • LSM: restrict who can load modules and from where (selinux, apparmor, loadpin to only accept modules from a trusted read-only filesystem)