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
162 changes: 162 additions & 0 deletions pocs/linux/kernelctf/CVE-2026-31533_lts_cos/docs/exploit.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
# CVE-2026-31533 — exploitation (LTS 6.12.77 and COS 121-18867.381.30)

This document walks through how the use-after-free described in
`docs/vulnerability.md` is turned into a root shell. The exploit is data-flow
identical on both targets; only the per-target offsets differ.

### Note on kernelXDK / libxdk

This exploit's RIP control hands the attacker the controlled object in **R12**
(the cryptd completion is reached through a JOP gadget
`mov rax, [r12+0x40]; mov rdi, r12; ...; jmp rax`). The current `libxdk`
release (`libxdk/v0.1`) ships a stack-pivot database for these targets that
contains **no pivot keyed on R12** (its pivots cover RAX/RBX/RCX/RDX/RSI/RDI/
RBP/R13/R14/R15, but not R12), so `PayloadBuilder` cannot generate the stack
pivot for this bug — `Build()` fails for `Register::R12` on both
`lts-6.12.77` and `cos-121-18867.381.30`. We therefore keep the original,
proven JOP stack pivot and ROP chain (which reproduce at 100%). The gadget
addresses are listed inline in `exploit/<target>/exploit.c` (see the
`exploit_gen()` comments) and explained below. If a future libxdk release adds
R12 pivots, the chain can be regenerated with `RopChain`/`PayloadBuilder`.

## Objects, caches and fields

| Object | Cache | Role |
| --- | --- | --- |
| `tls_rec` | `kmalloc-1k`-ish (size-bucketed) | the freed-then-reused victim; reached through TLS TX |
| `aead_request` / `crypto_async_request` | embedded in / referenced by the record | holds the `complete` function pointer that is called by the cryptd worker — this is the hijacked indirect call |
| netlink skb data (`sendmsg`/`io_prep_pwrite` on `NETLINK_USERSOCK`) | page-backed (kmalloc / page frag) | the **spray** object that reclaims the freed record and supplies the fake `complete` pointer + ROP payload |

The controlling register at the hijacked indirect call is **R12** (it points to
the base of the reclaimed object on both targets). This is what the pivot is
generated from.

## Step 1 — Trigger the vulnerability (sentinel corruption)

See `docs/vulnerability.md` for the mechanism. Concretely the exploit:

1. Binds `cryptd(...)` AF_ALG transforms to force kernel-TLS onto the async
cryptd path (`force_cryptd()`).
2. Prepares a TCP socket pair with kernel TLS (`TLS_TX`/`TLS_RX`,
`TLS_CIPHER_AES_CCM_128`).
3. Pre-creates a large pool of AF_ALG `op` sockets and AIO `iocb`s, plus a pool
of worker threads parked on a `pthread_barrier`. These are the cryptd-queue
saturators.
4. Crafts a pending TLS record and sets `sk->sk_err = EBADMSG` by having the
peer send invalid ciphertext and `recvmsg()`-ing it.
5. **Fires the race:** `io_submit()` bulk-fills the per-CPU cryptd queue, the
barrier releases the worker flood (`MAY_BACKLOG`) to keep it full, then a
`sendmsg()` with a `TLS_SET_RECORD_TYPE` cmsg pushes the pending record. The
encryption returns `-EBUSY`, the callback observes `sk_err`, and the
sentinel is double-decremented to 0. A non-`EAGAIN`/`EINPROGRESS` error from
the cmsg push is the success oracle.

## Step 2 — Heap grooming and the use-after-free

With the sentinel destroyed, `tls_encrypt_async_wait()` no longer waits. The
exploit:

1. Drains/leaves phase-1 saturation, clears `sk_err` (`SO_ERROR` getsockopt).
2. Re-saturates a fresh cryptd queue.
3. Submits one more TLS encryption that is backlogged, then **frees the
`tls_rec`** (the close/free path runs without waiting for the still-pending
cryptd callback). The freed record is immediately **reclaimed** by a netlink
data spray (`io_prep_pwrite` of a controlled buffer onto an
`NETLINK_USERSOCK` socket). Because the reclamation happens at page
granularity, this also defeats `CONFIG_RANDOM_KMALLOC_CACHES` on the COS
target.

When the cryptd worker finally processes the request, it calls
`crypto_async_request.complete` — now pointing into our sprayed buffer — giving
**RIP control with R12 = the controlled object**.

## Step 3 — KASLR

The exploit has no read primitive, so the kernel base is recovered with an
**EntryBleed-style prefetch timing side-channel** (`prefetchnta`/`prefetcht2`
+ `rdtscp`, majority vote across several rounds). The prefetch reduction is
selected at runtime from the CPU vendor (`detect_cpu_vendor()` via CPUID): on
Intel the mapped kernel text is the single minimum-time address, while on AMD
and virtualized/KVM hosts (e.g. the GitHub CI runners) it is the fine-grained
window with the highest aggregate time. This makes the same binary reliable on
the Intel kernelCTF remote and on AMD CI runners. The resulting base is fed to
the ROP chain.

(The exploit declares `requires_separate_kaslr_leak: false` — it brings its own
KASLR break.)

## Step 4 — Stack pivot + ROP chain (RIP → core_pattern overwrite)

The hijacked `complete` call runs in a **cryptd kernel worker thread**, which
has no userspace frame — so `ret2usr`/`commit_creds`-then-return is not usable
here. Instead the exploit overwrites `core_pattern`, then triggers a userspace
crash so the kernel re-executes our binary as root.

* **Pivot (JOP, from R12).** At the hijacked indirect call, `R12` points at the
base of the reclaimed object and the called function pointer
(`crypto_async_request.complete`) is at object offset `0x10`. That pointer is
set to a 2-step JOP gadget:

```
step 1: mov rax, [r12+0x40] ; mov rdi, r12 ; pop rbp ; pop r12 ; pop r13 ; jmp rax
pivot : push rdi ; pop rsp ; … ; ret (rax = [r12+0x40] = obj+0x40)
```

`step 1` loads the pivot gadget from `obj+0x40` and copies `r12` (the object)
into `rdi`; the pivot then does `push rdi; pop rsp`, landing `RSP` on the ROP
chain laid out from `obj+0x0`. The first ROP word is `add rsp, 0x10; ret`, so
the `complete` slot at `obj+0x10` is skipped by the chain. The exact gadget
addresses (per target) are in the `exploit_gen()` comments in
`exploit/<target>/exploit.c`.

* **ROP chain.** The chain performs:

```
_copy_from_user(core_pattern, "|/proc/%P/fd/666 %P", 0x14) (write core_pattern)
msleep(0x3b9aca00) (park the worker)
```

i.e. `pop rdx=0x14; pop rsi=&pattern; pop rdi=&core_pattern; call _copy_from_user`
then `pop rdi=large; call msleep`. The trailing `msleep` keeps the corrupted
cryptd worker from returning into freed/inconsistent state, so the machine
stays alive after the overwrite while the helper child triggers the coredump.

## Step 5 — Post-RIP: become root and read the flag

`core_pattern` is set to `|/proc/%P/fd/666 %P`. A child process (forked early,
pinned to a different CPU) has fd 666 pointing at a copy of our own executable
(`memfd` + `sendfile` of `/proc/self/exe`). Once the exploit detects that
`core_pattern` changed, the child dereferences a NULL pointer to crash. The
kernel runs `/proc/<pid>/fd/666` **as root**, passing the crashed child's PID as
`argv[1]`.

The root re-exec (the `argc > 1` branch in `main`) uses
`pidfd_open()` + `pidfd_getfd()` to steal the crashed child's stdin/stdout/stderr
(which are the console), then runs `cat /flag`. The flag is therefore printed on
the serial console, which is exactly what the kernelCTF repro harness greps for.

## Environmental / threading notes

* CPU pinning (`sched_setaffinity`) is used so the saturation and the victim
encryption target the same per-CPU cryptd queue.
* The exploit is multi-threaded: thousands of `MAY_BACKLOG` worker threads keep
the cryptd queue full, released together via a `pthread_barrier`. AIO
(`io_submit`) provides the fast bulk fill.
* `RLIMIT_NOFILE` is raised/managed for the large fd pools.
* No user namespaces and no capabilities are required.

## `--vuln-trigger` (KASAN)

Running the exploit with `--vuln-trigger` performs only the vulnerability
trigger (corrupt the sentinel, then free the `tls_rec` with a cryptd callback
still pending) without KASLR, ROP or the heap spray, so a KASAN build reports
the use-after-free directly. This is the mode used by the `vuln-verify`
workflow to confirm the bug and that the patch fixes it.

## Reliability

The `exploit_repro` GitHub Action reports **100%** on both targets
(`lts-6.12.77` 10/10, `cos-121-18867.381.30` 10/10). The two non-destructive
failure modes are a KASLR side-channel mis-vote and losing the cryptd
saturation race; both simply mean "re-run". See `metadata.json` for per-target
stability notes.
107 changes: 107 additions & 0 deletions pocs/linux/kernelctf/CVE-2026-31533_lts_cos/docs/vulnerability.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
# CVE-2026-31533 — net/tls use-after-free in the `-EBUSY` error path of `tls_do_encryption()`

## Summary

| | |
| --- | --- |
| **Subsystem** | `net/tls` (kernel TLS, software/`tls_sw` path) |
| **Bug class** | Use-after-free, caused by an improper reference-count / state update (CWE-911) |
| **Introduced by** | [`859054147318`](https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/commit/?id=8590541473188741055d27b955db0777569438e3) — *"net: tls: handle backlogging of crypto requests"* (the fix for CVE-2024-26584), first shipped in **v6.8-rc1** and backported to the `6.1.y` and `6.6.y` stable trees. |
| **Fixed by** | [`a9b8b18364ff`](https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/commit/?id=a9b8b18364fffce4c451e6f6fd218fa4ab646705) — *"net/tls: fix use-after-free in -EBUSY error path of tls_do_encryption"* (`Cc: stable`). |
| **Affected versions** | mainline **6.8 – until the fix**, plus the `6.1.y` / `6.6.y` stable branches that carry the introducing commit. Both kernelCTF targets in this submission (`lts-6.12.77`, `cos-121-18867.381.30`) are vulnerable. |
| **Requirements** | Unprivileged local user. **No** capabilities, **no** user namespaces. Needs `AF_INET`/TCP + kernel TLS (`CONFIG_TLS`) and `AF_ALG` (`CONFIG_CRYPTO_USER_API_SKCIPHER`, `CONFIG_CRYPTO_USER_API_AEAD`, `CONFIG_CRYPTO_CRYPTD`). |

## Root cause

`tls_do_encryption()` (`net/tls/tls_sw.c`) submits the record encryption to the
crypto API. Commit `859054147318` added handling for the `-EBUSY` return code
of `crypto_aead_encrypt()` (request placed on the cryptd backlog):

```c
rc = crypto_aead_encrypt(aead_req);
if (rc == -EBUSY) {
rc = tls_encrypt_async_wait(ctx); /* wait for the backlogged request */
rc = rc ?: -EINPROGRESS;
}
if (!rc || rc != -EINPROGRESS) {
atomic_dec(&ctx->encrypt_pending); /* (A) */
sge->offset -= prot->prepend_size; /* (B) */
sge->length += prot->prepend_size;
}
```

When the request is backlogged, the asynchronous completion callback
`tls_encrypt_done()` runs and **already** performs the cleanup:

* it decrements `ctx->encrypt_pending` (via `atomic_dec_and_test()`), and
* it restores the scatterlist entry (`sge->offset`, `sge->length`).

If `tls_encrypt_async_wait()` returns an **error** — for example because the
socket's `sk->sk_err` was set in the meantime — then `rc` is non-zero and not
`-EINPROGRESS`, so the synchronous path at `(A)`/`(B)` runs the *same* cleanup a
second time. This produces two distinct corruptions:

### Bug 1 — double-decrement of the `encrypt_pending` sentinel

`encrypt_pending` is initialised to `1` and used as a sentinel so that
`tls_encrypt_async_wait()` knows whether any async request is still
outstanding. The second `atomic_dec()` drives the sentinel to `0`. From then on
`tls_encrypt_async_wait()` (an `atomic_dec_and_test()` on a value that is now
`0` → wraps / returns true immediately) **stops waiting for pending callbacks**.

This is the exploitation primitive: a later `sendmsg()` can free the `tls_rec`
through `bpf_exec_tx_verdict()` → `tls_free_open_rec()` while a cryptd callback
for that record is **still pending**. When the cryptd worker later fires the
callback, it dereferences and calls through the freed `tls_rec` /
`aead_request`, i.e. a **use-after-free with control of an indirect call**
(`crypto_async_request.complete`).

### Bug 2 — double restoration of the scatterlist entry

The same path restores `sge->offset`/`sge->length` a second time, so
`sge->length` ends up larger than `msg_encrypted->sg.size`. A later
`sk_msg_free()` then underflows its unsigned length accounting and crashes. This
is avoided in the exploit by laying out the `msg_encrypted` scatterlist via page
fragment alignment so the first element's length is exactly `sg.size`, letting
`sk_msg_free()` terminate cleanly and keeping the corrupted sentinel alive.

## Triggering the vulnerability

All steps are reachable by an unprivileged user:

1. **Force async crypto.** Bind `cryptd(...)` AF_ALG transforms
(`cryptd(ctr(aes-generic))`, `ccm_base(cryptd(...),...)`) so the system-wide
crypto priority makes kernel-TLS use cryptd (returning `-EINPROGRESS`/`-EBUSY`
instead of completing synchronously).
2. **Saturate the cryptd queue.** Submit ≥ `cryptd_max_cpu_qlen` (default 1000)
crypto operations on the target CPU — here via a batch of `io_submit()` AIO
reads plus a flood of `MAY_BACKLOG` worker threads — so the next encryption
returns `-EBUSY`.
3. **Set `sk->sk_err`.** Have the TLS peer send invalid ciphertext and call
`recvmsg()`; the decryption failure sets `sk->sk_err = EBADMSG`
(`tls_err_abort()`).
4. **Push the pending record via cmsg.** `sendmsg()` with a
`TLS_SET_RECORD_TYPE` control message reaches `tls_handle_open_record()`
before the `sk_err` check; the encryption hits `-EBUSY`, the callback observes
`sk_err`, and the double-decrement fires — corrupting the sentinel.

After the sentinel is corrupted, closing/freeing the record while its callback
is still queued yields the use-after-free.

## Cause type

Use-after-free (driven by an improper reference-count / state update). See
`docs/exploit.md` for how the UAF is turned into RIP control and a root shell.

## Syscalls / interfaces involved

`socket(AF_INET/AF_ALG/AF_NETLINK)`, `setsockopt(TCP_ULP="tls", SOL_TLS, …)`,
`sendmsg`/`recvmsg` (incl. `TLS_SET_RECORD_TYPE` cmsg), `io_setup`/`io_submit`
(libaio). No `io_uring`, no user namespaces, no capabilities.

## Notes for blocking

The vulnerable path requires the kernel-TLS software encryption path combined
with cryptd-backed async crypto. Blocking `AF_ALG` (`CONFIG_CRYPTO_USER_API_*`)
removes the attacker's ability to force the async/backlog path and thus the bug's
reachability.
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# kernelCTF CVE-2026-31533 — exploit for target cos-121-18867.381.30
#
# The exploit links against libaio (io_submit batching is used to saturate the
# per-CPU cryptd queue). The `prerequisites` target installs it; the kernelCTF
# CI runs `make prerequisites` automatically before `make exploit` if present.

CFLAGS = -static -O2 -DTARGET_COS
LDFLAGS = -laio -lpthread

all: exploit

prerequisites:
sudo apt-get update
sudo apt-get install -y libaio-dev

exploit: exploit.c
gcc $(CFLAGS) -o exploit exploit.c $(LDFLAGS)

run: exploit
./exploit

clean:
rm -f exploit

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