A few weeks ago I wrote about detecting CopyFail and DirtyFrag by thinking outside the box.

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.

This is part two. Same job, different beast.

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.

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.

There is a lot of material to cover. We’ll go through:

  • The nf_tables building blocks: chains, the use counter, verdict maps, catchall elements, and transactions.
  • The bug — a single inverted character, and the delicious irony of where it came from.
  • From a reference-count off-by-one to a root shell (the short version).
  • Why the obvious detections fall apart.
  • How we catch it anyway — by watching the control-plane ritual instead of the payload.

The building blocks

nftables lets you build firewall rules out of a few primitives. You need four of them to follow this bug.

  • 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.
  • Verdict maps map a key to a verdict, and a verdict can be goto <chain> or jump <chain>. Taking such a reference bumps the target chain's use; dropping it decrements it.
  • 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.
  • 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.

The bug: one inverted character

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 !:

/* vulnerable */ if (!nft_set_elem_active(ext, genmask)) continue;
/* 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.

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!

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.

From a reference-count off-by-one to root (the short version)

I’ll keep this part high-level — the full chain and the source live in my personal repo — but the shape matters for understanding why the detection works.

  1. Inside an unprivileged user + network namespace, build a table, a base chain, a victim chain, and a verdict map with a catchall : goto victim_chain. Add a goto rule. The victim chain's use is now 2.
  2. In one batch, delete the map (this deactivates the catchall and drops use to 1) and then deliberately fail a later operation. The kernel aborts - and thanks to the bug, use is not restored. It stays at 1.
  3. A benign transaction commits the undercount.
  4. Cleanly delete a second map. use drops to 0 while the goto rule still references the chain.
  5. Delete the chain. use == 0, so the kernel frees it - leaving the rule's verdict pointing at freed memory. Use-after-free.

From there it’s a heap game: the freed chain object gets reclaimed with attacker controlled data, and a packet that traverses the dangling verdict drives execution through a forged chain.

My exploit turns that into a KASLR leak, an arbitrary read, a walk of live kernel structures to recover the addresses it needs, and finally a short ROP chain that calls commit_creds (init_cred) and returns cleanly to userspace as ROOT - all of it leaked at runtime, nothing hardcoded that a real attacker couldn't obtain.

One honest aside worth its own post: affected is not the same as exploitable. The bug is reachable on a huge range of kernels — ./exploit check confirms it instantly - but turning it into a root shell depends on heap determinism (CONFIG_RANDOM_KMALLOC_CACHES) and forward-edge control-flow integrity (CONFIG_X86_KERNEL_IBT). A kernel can be fully vulnerable and still resist a given weaponization. That distinction is going to matter in a second, because it tells you where a detector should live.

Credit where it’s due: the vulnerability and the original exploitation strategy are the work of Exodus Intelligence and FuzzingLabs, and the fix is the upstream maintainers’. The functional, self-contained exploit is my own build.

Why the obvious detections fall apart

Now put on the defender hat. How do you catch this at runtime, on a production fleet, without a wall of false alarms?

The instinct is to detect the outcome. Catch the root shell. Catch the commit_creds. Catch the modprobe_path overwrite. Watch for the suspicious binary.

<script src="https://cdn.jsdelivr.net/npm/gsap@3.12.5/dist/gsap.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.12.5/dist/Flip.min.js"></script>

<script>
  document.addEventListener("DOMContentLoaded", (event) => {
    gsap.registerPlugin(Flip);
    const state = Flip.getState("");
    const element = document.querySelector("");
    element.classList.toggle("");
    Flip.from(state, {
      duration: 0,
      ease: "none",
      absolute: true,
    });
  });
</script>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.12.5/dist/gsap.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.12.5/dist/Flip.min.js"></script>

<script>
  document.addEventListener("DOMContentLoaded", (event) => {
    gsap.registerPlugin(Flip);
    const state = Flip.getState("");
    const element = document.querySelector("");
    element.classList.toggle("");
    Flip.from(state, {
      duration: 0,
      ease: "none",
      absolute: true,
    });
  });
</script>