Skip to content
Open
Show file tree
Hide file tree
Changes from 8 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
169 changes: 169 additions & 0 deletions pocs/linux/kernelctf/CVE-2026-52910_lts_cos_mitigation/docs/exploit.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
# Exploitation Details

This exploit turns the `SO_ATTACH_REUSEPORT_CBPF` program lifetime bug into control over `struct bpf_prog::bpf_func`. The controlled function pointer is redirected into a cBPF JIT "one gadget" that overwrites `core_pattern`; after that, a controlled core dump executes the exploit binary as root and prints the flag.

The same exploitation strategy is used for `lts-6.12.79`, `cos-121-18867.381.45`, and `mitigation-v4-6.12`. The target directories only differ in race tuning constants and the sprayed JIT payload address.

## Triggering the Vulnerability

The vulnerable object is a classic BPF `struct bpf_prog` attached to a reuseport socket group through `SO_ATTACH_REUSEPORT_CBPF`.

The receive path uses the following high-level flow:

1. `reuseport_select_sock()` enters an RCU read-side critical section.
2. It loads `reuse->prog`.
3. If the program is a classic BPF program, it calls `run_bpf_filter()`.
4. `run_bpf_filter()` eventually executes `bpf_prog_run_save_cb(prog, skb)`, which calls `prog->bpf_func`.

The race is against `reuseport_attach_prog()`, reached from `setsockopt(SO_ATTACH_REUSEPORT_CBPF)`. That path installs a new program with `rcu_assign_pointer(reuse->prog, prog)`, drops the lock, and then immediately frees the old program through `sk_reuseport_prog_free(old_prog)`. Since the receive path can already have loaded the old pointer under RCU, the old `struct bpf_prog` can be freed while it is still about to be executed.

The exploit sets up a single UDP socket on loopback with `SO_REUSEPORT` and initially attaches a tiny two-instruction cBPF program. A second thread then repeatedly replaces this program with another tiny cBPF program. These two programs are functionally unimportant; they are only used to keep freeing the previous `struct bpf_prog` while the main thread sends UDP packets to enter `reuseport_select_sock()`.

## From UAF to Fake `struct bpf_prog`

`struct bpf_prog` is allocated from vmalloc memory in page-sized units. When the old program is freed, its backing page can be returned to the page allocator, but the receiving CPU may still have a stale TLB entry for the old vmalloc address. The exploit relies on that stale translation:

1. CPU0 sends UDP packets and enters the reuseport receive path.
2. CPU0 loads or reloads the old `struct bpf_prog` vmalloc mapping.
3. CPU1 replaces the reuseport cBPF program and frees the old program.
4. CPU1 immediately writes controlled pages into pipes.
5. If a pipe page reclaims the freed physical page, CPU0's stale vmalloc TLB entry now reads the pipe contents as the freed `struct bpf_prog`.

The fake program is one zeroed 4096-byte page. Only one field is required:

```c
#define BPF_PROG_OFFS_BPF_FUNC 0x30
*(uint64_t *)(fake_page + BPF_PROG_OFFS_BPF_FUNC) = CBPF_JIT_GADGET_RIP;
```

Offset `0x30` is the `bpf_func` field used by `bpf_prog_run()`. All other fields can remain zero for the path used by the exploit. Once `run_bpf_filter()` calls `bpf_prog_run_save_cb()` on the stale pointer, control transfers to the sprayed cBPF JIT payload address.

If the stale TLB entry is invalidated before CPU0 uses the pointer, the vmalloc address no longer maps to the reclaimed physical page and the attempt usually crashes. For this reason the exploit sends several warmup packets before every race attempt; these packets run `reuseport_select_sock()` on CPU0 and refresh the TLB entry for the current reuseport program mapping.

## RIP Target: cBPF JIT One Gadget

Since we already have an RIP control primitive, we only need a useful target. For this we reuse the "one gadget" technique described in detail in the [CVE-2025-21700 writeup](https://github.com/google/security-research/blob/e0c462526bf221a5bf61185a82f21f8d941a7a9b/pocs/linux/kernelctf/CVE-2025-21700_lts_cos_mitigation/docs/novel-techniques.md). This is a `core_pattern` overwrite gadget. It also does not need a KASLR bypass.

The gadget is provided through generated cBPF filters in `filters.inc`. The spray child attaches those filters to AF_INET datagram sockets with `SO_ATTACH_FILTER` and then sleeps forever so the JIT images stay alive. The exploit writes the target-specific JIT address into the fake `bpf_prog::bpf_func` field:

| Target | `CBPF_JIT_GADGET_RIP` |
| ------ | --------------------- |
| `lts-6.12.79` | `0xffffffffc05f1064` |
| `cos-121-18867.381.45` | `0xffffffffc05f1064` |
| `mitigation-v4-6.12` | `0xffffffffc05f105c` |

The payload data itself is loaded into the x87 FPU state before the race. The one gadget uses that state to write the following core-dump configuration into the kernel's coredump globals:

```c
struct {
int core_name_size;
char core_pattern[14];
} payload = {
.core_name_size = 128,
.core_pattern = "|/proc/%P/exe",
};
```

## Why Not Stack Pivot ROP

A conventional stack-pivot ROP chain was a poor fit for this primitive. The initial RIP control comes from a stale vmalloc TLB translation for a freed `struct bpf_prog` page that has been reclaimed by a pipe page. If the ROP chain pivots into attacker-controlled memory reachable through that transient stale alias, the chain must remain valid for many successive gadget fetches and stack reads. In testing, that was unreliable: once the relevant TLB entry was evicted or invalidated, the pivoted stack effectively disappeared and the chain faulted before finishing.

The cBPF JIT one gadget avoids that failure mode. After the fake `bpf_prog::bpf_func` call succeeds, execution is in stable RX JIT memory rather than on a fake stack backed by the stale vmalloc alias. The small amount of data needed for the `core_pattern` overwrite is carried in x87 FPU state, so the post-RIP stage does not depend on a long-lived pivoted stack.

## Race Setup and Synchronization

There are three long-lived execution contexts:

1. The main thread on CPU0 sends UDP packets and arms a timerfd.
2. The replace thread on CPU1 swaps the reuseport cBPF program and performs the pipe-page reclaim spray.
3. A core-dump watcher process on CPU1 waits for `core_pattern` to change, then crashes itself to trigger the root helper.

The main thread and replace thread synchronize with a two-party `pthread_barrier_t` around each attempt.

Each race iteration works as follows:

1. The main thread sends several warmup UDP packets. This runs `reuseport_select_sock()` on CPU0 and reloads the current `bpf_prog` vmalloc mapping into CPU0's TLB.
2. Both threads enter the first barrier.
3. The replace thread waits `REUSEPORT_REPLACE_DELAY_US` microseconds so CPU0 is likely to be in, or about to enter, the reuseport selection path.
4. The replace thread calls `setsockopt(SO_ATTACH_REUSEPORT_CBPF)` with the alternate two-instruction cBPF program. This frees the previously attached `struct bpf_prog`.
5. The replace thread writes the fake `struct bpf_prog` page into a batch of pipes. These pipe data pages are the reclaim spray for the freed BPF program backing page.
6. The main thread arms a timerfd to fire after the current `COUNT_*` value and sends one UDP packet to trigger the vulnerable receive path.
7. Both threads enter the second barrier.
8. The replace thread closes and recreates the pipes so the next attempt starts with empty pipe rings.

The timerfd is duplicated many times and registered in many epoll instances in a stopped child process. When the timer expires, this produces many callbacks and adds scheduling/softirq pressure around the receive path. This widens the window where CPU1 can replace and reclaim the old program while CPU0 is still using the stale pointer.

## Pipe Reclaim Spray

The reclaim object is a pipe data page. The exploit keeps an array of pipe file descriptor pairs and writes the same 4096-byte fake `struct bpf_prog` page into each pipe.

The spray is deliberately batched:

```c
#define N_SPRAY_BATCH 16
```

After each batch the replace thread yields, which gives the main thread and softirq processing opportunities to continue while still filling enough pages to hit the freed BPF program backing page. At the end of an attempt, all pipe file descriptors are closed and recreated. This frees the previous pipe pages and keeps page allocator state moving between attempts.

## Getting RIP Control

Successful RIP control occurs when all of these conditions hold in one attempt:

1. CPU0 has loaded the old `reuse->prog` pointer in `reuseport_select_sock()`.
2. CPU1 replaces the attached cBPF program and frees the old `struct bpf_prog`.
3. A pipe page reclaims the freed physical page backing the old vmalloc mapping.
4. CPU0 still has a stale TLB translation for the old vmalloc address.
5. `bpf_prog_run_save_cb()` reads `bpf_func` from the fake page and calls the sprayed JIT payload.

# Exploitation Analysis

## step(0): Spray cBPF JIT gadget

The exploit sprays the cBPF JIT one gadget. A child process attaches generated cBPF filters from `filters.inc` to AF_INET datagram sockets with `SO_ATTACH_FILTER`. The child then keeps the sockets open forever, which keeps the generated JIT images mapped in kernel executable memory.

The parent waits until the child reports a positive number of sprayed filters. Once the spray is alive, the exploit stores the target-specific JIT address in `g_spray_rip`:

```c
g_spray_rip = CBPF_JIT_GADGET_RIP;
```

This address is later written into the fake `struct bpf_prog::bpf_func`. Since the sprayed payload is the `core_pattern` overwrite one gadget, the exploit does not need a KASLR bypass or a post-RIP ROP chain.

## step(1): Prepare fake bpf_prog

The exploit prepares two pieces of data before starting the UAF race.

First, `fake_core_pattern_payload()` writes the one-gadget input into x87 FPU state. The payload sets `core_name_size` and `core_pattern` so the gadget can install:

```text
|/proc/%P/exe
```

Second, the exploit builds the fake `struct bpf_prog` page used for pipe reclaim. The page is initialized with `SPRAY_BYTE`, and the only field that needs to be controlled is `bpf_func`:

```c
*(uint64_t *)(g_spray_page + BPF_PROG_OFFS_BPF_FUNC) = g_spray_rip;
```

If this page reclaims the freed BPF program backing page while CPU0 still has a stale vmalloc TLB translation, `bpf_prog_run_save_cb()` will fetch `bpf_func` from this fake page and branch to the sprayed JIT gadget.

## step(2): Trigger SO_REUSEPORT cBPF UAF

The final step sets up the race and runs it until RIP control is reached.

The exploit creates a timerfd callback storm by duplicating one timerfd into many epoll instances in a stopped child process. It then creates the UDP `SO_REUSEPORT` socket, attaches the initial two-instruction reuseport cBPF program, allocates the pipe descriptors used for reclaim, and starts a watcher process that waits for `core_pattern` to change.

The main thread is pinned to CPU0 and repeatedly sends UDP packets to the loopback socket. Before each race attempt it sends several warmup packets so CPU0 reloads the current `reuse->prog` vmalloc mapping into its TLB. The replace thread is pinned to CPU1 and waits at the race barrier.

Each attempt follows this order:

1. CPU0 sends warmup packets through `reuseport_select_sock()`.
2. Both threads synchronize at the first barrier.
3. CPU1 waits `REUSEPORT_REPLACE_DELAY_US` microseconds.
4. CPU1 replaces the attached reuseport cBPF program with `setsockopt(SO_ATTACH_REUSEPORT_CBPF)`, freeing the old `struct bpf_prog`.
5. CPU1 sprays the fake `struct bpf_prog` page into pipes to reclaim the freed backing page.
6. CPU0 arms the timerfd with the current `COUNT_*` value and sends one UDP packet to execute the vulnerable receive path.
7. Both threads synchronize at the second barrier.
8. CPU1 closes and recreates the pipes so the next attempt starts from a fresh reclaim state.

When the race wins, CPU0 calls `prog->bpf_func` through the stale BPF program pointer, but the field is read from the pipe-reclaimed fake page. Control transfers to the cBPF JIT one gadget, which overwrites `core_pattern`. The watcher then crashes itself, the kernel executes `/proc/%P/exe` as the core dump helper, and the exploit re-enters as root to print the flag.
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# Vulnerability Details
- **Requirements**:
- **Capabilities**: None
- **Kernel configuration**: `CONFIG_NET=y`, `CONFIG_INET=y`, `CONFIG_BPF=y`
- **User namespaces required**: No
- **Introduced by**: https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/commit/?id=538950a1b752
- **Fixed by**: https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/commit/?id=18fc650ccd7fe3376eca89203668cfb8268f60df
- **Affected Version**: `4.5 - v7.1-rc2`
- **Affected Component**: `net/bpf`
- **Syscall to disable**: -
- **Cause**: When a classic BPF program attached with `SO_ATTACH_REUSEPORT_CBPF` is replaced or detached from a reuseport socket group, `sk_reuseport_prog_free()` frees the old cBPF program immediately. Meanwhile, the packet receive path can still hold and execute the old `reuse->prog` pointer inside `reuseport_select_sock()` under RCU. Racing `setsockopt(SO_ATTACH_REUSEPORT_CBPF)` against UDP packet delivery can therefore make the receive path dereference or execute a freed `bpf_prog`, leading to a use-after-free.
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
all: exploit

CXX ?= g++
TARGET_DB_URL := https://storage.googleapis.com/kernelxdk/db/kernelctf.kxdb
CPPFLAGS += -I.
CXXFLAGS ?= -std=gnu++20 -O2 -pthread
CXXFLAGS_DEBUG ?= -std=gnu++20 -g -pthread
LDFLAGS ?= -static -pthread
LDLIBS ?= -lkernelXDK

prerequisites: target_db.kxdb

target_db.kxdb:
wget -O $@ $(TARGET_DB_URL)

exploit: FORCE exploit.cpp exploit.hpp rip.h filters.inc target_db.kxdb
$(CXX) $(CPPFLAGS) $(CXXFLAGS) \
$(LDFLAGS) -o $@ exploit.cpp $(LDLIBS)

exploit_debug: FORCE exploit.cpp exploit.hpp rip.h filters.inc target_db.kxdb
$(CXX) $(CPPFLAGS) $(CXXFLAGS_DEBUG) \
$(LDFLAGS) -o $@ exploit.cpp $(LDLIBS)

run: exploit
./exploit

clean:
rm -f exploit exploit_debug

FORCE:

.PHONY: all prerequisites run clean FORCE
Binary file not shown.
Loading
Loading