Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
217 changes: 217 additions & 0 deletions pocs/linux/kernelctf/CVE-2023-52926_lts/docs/exploit.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,217 @@
# Vulnerability

## Overview
Vulnerability is a use-after-free that exists in io_uring subsystem. `IORING_OP_READ` did not correctly consume the provided buffer list when read i/o returned < 0 (except for -EAGAIN and -EIOCBQUEUED return). This can lead to a potential use-after-free when the completion via io_rw_done runs at separate context.

## Vuln Details
### io_uring Imported Buffer
IO Uring had a feature where we can register buffers that can used on most of io uring operation. Buffer stored in a `xarray`. We can register/unregister buffer using `IORING_REGISTER_PBUF_RING` or `IORING_UNREGISTER_PBUF_RING`. When issuing io_uring request, we can put the index of the buffer we want to use at `buf_index` and flag `IOSQE_BUFFER_SELECT` of io_uring submission context.
It will handled by `io_buffer_select` function.
```c
void __user *io_buffer_select(struct io_kiocb *req, size_t *len,
unsigned int issue_flags)
{
struct io_ring_ctx *ctx = req->ctx;
struct io_buffer_list *bl;
void __user *ret = NULL;

io_ring_submit_lock(req->ctx, issue_flags);

bl = io_buffer_get_list(ctx, req->buf_index);
if (likely(bl)) {
if (bl->is_mapped)
ret = io_ring_buffer_select(req, len, bl, issue_flags);
else
ret = io_provided_buffer_select(req, len, bl);
}
io_ring_submit_unlock(req->ctx, issue_flags);
return ret;
}
```
There's two type of buffers, classic buffer and ring buffer, in this exploitation we gonna use ring buffer. `io_buffer_select` will call `io_ring_buffer_select` for our chosen buffer ring.
```c
static void __user *io_ring_buffer_select(struct io_kiocb *req, size_t *len,
struct io_buffer_list *bl,
unsigned int issue_flags)
{
...
req->flags |= REQ_F_BUFFER_RING;
req->buf_list = bl;
req->buf_index = buf->bid;
...
}
```
Our buffer ring object will stored at `req->buf_list` here (without increase any refs at all), and will be used across io_uring operation later.

### io_read handle error
In `io_read`, if read return error code is not EAGAIN nor EIOCBQUEUED, it will goto line[1] and jmp to done
```C
ret = io_iter_do_read(rw, &s->iter);

if (ret == -EAGAIN || (req->flags & REQ_F_REISSUE)) {
req->flags &= ~REQ_F_REISSUE;
/* if we can poll, just do that */
if (req->opcode == IORING_OP_READ && file_can_poll(req->file))
return -EAGAIN;
/* IOPOLL retry should happen for io-wq threads */
if (!force_nonblock && !(req->ctx->flags & IORING_SETUP_IOPOLL))
goto done;
/* no retry on NONBLOCK nor RWF_NOWAIT */
if (req->flags & REQ_F_NOWAIT)
goto done;
ret = 0;
} else if (ret == -EIOCBQUEUED) {
if (iovec)
kfree(iovec);
return IOU_ISSUE_SKIP_COMPLETE;
} else if (ret == req->cqe.res || ret <= 0 || !force_nonblock ||
(req->flags & REQ_F_NOWAIT) || !need_complete_io(req)) {
/* read all, failed, already did sync or don't want to retry */
goto done; //[1]
}
```

it will call `kiocb_done` to call `io_rw_done` to call `io_rw_done` -> `kiocb->ki_complete (io_complete_rw)`

```C
done:
/* it's faster to check here then delegate to kfree */
if (iovec)
kfree(iovec);
return kiocb_done(req, ret, issue_flags);



static int kiocb_done(struct io_kiocb *req, ssize_t ret,
unsigned int issue_flags)
{
struct io_rw *rw = io_kiocb_to_cmd(req, struct io_rw);
unsigned final_ret = io_fixup_rw_res(req, ret);

if (ret >= 0 && req->flags & REQ_F_CUR_POS)
req->file->f_pos = rw->kiocb.ki_pos;
if (ret >= 0 && (rw->kiocb.ki_complete == io_complete_rw)) {
if (!__io_complete_rw_common(req, ret)) {
/*
* Safe to call io_end from here as we're inline
* from the submission path.
*/
io_req_io_end(req);
io_req_set_res(req, final_ret,
io_put_kbuf(req, issue_flags));
return IOU_OK;
}
} else {
io_rw_done(&rw->kiocb, ret);
}

if (req->flags & REQ_F_REISSUE) {
req->flags &= ~REQ_F_REISSUE;
if (io_resubmit_prep(req))
io_req_task_queue_reissue(req);
else
io_req_task_queue_fail(req, final_ret);
}
return IOU_ISSUE_SKIP_COMPLETE;
}
```

It will queue current io_read work into task_work list, it mean it won't release req->buf_list before release io_uring lock.

```C
static void io_complete_rw(struct kiocb *kiocb, long res)
{
struct io_rw *rw = container_of(kiocb, struct io_rw, kiocb);
struct io_kiocb *req = cmd_to_io_kiocb(rw);

if (__io_complete_rw_common(req, res))
return;
io_req_set_res(req, io_fixup_rw_res(req, res), 0);
req->io_task_work.func = io_req_rw_complete;
io_req_task_work_add(req);
}

```


It mean we can free `io_buffer_list` before task_work execute, `req->buf_list` still hold freed `io_buffer_list` then it will use-after-free at [1] when trying to increase the head. So we have use-after-free increase primitive.


## Exploit detail in LTS

The primitive we obtained from this vulnerability allows us to increase the value at offset 22 within a kmalloc-64 slab. However, due to the limited use-after-free (UAF) nature of the bug, we cannot directly overwrite kernel pointers.

For example, a valid kernel heap address like 0xffff888100063bf0 becomes 0x888100063bf0 after the increase operation—this is an invalid kernel address. Similarly, we cannot overwrite a Page Table Entry (PTE). A valid PTE like 0x8000000100c63163 would turn into 0x8001000100c63163 after an increase, pointing to an invalid physical address and thus not being exploitable.

### Discovery of a New Exploitation Path via BPF
While exploring potential overwrite targets, we took a closer look at the BPF (Berkeley Packet Filter) subsystem, suspecting it might offer useful primitives. Importantly, BPF does not require unprivileged namespaces, making it more viable in constrained environments.

We identified a particularly interesting function: `bpf_map_is_rdonly`. This function is used during verification to determine if a BPF map can be treated as read-only. If so, the verifier assumes the contents of the map will not change, allowing it to relax certain checks and optimize based on that assumption.

Here is the relevant kernel code:

```C
static bool bpf_map_is_rdonly(const struct bpf_map *map)
{
/* A map is considered read-only if the following conditions are true:
*
* 1) The BPF program cannot change any of the map's contents.
* This is enforced by the BPF_F_RDONLY_PROG flag, which must
* have been set at map creation time.
* 2) The map values have been initialized from user space and then
* "frozen", preventing further update/delete operations from user space.
* 3) Any parallel or pending update/delete operations must have completed.
* Only then can the map be considered truly immutable.
*/
return (map->map_flags & BPF_F_RDONLY_PROG) &&
READ_ONCE(map->frozen) &&
!bpf_map_write_active(map);
}
```
### The Exploitation Idea

A new exploitation path emerged from this logic:
What if we can make the BPF verifier believe a map's content is A, but then—using our UAF-based increase primitive—we change it to B? This would break the verifier’s assumptions, and potentially allow us to bypass checks and construct a stable arbitrary read/write primitive inside a BPF program, much like traditional BPF-based kernel exploits.

## Exploit Summary
The exploitation path can be summarized as follows:
### io_uring Use-After-Free Vulnerability
The exploit begins by initializing an `io_uring` instance, mapping its rings, and registering a dummy file descriptor. A data buffer (`res_buffer`) and an `io_uring_buf_ring` metadata structure (`ring_buffer`) are mmaped. To prepare for the UAF, the `ring_buffer` is initialized with `res_buffer`. Critically, a large number of `io_uring_buf_ring` entries (0x1000) are then registered with unique `bgid`s using `IORING_REGISTER_PBUF_RING`. This action is part of the heap grooming strategy, ensuring that `struct io_buffer_list` objects (allocated from the `kmalloc-64` slab) are spread across the slab cache.

The actual UAF is triggered by submitting an vulnerable `IORING_OP_READ`. The `IORING_OP_READ` uses `IOSQE_BUFFER_SELECT` with a specific `buf_group` ID (e.g., 0x800). This operation prepares a read using a buffer from the registered `io_uring_buf_ring`, but crucially, it does **not** correctly increment the reference count of the associated `struct io_buffer_list` object. Immediately after submission, a range of `io_uring_buf_ring`s (including the one with `bgid 0x800`) are unregistered using `IORING_UNREGISTER_PBUF_RING`. This unregistration frees the `io_buffer_list` object that is still referenced by the pending `IORING_OP_READ`. When the kernel later processes the pending `IORING_OP_READ` and attempts to access the freed `io_buffer_list` (specifically, incrementing its `bl->head` field at offset 22), a use-after-free occurs, providing a controlled increment primitive on the `kmalloc-64` slab.

### Heap Grooming and Cross-Cache Attack
Immediately after triggering the UAF, the exploit performs extensive heap grooming. It creates numerous `array_map` BPF maps (0x200 of them) with the `BPF_F_RDONLY_PROG` flag. This spraying technique aims to reclaim the freed `io_buffer_list` memory with a BPF map's value, establishing a cross-cache overlap between the `kmalloc-64` slab (where `io_buffer_list` resides) and the `array_map`'s page. The maps are then "frozen" using `BPF_MAP_FREEZE`, setting their `map->frozen` field to `true`.

### BPF Verifier Bypass and Kernel Address Leak
Create multiple array_map BPF maps with the BPF_F_RDONLY_PROG flag to ensure the kernel cross-cache from kmalloc-64 slab pages to array_map's pages.
Freeze the maps, setting the map->frozen field to true, making them read-only from the syscall bpf with `BPF_MAP_FREEZE`.
Construct BPF programs that uses these maps. Because bpf_map_is_rdonly() returns true, the verifier performs a read-ahead of the map content and treats it as a constant (SCALAR_VALUE):

```C
if (tnum_is_const(reg->var_off) &&
bpf_map_is_rdonly(map) &&
map->ops->map_direct_value_addr) {
int map_off = off + reg->var_off.value;
u64 val = 0;

err = bpf_map_direct_read(map, map_off, size,
&val);
if (err)
return err;

regs[value_regno].type = SCALAR_VALUE;
__mark_reg_known(&regs[value_regno], val);
```
Use the UAF-based increase primitive to modify the map contents after verifier checks but before actual execution, violating the SCALAR assumption. This can be exploited via BPF_FUNC_skb_load_bytes_relative, which can lead to a stack overflow, enabling further exploitation like arbitrary kernel memory read/write. Refer to [this](https://bughunters.google.com/blog/6303226026131456/a-deep-dive-into-cve-2023-2163-how-we-found-and-fixed-an-ebpf-linux-kernel-vulnerability#exploitation) for more detailed technique.

Due to the UAF, the `io_buffer_list->head` field (at offset `0x16` within the overlapping BPF map value) is incremented by the kernel at runtime (e.g., from 0 to 1). The BPF programs load this UAF-modified value into a register (e.g., `BPF_REG_4`). While the verifier still believes this register holds `0`, the runtime value is `1`. This discrepancy is exploited with `BPF_FUNC_skb_load_bytes_relative`:
* **Kernel Address Leak:** The `array_map_leak_prog` uses the runtime `1` value to calculate a `len` of `9` (e.g., `1 + 8`), while the verifier expects `8` (e.g., `0 + 8`). This 1-byte overflow on the BPF stack partially corrupts a pointer to a magic value. By carefully constructing the stack and the input `skb` data, this corruption redirects the pointer such that dereferencing it (and an adjacent offset) yields another magic value (for verification) and a leaked kernel address (e.g., `array_map_ops` pointer). The exploit iterates through the sprayed maps, attaching the `array_map_leak_prog` to a socket, and sending trigger packets until a valid kernel address is successfully leaked into a designated output BPF map.

### Arbitrary Kernel Read/Write Primitive
Once a kernel address is leaked, the `kernel_read_write_prog` is attached to a socket. This program utilizes the *same verifier bypass* (`R4` being `1` at runtime but `0` for the verifier). However, it uses additional multiplication and addition (e.g., `R4 = 1 * 8 = 8` then `R4 + 8 = 16`) to make the runtime `len` for `bpf_skb_load_bytes_relative` become `16`, while the verifier still expects `8`. This 16-byte write completely overwrites an 8-byte pointer on the BPF stack with an *arbitrary target kernel address* supplied by userspace within the `skb` data. The program then conditionally performs either a 64-bit read from or a 64-bit write to this arbitrary kernel address, with a control flag and data passed via elements of the output BPF map.

### Privilege Escalation
With arbitrary kernel read/write capabilities, the exploit calculates the kernel base address using the leaked `array_map_ops` address and a known offset. It then calculates the address of the `core_pattern` kernel variable. Finally, it overwrites `core_pattern` with a malicious payload: `|/proc/%P/fd/666 %P`. This payload instructs the kernel to execute the file descriptor 666 (which is the exploit binary itself) with root privileges whenever a process crashes, passing the crashing process's PID as an argument. A child process is then made to crash, triggering the payload and resulting in a root shell.


22 changes: 22 additions & 0 deletions pocs/linux/kernelctf/CVE-2023-52926_lts/docs/vulnerability.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# Vulnerability
IORING_OP_READ did not correctly consume the provided buffer list when read i/o returned < 0 (except for -EAGAIN and -EIOCBQUEUED return). This can lead to a potential use-after-free when the completion via io_rw_done runs at separate context.

## Requirements to trigger the vulnerability
- Capabilities: NONE
- Kernel configuration: `CONFIG_IO_URING`
- User namespaces needed: No

## Commit which introduced the vulnerability
- https://git.kernel.org/pub/scm/linux/kernel/git/stable/linux.git/commit?id=c7fb19428d67d

## Commit which fixed the vulnerability
https://git.kernel.org/pub/scm/linux/kernel/git/stable/linux.git/commit/?id=6c27fc6a783c8a77c756dd5461b15e465020d075

## Affected kernel versions
v6.6 - v6.6.67

## Affected component, subsystem
- io_uring

## Cause
- Use After Free
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
all: exploit

exploit: exploit.c
gcc $^ -o $@ -static
Binary file not shown.
Loading
Loading