[Write-up] ACTF 2026: ACPU
题目由 GPT 完成,本报告也由 GPT 生成
1. 题目概况
这题表面附件是一个 Linux ELF,但真正的攻击面不是原生 ELF 的栈、堆或 GOT,而是一个 Verilator 编译出来的 RISC-V CPU 仿真器。服务端接收一段 base64 编码的 RISC-V 机器码,把它写入仿真器 ROM,然后启动 CPU 执行。目标是绕过 CPU 内部对 flag.mem 的特权隔离,把 flag 从高地址内存读出来。
题目附件主要包括:
1 | bin/run.py 服务包装脚本 |
2. 服务入口分析
2.1 run.py 输入处理
bin/run.py 的核心流程如下:
1 | MAX_LEN = 0x200 * 4 |
几个关键点:
- 用户只能输入 base64,解码后最多
0x800字节。 - 用户代码写入
/tmp/rom_file.mem的@00000100。 flag.txt被写入/tmp/flag.mem。Simulation在/tmp下运行,因此它会读取/tmp/rom_file.mem和/tmp/flag.mem。
.mem 文件中的地址是 32-bit word 下标,而 CPU 的 PC 是字节地址。因此 CODE_START = 0x100 对应的执行地址是:
1 | 0x100 * 4 = 0x400 |
这和后面 ROM 启动代码设置 sepc = 0x400 完全对应。
2.2 结束输出机制
IDA 分析 Simulation,在 VCPU___024root___eval_initial__TOP__Vtiming__0 中可以看到仿真结束逻辑:
1 | if (pc > 0x1fff) { |
也就是说,只要能让 PC 跳到 0x2000 或更高,仿真器就会把全部 32 个通用寄存器打印到 stdout。这个机制是天然泄露通道:只要把 flag 放进寄存器,再跳到 0x2000 即可看到 flag。
3. ROM 程序分析
3.1 system.mem 反汇编
system.mem 是初始 ROM。将每个 32-bit word 按 little-endian 转为二进制后,用 RISC-V objdump 反汇编,关键代码如下:
1 | 0x00: addi x5,x0,1024 |
3.2 ROM 的设计意图
ROM 在启动阶段设置:
1 | sepc = 0x400 |
随后执行 sret,进入用户代码。用户态如果执行 ecall,CPU 会跳到 stvec = 0x40 的 handler。这个 handler 的设计意图是提供一个受限 oracle:
- 用户把某个地址放到
a0/x10。 - handler 在特权态读取
0x80000000的 flag memory。 - handler 再读取用户给出的地址。
- 比较前
0x40字节是否相同。 - 返回
a0 = 0或a0 = 1,表示相等或不相等。
为了避免直接泄露,handler 返回前会清空 x5/x6/x7/x28/x29,也就是清掉曾经保存 flag 地址、用户地址和 flag word 的临时寄存器。
从正常设计看,我们最多只能得到一个比较 oracle,不能直接读出 flag。
4. 仿真器内存模型分析
IDA 中 VCPU___024root___eval_initial__TOP 显示仿真器启动时读入两块 memory:
1 | VL_READMEM_N(..., "rom_file.mem", ..., a1 + 872, ...); |
结合 ROM 行为和实验现象,可以得到内存模型:
rom_file.mem是指令 ROM,同时用户代码也写入其中。flag.mem是单独的 flag memory。0x80000000是 flag memory 的映射基址。- 用户态直接
lw 0x80000000读不到 flag,结果为 0。 - ROM handler 在特权态可以读取
0x80000000。
因此漏洞利用不能简单写成:
1 | lui t0,0x80000 |
本地测试这种直接读法时,寄存器 dump 中 s0/s1/... 全是 0,说明权限检查确实存在。
5. 漏洞点分析
5.1 对照实验
分析时做了三个对照。
第一种,用户态直接读 flag:
1 | lui t0,0x80000 |
结果:x8/x9/... 都是 0,无法泄露。
第二种,普通 ecall 后再读 flag:
1 | lui t0,0x80000 |
结果:handler 正常执行,x5/x6/x7 被清空,x10 = 0xffffffff,后续读 flag 仍失败。
第三种,在 ecall 执行前自修改该地址:
1 | 0x414: sw patched_jal, 0x418 |
结果:后续 lw 能把 0x80000000 的 flag 内容读进普通寄存器,且寄存器 dump 里 x5/x6/x7/x10 没有被 ROM handler 清空。说明问题出现在自修改代码、系统指令和流水线特权状态之间。
5.2 根因推断
题目没有给 SystemVerilog 源码,只能通过 Verilator 产物、ROM、波形调试版本和黑盒实验交叉确认。现象非常稳定:
- CPU 支持 data store 写入 code memory,用户代码可以修改自己的后续指令。
ecall与刚写入的替换指令位于同一个流水线窗口。- 旧的
ecall、新写入的jal x0,0x2000、异常/特权状态更新之间没有被精确同步。 - 流水线中和特权访存相关的状态被错误地保留下来或转发到后续 load。
- 但 ROM handler 的架构性寄存器清理没有正常作用到最终可见寄存器。
- 最终表现为:用户后续
lw可以读取本应只能特权态读取的0x80000000,并且读出的值会提交到普通寄存器。
换句话说,这是一个 CPU pipeline privilege bypass。远端 flag 中的 m3ltd0wn 也提示了这一点:利用方式类似瞬态执行/特权检查时序错误,而不是传统 pwn 里常见的栈溢出或堆利用。
6. 漏洞利用过程
6.1 利用目标
利用需要完成三件事:
- 让后续 load 获得读取
0x80000000的能力。 - 把 flag 多个 32-bit word 放入不会被 handler 清空的寄存器。
- 跳到
0x2000,触发仿真器打印全部寄存器。
6.2 指令布局
用户代码从字节地址 0x400 开始执行。EXP 把触发点固定放在 0x418:
1 | 0x400: lui t0,0x80000 ; t0 = 0x80000000 |
0x418 处原始字节仍然是 ecall,但在执行前会被 sw 改写为 jal x0, 0x2000。这个组合触发了 CPU 的异常/取指/特权状态处理缺陷。
6.3 为什么选择这些寄存器
ROM handler 明确会清理:
1 | x5 / t0 |
a0/x10 也会被 handler 当作返回值使用。因此 EXP 泄露时选择:
1 | [8, 9, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24] |
也就是避开 t0/t1/t2/t3/t4/a0,把 flag 读到 s0/s1/a1-a7/s2-s8 等寄存器中。
6.4 为什么要等待若干周期
RISC-V load 不是取指后立即写回寄存器。仿真器在 pc > 0x1fff 时马上 dump 寄存器,如果跳得太快,部分 load 结果可能还没有写回。
EXP 在跳到 0x2000 前插入 30 条:
1 | addi s9,s9,1 |
作用是拖延流水线,保证 flag word 已经提交到寄存器文件。
6.5 泄露还原
寄存器以 32-bit 十六进制输出,RISC-V 是 little-endian。例如远端输出:
1 | x8 = 0x46544341 |
按 little-endian 拼接:
1 | 0x46544341 -> b"ACTF" |
继续拼接直到 },即可得到完整 flag。
7. 远程验证结果
远端只请求了一次,完整交互输入输出如下:
1 | $ python3 solve_acpu_fixed.py --host pwn-b105ea4334.adworld.xctf.org.cn --port 9999 |
最终 flag:
1 | ACTF{H4v3_y0u_h34rd_0f_m3ltd0wn?} |
8. 完整 EXP
1 | #!/usr/bin/env python3 |