CVE-2026-23111: exploiting and detecting a nftables UAF born from a security fix

rafaeldavidtin1 pts0 comments

Detecting the nftables Catchall Use-After-Free (CVE-2026–23111) by thinking outside the box | by Miggo Engineering | Jun, 2026 | MediumSitemapOpen in appSign up<br>Sign in

Medium Logo

Get app<br>Write

Search

Sign up<br>Sign in

Detecting the nftables Catchall Use-After-Free (CVE-2026–23111) by thinking outside the box

Miggo Engineering

11 min read·<br>Just now

Listen

Share

(Authored by Rafael David Tinoco)<br>Press enter or click to view image in full size

A few weeks ago I wrote about detecting CopyFail and DirtyFrag by thinking outside the box.<br>The thesis there was simple: you don’t catch a kernel LPE by chasing the root shell at the end of it — you catch it by recognizing the one abnormal pattern the exploit cannot avoid producing, and you do it with high confidence and near-zero false positives.<br>This is part two. Same job, different beast.<br>This time the target is CVE-2026–23111 , a use-after-free in the Linux kernel’s nf_tables subsystem - the thing behind nftables, the modern replacement for iptables. It is reachable by any unprivileged user on an affected kernel, and it is the kind of bug that ends in a root shell or a container escape.<br>I went all the way with this one. I built a full, self-contained, fully autonomous exploit that takes an unprivileged user to uid=0 - leaking every kernel address it needs at runtime, no cheats. I'm publishing that exploit and the deep research in my own personal repository. But, exactly like last time, we can talk about the discovery freely, and we can talk about how we detect it - which is the part that matters for everyone running production Linux.<br>There is a lot of material to cover. We’ll go through:<br>The nf_tables building blocks: chains, the use counter, verdict maps, catchall elements, and transactions.<br>The bug — a single inverted character, and the delicious irony of where it came from.<br>From a reference-count off-by-one to a root shell (the short version).<br>Why the obvious detections fall apart.<br>How we catch it anyway — by watching the control-plane ritual instead of the payload.<br>The building blocks<br>nftables lets you build firewall rules out of a few primitives. You need four of them to follow this bug.<br>Chains hold rules. A chain carries a reference counter, chain->use, that counts how many things point at it - rules that goto/jump to it, map entries that resolve to it, and so on. The kernel refuses to delete a chain while use > 0. Hold that thought: this counter is the entire ballgame.<br>Verdict maps map a key to a verdict, and a verdict can be goto or jump . Taking such a reference bumps the target chain's use; dropping it decrements it.<br>Catchall elements are the wildcard entry in a set or map — * : goto some_chain. Here is the important detail: the pipapo set backend stores its normal elements in its own data structure, but catchall elements live somewhere else entirely, on a separate catchall_list. That separation is exactly where the bug hides.<br>Transactions. Every nftables change is a netlink batch that is staged and then either committed or aborted as a single unit. If anything in the batch fails, the kernel walks back every staged operation on the abort path and undoes it, so the ruleset ends up exactly as it was. Deactivating a catchall element during a staged delete has to be undone - reactivated - on abort. That undo is the line of code that's wrong.<br>The bug: one inverted character<br>On the abort path, catchall map elements are reactivated by nft_map_catchall_activate(). It is supposed to mirror its non-catchall sibling: skip the elements that are already active, and process the inactive ones (reactivating them and restoring the chain reference they hold). The vulnerable version does the exact opposite. The whole fix is the removal of a single !:<br>/* vulnerable */ if (!nft_set_elem_active(ext, genmask)) continue;<br>/* fixed */ if ( nft_set_elem_active(ext, genmask)) continue;Because the abort path skips the inactive (just-deactivated) catchall element instead of restoring it, the call that would bump the chain’s reference back never runs. The chain’s use counter is now permanently one too low - there is a live, uncounted reference to it.<br>The map still points at the chain. The kernel just believes one fewer reference exists than actually does. Drive that counter to zero and you can delete — and free — a chain that something is still pointing at. That is the use-after-free.

Here’s the part I love. This bug is not some ancient dusty corner of the kernel. It is a regression introduced by a security fix!<br>The very commit that added nft_map_catchall_activate() - 628bd3e49cba, "drop map element references from preparation phase" - was the remediation for an earlier nftables refcount bug, CVE-2023-4244 . Fixing one reference-counting flaw quietly planted another, and it rode along into mainline (~v6.6) and got backported across the stable LTS branches . It sat there from late 2023 until a one-character patch landed in February 2026.<br>From a reference-count off-by-one to...

catchall reference chain kernel nftables from

Related Articles