TL;DR. CVE-2026-23111 is a use-after-free in the Linux kernel's nf_tables (netfilter) subsystem. The fix removed a single character, a stray ! that inverted an element-activity check, so aborting a transaction processed live elements instead of inactive ones and never restored a reference count. An unprivileged local user can drive that into a clean root, and into a container escape, through unprivileged user namespaces. Exodus Intelligence found it in early 2025, it was patched upstream on 2026-02-05, and as of 2026-06-08 there is a fully working public exploit (a second public PoC from FuzzingLabs landed back in April). NVD scores it CVSS 7.8 (HIGH). If your kernel is below your distro's fixed build, you are exposed. Two things to do today: patch the kernel, and restrict unprivileged user namespaces (the single mitigation that defangs this whole class). This article covers detection, the mitigation, the patch, and a safe VM lab to reproduce it.
This is a local privilege escalation, not a remote bug. That sounds reassuring until you remember how many "local" shells exist: every container, every CI runner that executes third-party code, every account on a shared host. On any of those, this turns one unprivileged foothold into host root.
Why this one is worth your afternoon
Three things make CVE-2026-23111 stand out from the steady drip of kernel CVEs:
- It is a one-character bug in a default-on subsystem.
nf_tablesis the modern packet-filter backend behindnftablesandiptables-nft, compiled into essentially every mainstream distro kernel. The bug is not in some exotic driver, it is in the firewall layer everyone ships. - It is weaponized. Exodus Intelligence published a detailed writeup and a working exploit on 2026-06-08, reporting over 99% reliability on an idle system and around 80% under heap pressure. FuzzingLabs independently published a reproduction with a working root exploit on 2026-04-16. The gap between "PoC exists" and "script kiddies have it" is closed.
- The path to root runs through user namespaces. The exploit needs
CONFIG_USER_NSandCONFIG_NF_TABLES, both standard, and an unprivileged user who can create a user namespace. That last precondition is the one you can take away without patching, which is the whole point of the mitigation section below.
The affected range is wide. NVD lists vulnerable kernels from the 4.19 series all the way through 6.18.x. If you are on anything older than your distro's fixed build, assume you are in scope.
Am I vulnerable?
Four quick checks. None needs root, and together they tell you whether the bug is present and whether the precondition (unprivileged user namespaces) is open.
# 1. Running kernel version. Compare against your distro's fixed build (tables below).
uname -r
# 2. Is nf_tables present? (It is the attack surface.)
modinfo nf_tables 2>/dev/null | head -2 || echo "nf_tables not found as a module (may be built in)"
# 3. Can an unprivileged user create a user namespace? This is the precondition.
unshare -Ur id 2>&1 | head -1
# uid=0(root) gid=0(root) ... -> userns is open, precondition met
# unshare: ... Operation not permitted -> restricted, precondition closed
# 4. On Ubuntu/Debian, the userns knobs directly:
cat /proc/sys/user/max_user_namespaces 2>/dev/null
sysctl kernel.unprivileged_userns_clone 2>/dev/null
sysctl kernel.apparmor_restrict_unprivileged_userns 2>/dev/null # Ubuntu 23.10+Two things to read carefully:
- Presence of
nf_tablesis the attack surface, not proof of exploitability. A patched kernel still shipsnf_tables; it simply no longer carries the inverted check. The deciding signal is your kernel version against the vendor advisory, not whether the module exists. - If check 3 already fails, you have a strong mitigation in place. On stock Ubuntu 24.04 the AppArmor restriction on unprivileged user namespaces is enabled by default (
kernel.apparmor_restrict_unprivileged_userns=1), so an unconfined unprivileged process cannot create the namespace the exploit needs. That does not fix the kernel bug, but it removes the realistic path to it for this exploit. Do not let it lull you: a confined profile with theusernspermission, aCAP_SYS_ADMINprocess, or a box where someone has flipped that sysctl off, is still exposed until patched.
Here is what the checks look like on an unpatched Ubuntu 24.04 host with userns open:
techearl@lab:~$ uname -r
6.8.0-79-generic
techearl@lab:~$ modinfo nf_tables 2>/dev/null | head -2
filename: /lib/modules/6.8.0-79-generic/kernel/net/netfilter/nf_tables.ko.zst
license: GPL
techearl@lab:~$ unshare -Ur id 2>&1 | head -1
uid=0(root) gid=0(root) groups=0(root)
6.8.0-79-generic is well below Ubuntu's fixed 6.8.0-107.107, nf_tables is present, and unshare -Ur succeeds, so this box has the bug and an open path to it.
Those four checks are bundled into a read-only exposure checker, check-exposure.sh, in the companion lab toolkit. It compares your kernel against the fixed build for your distro, tests the user-namespace precondition as an unprivileged user (not as root, which would give a false reading), and prints an EXPOSED / MITIGATED / fixed verdict.
NVD scores this CVSS 7.8 (HIGH), vector AV:L/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H, CWE-416 (Use After Free). It requires local access, which is why it is High rather than Critical, but "local" includes every container and CI job on the box.
Mitigate now: restrict unprivileged user namespaces
This is the part to do first, before you even schedule the reboot, because it neutralizes the realistic exploit path for this bug and for a large share of kernel LPEs generally (Google has reported that a large fraction of the in-the-wild kernel exploits they see depend on unprivileged user namespaces). There are two approaches.
Ubuntu 23.10 and newer (AppArmor-based, the right tool there). Ubuntu added fine-grained restriction so that only AppArmor profiles carrying a userns rule (or processes with CAP_SYS_ADMIN) can create unprivileged user namespaces. On 24.04 it is on by default. Confirm and persist it:
# Confirm it is on (1 = restricted)
sysctl kernel.apparmor_restrict_unprivileged_userns
# Turn it on and persist
echo "kernel.apparmor_restrict_unprivileged_userns=1" | sudo tee /etc/sysctl.d/99-restrict-userns.conf
sudo sysctl --systemThis is preferable to a blanket disable because legitimate users of user namespaces (the Chrome and Firefox sandboxes, Flatpak, rootless Podman, bwrap) keep working through their AppArmor profiles, while a random unprivileged process cannot open a namespace to reach the bug.
Everywhere else (blanket disable). On distros without the AppArmor mechanism, take the namespace away entirely. On Debian and Ubuntu set both knobs, because setting only one can leave a residual path on some kernel versions:
cat <<'EOF' | sudo tee /etc/sysctl.d/99-restrict-userns.conf
kernel.unprivileged_userns_clone=0
user.max_user_namespaces=0
EOF
sudo sysctl --systemThe blanket disable is heavier-handed: it breaks anything that relies on unprivileged user namespaces, which on a desktop is a real list (browser sandboxes, Flatpak, rootless containers). On a server that runs none of those it is usually safe and is a strong, immediate mitigation. Test in staging if you are unsure what depends on it.
Neither approach replaces the patch. They buy you a safe window and they harden you against the next bug of this shape.
Patch the kernel
Fixed versions at a glance: Ubuntu 24.04 is fixed in 6.8.0-107.107 (generic) or 6.8.0-1051.54 (linux-aws); Debian 12 (bookworm) in 6.1.174-1 and Debian 13 (trixie) in 6.12.90-2; RHEL/AlmaLinux 10 via the RHSA for your stream (the el10_1 124.x series is vulnerable, the fix ships in the 10.2 / 211.x stream). Anything below your distro's fixed build is exposed.
The fix is a normal kernel security update: upgrade the kernel package and reboot. The upstream fix simply removes the inverted check. (nf_tables is also the backend behind iptables-nft, so an iptables-based firewall is in scope too; the bug is in the shared nf_tables core, not the front-end tool.)
Ubuntu
Fixed in the generic kernel at 6.8.0-107.107 for 24.04 LTS (noble), with the same fix across the cloud and flavor kernels (linux-aws 6.8.0-1051.54, linux-azure 6.8.0-1052.58, linux-gcp 6.8.0-1053.56, linux-gke 6.8.0-1049.54, and others). Ubuntu ships many kernel flavors per release and they do not all update on the same day, so check your exact flavor at ubuntu.com/security/CVE-2026-23111 against your uname -r.
sudo apt update
# Match the metapackage to your flavor: -generic, -aws, -azure, -gcp, etc.
sudo apt install --only-upgrade linux-image-generic
sudo rebootDebian, RHEL family, others
The same shape applies, with your distro's package manager:
| Distro | Command | Notes |
|---|---|---|
| Debian | sudo apt update && sudo apt install --only-upgrade linux-image-$(dpkg --print-architecture) && sudo reboot | Check security-tracker.debian.org/tracker/CVE-2026-23111 for the fixed source version per release. |
| RHEL / Rocky / AlmaLinux | sudo dnf update kernel && sudo reboot | See access.redhat.com/security/cve/cve-2026-23111 for the RHSA and exact NVR. Rocky and Alma track the matching RHSA within a day or two. |
| Amazon Linux 2 / 2023 | sudo dnf update kernel && sudo reboot | Livepatch may be available for no-reboot mitigation; check the ALAS entry. |
| SUSE / openSUSE | sudo zypper refresh && sudo zypper patch && sudo reboot | |
| Arch | sudo pacman -Syu linux && sudo reboot | Check security.archlinux.org for the linux-lts/-hardened/-zen variant version. |
| Fedora | sudo dnf upgrade kernel && sudo reboot | Rolling; current branches are past the fix. |
Always check your exact running kernel flavor against your vendor's tracker before declaring a host patched. "We ran dnf update" is not the same as "this kernel is at or past the fixed build for this minor release."
Container hosts and managed Kubernetes
The kernel lives on the host, not in the container, so patch the host kernel and reboot the node, then drain and reschedule. Containers inherit the fix automatically because they share the host kernel. On EKS, GKE, and AKS, roll your node pools onto the latest patched node image (kubectl get nodes -o wide shows each node's KERNEL-VERSION to compare). A rolling node upgrade is enough; nothing inside the containers needs rebuilding.
I ran this in a throwaway VM, three ways
You cannot safely demonstrate a kernel LPE "in a Docker container," and this is worth saying plainly: a container shares the host kernel, so running a kernel exploit inside one exploits your real host, not a sandbox. This CVE's payload is itself a user-namespace container escape, which makes the point for me. The correct lab is a disposable virtual machine with its own kernel, where reaching the bug inside the throwaway VM is harmless and you can delete it afterward. I used short-lived AWS EC2 instances; Multipass, Vagrant with libvirt/VirtualBox, or any local hypervisor work the same way. Never use a container for this.
I stood up three of them, each booted onto a genuinely pre-fix kernel, and ran the same checks. The headline difference between distros is not the bug (they are all vulnerable), it is the default mitigation posture:
| Distro (VM) | Vulnerable kernel I booted | Unprivileged userns default | Detector verdict |
|---|---|---|---|
| Ubuntu 24.04 | 6.8.0-1028-aws | restricted (AppArmor, on by default) | MITIGATED, not fixed |
| Debian 12 | 6.1.0-48-cloud (6.1.172-1) | open | EXPOSED |
| AlmaLinux 10.1 | 6.12.0-124.38.1.el10_1 | open (SELinux unconfined) | EXPOSED |
Ubuntu 24.04 is the outlier in a good way: its default AppArmor restriction means an unprivileged process cannot even open the user namespace the exploit needs, so the detector lands on MITIGATED. Debian and AlmaLinux ship no equivalent restriction, so they are EXPOSED out of the box.



Pinning a vulnerable kernel (the part with sharp edges)
Getting a genuinely pre-fix kernel that still boots took a different trick on each distro, and one of them cost me a bricked VM before I got it right:
- Ubuntu on a cloud VM: use an old
linux-aws, notlinux-generic. My first attempt bootedlinux-image-6.8.0-79-genericand lost SSH completely, because the generic kernel does not carry the ENA network driver that AWS Nitro instances need. The fix is to pin an oldlinux-awsbuild instead (I used6.8.0-1028-aws, below the fixed6.8.0-1051); the cloud kernel keeps ENA, so networking survives the reboot. On local virt (Multipass/VirtualBox) the generic kernel is fine; this is a cloud-NIC gotcha. - Debian: the main repo only carries the current (patched) kernel. The AMI shipped
6.1.174-1, which is exactly the fixed version. To get a vulnerable one I added a pre-fix snapshot mirror (snapshot.debian.org/archive/debian/20251201T000000Z) and installedlinux-image-6.1.0-48-cloud-amd64(6.1.172-1). Thecloudkernel keeps ENA, so it boots on EC2. - AlmaLinux: the vulnerable
el10_1kernels live in the 10.1 vault. The current AMI is 10.2 (patched,6.12.0-211.x). I pointed dnf atvault.almalinux.org/10.1/, installedkernel-6.12.0-124.38.1.el10_1(FuzzingLabs' target), and set it as the default withgrubby --set-default. RHEL-family kernels carry ENA built in, so no driver drama.
The exact archived versions rotate as mirrors age; the lab toolkit documents the current picks in its README, alongside the exposure checker and the unprivileged-user setup script.
Create a genuinely unprivileged user (this is what makes it a real test)
Here is the catch that invalidates most "labs": multipass shell, vagrant ssh, and every cloud image drop you in as the default user, which has passwordless sudo. That user is already root for all practical purposes, so running anything as them proves nothing about escalation. A privilege-escalation demo has to start from a user that genuinely cannot become root:
# As the default (sudo-capable) VM user, once. (useradd works on every distro;
# Debian's adduser takes different flags than RHEL's, so prefer useradd.)
sudo useradd -m -s /bin/bash labuser
# Drop to the unprivileged user and PROVE it is unprivileged.
sudo -u labuser -i
id # uid=1001(labuser) gid=1001(labuser) groups=1001(labuser)
sudo -n true # "labuser is not in the sudoers file" -> no path to root except the bugEverything from here runs as labuser. The screenshot below is from the Ubuntu box: labuser is genuinely unprivileged, the default AppArmor restriction blocks its unshare -Ur, and only after I relax that restriction (lab only) does the unprivileged user get uid=0 inside a new namespace, the precondition the exploit needs.

Triggering the bug, and what you actually see
This is the honest centre of the lab. As labuser, with the precondition open, I ran the public nft trigger from the FuzzingLabs writeup (a catchall map element with a goto verdict, then a deliberately-failing batch that forces the abort phase) inside a new user+net namespace:

Here is the result I want to be exact about, because the writeups can give the wrong impression: on a stock production kernel the trigger is silent. It runs the buggy abort path, but there is no crash and no dmesg output, even when I looped it 200 times with a deliberate free-and-reuse. The dramatic KASAN: slab-use-after-free in nft_... output you see in the FuzzingLabs and Exodus writeups comes from a KASAN-instrumented debug kernel with extra print statements. To see the corruption you need that debug kernel; to weaponize the latent use-after-free you need the exploit's heap-spray-and-reallocate dance that turns it into a controlled primitive. The bare trigger proves the unprivileged user reaches the vulnerable code; it does not, by itself, hand you a root shell.
So the lab's payoff is not a screenshot of a root prompt. It is the three things above, proven on real kernels: a genuinely unprivileged user reaching the bug, Ubuntu's default AppArmor restriction blocking that path, and the detector telling you EXPOSED versus MITIGATED versus fixed. The full chain to uid=0 (spray to leak the kernel base, a read primitive, RIP control through a corrupted chain, ROP to overwrite modprobe_path or call commit_creds) is what the Exodus and FuzzingLabs writeups describe; neither ships it as a clean run-anywhere exploit, and I am not providing one (the next section explains why).
Fixed versions, and teardown
Patch targets I verified against the vendor trackers while building this lab:
| Distro | Fixed kernel | Source of truth |
|---|---|---|
Ubuntu 24.04 (linux) | 6.8.0-107.107 | ubuntu.com/security/CVE-2026-23111 |
Ubuntu 24.04 (linux-aws) | 6.8.0-1051.54 | same |
| Debian 12 (bookworm) | 6.1.174-1 | security-tracker.debian.org |
| Debian 13 (trixie) | 6.12.90-2 | same |
| RHEL / AlmaLinux 10 | per RHSA (the el10_1 124.x series is vulnerable; the fix ships in the 10.2 / 211.x stream) | access.redhat.com/security/cve/cve-2026-23111 |
Then throw the VM away (multipass delete --purge, vagrant destroy -f, or terminate the cloud instance). The whole point of a disposable VM is that cleanup is one command.
How it actually works
Skip this if you only want the fix. From here down is the mechanism.
nf_tables, sets, maps, and the abort phase
nf_tables is the kernel's modern netfilter engine. Userspace (the nft tool, or any program speaking the netlink API) sends transactions: batched changes to tables, chains, sets, and maps that the kernel either commits atomically or aborts as a unit. That commit-or-abort design is the setting for the bug.
A set is a collection of elements; a map is a set whose elements carry a value, often a verdict like "jump to this chain" (NFT_JUMP) or "go to this chain" (NFT_GOTO). A catchall element is the default element that matches when nothing else does. When an element holds a GOTO/JUMP verdict pointing at a chain, the kernel takes a reference on that chain (chain->use) so the chain cannot be freed while something still points at it. Reference counting like this is how the kernel keeps "is anyone still using this object?" honest.
The inverted check
When a transaction is aborted, the kernel has to undo whatever the transaction tentatively did, including putting back any references it tentatively dropped. nft_map_catchall_activate() is part of that undo path for catchall map elements. It is supposed to act on the inactive elements (the ones the aborted transaction was about to change) and restore their state.
The bug is a single inverted condition: the genmask/activity check had a stray !, so the function processed active elements instead of inactive ones. For a catchall element carrying an NFT_GOTO verdict, that means the code path that should call nft_data_hold() to restore chain->use never runs. The reference count is left one too low.
A reference count that is one too low is a classic use-after-free fuse: the kernel now believes one fewer thing is using the chain than really is, so it can free the chain while a live pointer still references it. The next access to that freed-then-reallocated object is the use-after-free.
Here is the entire fix, in nft_map_catchall_activate() on the transaction-abort path:
/* net/netfilter/nf_tables_api.c */
/* vulnerable: the "!" skips INACTIVE elements, so the catchall element that
actually needs re-activating on abort is skipped, and chain->use is never restored */
if (!nft_set_elem_active(ext, genmask))
continue;
/* fixed: drop the "!" so the abort path skips ACTIVE elements and processes the
inactive one, matching the non-catchall sibling nft_mapelem_activate() */
if (nft_set_elem_active(ext, genmask))
continue;That single removed character is the whole patch (upstream commit "netfilter: nf_tables: fix inverted genmask check in nft_map_catchall_activate()"), and it is why the nickname going around is "off by !".
From a refcount bug to root (conceptual)
Turning a use-after-free into a root shell is the part the public writeups spend their pages on. At a high level, the disclosed chains do roughly this:
- Trigger. Create a map with a catchall element holding a
GOTOverdict, then abort a batch delete (DELSET) so the inverted check skips the reference restore. The chain gets freed while still referenced: use-after-free. - Leak. Reclaim the freed slot with a sprayable object (the writeups use
struct seq_operations) to read back a kernel pointer and defeat KASLR (recovering the kernel base from a known function pointer). - Arbitrary read. Pivot the leak into a read primitive (the FuzzingLabs chain walks
init_ipc_nsand a radix tree) to locate the structures it needs. - Control flow. Get the kernel to call through a pointer the attacker now controls (via a corrupted chain reached through
nft_chain_validate()), turning the UAF into instruction-pointer control. - Root. A short ROP chain flips the usual switches: overwrite
modprobe_path(or callcommit_creds(prepare_kernel_cred(0))), neutralize the LSM that is in the way, and you are root.
None of those steps is something I am going to package up as a copy-paste script here, and I want to be explicit about why.
Why there is no working exploit in this post
The exploit is already public, in detail, from the researchers who built it. Re-publishing a turn-key version on a blog does one thing the original disclosures do not: it removes friction for someone scanning for unpatched hosts to run, today, in the window before everyone patches. That is a bad trade. The mechanism above is enough to understand the bug, brief your team, and recognize it. If you need the byte-precise chain, the Exodus writeup and the FuzzingLabs reproduction are the canonical references, written by the people who did the work. Study them in the disposable VM from the lab section, not on anything you care about.
Were you already exploited?
This is harder to answer cleanly than a file-based CVE, because a successful exploit leaves its mark mostly in volatile kernel state and in whatever the attacker did with their root window. Realistic signals:
# 1. Kernel oops / nf_tables warnings around the suspected window. A failed or
# noisy exploitation attempt often leaves a trace in the ring buffer / journal.
sudo dmesg -T | grep -iE "nf_tables|netfilter|use-after-free|KASAN|general protection|BUG:"
sudo journalctl -k --since "2 weeks ago" | grep -iE "nf_tables|KASAN|BUG: unable|general protection"
# 2. If auditd is running, unprivileged user-namespace creation is a useful tell.
sudo ausearch -m all -ts recent 2>/dev/null | grep -iE "unshare|clone|userns" | tail
# 3. The usual post-root persistence sweep (this is where the real damage lives).
sudo grep -RIl "" /etc/cron.d /etc/cron.daily /etc/systemd/system 2>/dev/null | xargs ls -la 2>/dev/null
for u in $(cut -d: -f1 /etc/passwd); do sudo test -f /home/$u/.ssh/authorized_keys && echo "keys: $u"; done
sudo cat /root/.ssh/authorized_keys 2>/dev/nullIf you find evidence of exploitation, assume host root: rotate every credential the host has touched, snapshot for forensics, audit persistence (SSH keys, cron, systemd units, /etc/ld.so.preload, dropped binaries in /usr/local/bin and /var/tmp), and rebuild from known-good media. A reboot clears the in-kernel state but not the attacker's persistence. My WordPress malware removal writeup covers the cleanup shape for a different root compromise, and the playbook overlaps.
For the log-grepping patterns above, my grep cheat sheet covers the syntax. One thing worth being explicit about, because it trips people up: the usual "lock down root" hardening does not defend against this bug. The exploit becomes root inside the kernel without ever touching the root login path, so disabling root SSH and locking the root password is good general hygiene but changes nothing about whether this escalation succeeds. The defenses that actually matter here are the two from earlier, restrict unprivileged user namespaces and patch the kernel, plus keeping an attacker from ever getting the unprivileged shell in the first place (tight web-app and dependency hygiene, key-only SSH behind a firewall allow-list).
Containers, CI/CD, and multi-tenant hosts
The blast radius is bigger than one box:
- Containers. The kernel is shared. An unprivileged process inside a container reaches
nf_tablesthe same way a host process does, unless you have blocked it with seccomp or a tight policy. If the container can create a user namespace, the precondition is met inside the container too. - CI/CD runners. Any self-hosted runner that executes third-party PR code is a privilege-escalation vector on an unpatched host: untrusted code runs as an unprivileged user, the exploit promotes it to root on the runner, and the runner holds secrets and registry access.
- Multi-tenant hosts. Shared shells, web hosts, bastions: any tenant can escalate and read everyone else's data.
For all of these the answer is the same pair: patch the host kernel, and restrict unprivileged user namespaces so the precondition is gone even before the patch lands.
References
Primary sources:
- NVD: CVE-2026-23111. CVSS 7.8 HIGH (
AV:L/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H), CWE-416 (Use After Free). - Exodus Intelligence: "Off By !". The discovery, full exploit, and reliability figures (2026-06-08).
- FuzzingLabs: Reproducing CVE-2026-23111. Independent reproduction with a working exploit (2026-04-16).
Vendor trackers (check your exact release and flavor):
- Ubuntu: ubuntu.com/security/CVE-2026-23111. 24.04 generic fixed at
6.8.0-107.107. - Debian: security-tracker.debian.org/tracker/CVE-2026-23111.
- Red Hat: access.redhat.com/security/cve/cve-2026-23111.
Mitigation background:
- Ubuntu: restricted unprivileged user namespaces. How the AppArmor-based restriction works and how to control it.
Lab toolkit:
- github.com/ishankaru/CVE-2026-23111-nftables-lab. The read-only exposure checker, the unprivileged-user setup, and the disposable-VM lab used for this writeup. Defensive only, no exploit.
Sources
Authoritative references this article was fact-checked against.
- NVD: CVE-2026-23111nvd.nist.gov
- Exodus Intelligence: Off By ! (CVE-2026-23111 writeup and exploit)blog.exodusintel.com
- FuzzingLabs: Reproducing CVE-2026-23111fuzzinglabs.com
- Ubuntu: CVE-2026-23111ubuntu.com
- Red Hat: CVE-2026-23111access.redhat.com
- Ubuntu: restricted unprivileged user namespacesubuntu.com





