NESTCTF 2026 · 三道题
这场三道题方向完全不同:一个”不许加载”的 pickle、一个五层跳板的 Web、一个汽车 UDS 的真靶 pwn。按做题顺序记一遍,脚本都留在本机各题目录里。

一、pickle-me-not · 全程没 load 过 checkpoint
题面明写了一句 **”Do not load unknown checkpoints locally just to inspect them.”**,所以整题只用 zipfile + pickletools 解字节,没让 torch 碰过任何 .pt。
先静态看附件里的 normal_reference.pt(1806 B):data.pkl 319 B,3 个 GLOBAL 全在白名单,format=modelhub-safe-v4,权重 6×8,bias 步长 0.4 —— 这就是题面说的”受信任参考”,没有秘密。
题目主体在 ModelHub 的隔离区,得先进工作区。
1. 双编码路径穿越
GET /api/v1/artifacts?path= 的 gateway 做一次 urldecode 就做策略检查,storage 层又 unquote 一次:
1 | path=%252e%252e%252fquarantine%252fimport_ticket.json |
读到了 repository:/quarantine/import_ticket.json,拿到票据。
2. replay 拿 legacy worker RCE
POST /api/v1/import-jobs/IMP-2026-0917-017/replay 只检查 payload 前缀是 LEGACY\n,后面直接交给兼容 worker 反序列化。于是顺手把隔离目录 7 个文件全拉回本地:两个 safetensors、index、manifest、ticket、quarantine_snapshot.pt、import_diagnostics.ndjson。
3. 静态摊平快照
data.pkl 617 B,5 个 GLOBAL,白名单外只有一个:modelhub_compat.rebuild_legacy_token。它的参数把 guard 视图的几何全暴露了:
1 | storage=2 offset=19 size=(4,32) stride=(67,2) |
4. mhkdf:卡点就是题眼
import_diagnostics.ndjson 里写着 KDF 规格 mhkdf-sha256-v1:
1 | block_i = sha256( utf8(global|route_id|nonce|storage_key|peer_salt) || counter_be32 ) |
我一开始按绝对偏移对齐 keystream,把字段组合、分隔符、counter 顺序全试了一遍,125 万+ 组合,最高可打印率 74/128 —— 全是噪声。换成视图内序号对齐(第 j 个视图字节异或第 j 个 keystream 字节)之后,第一个候选就出明文:
1 | seed = modelhub_compat.rebuild_legacy_token|legacy-tar-bridge/quarantine-17|6c6f61645f736166655f323032365f31|2|69c429c475ee3c2c89aa14ae514d2c75 |
6c6f... 那段是 ASCII load_safe_2026_1,storage_key 就是视图号 2。flag:
1 | flag{UuRlZZK0Q83g2Jw5xYCovugmjBOSbLllhBk35dVH} |

二、wasmnesia · 从 SSRF 到 SUID 劫持
EdgeLedger 是个运营报表平台,登录页预填了账号,SPA 只暴露 11 个 API。五层跳板,每层都有回显。
1. SSRF(userinfo 骗白名单)
POST /api/v1/connectors/fetch 有主机白名单,但解析 host 时没处理 userinfo:
1 | http://***@127.0.0.1:9090/internal/v1/artifacts/runs/job-f1234001/formal.wexp |
拿回 1168 字节的正式导出(WEXP 容器),sha256 和页面上记录的工件一致。
2. 复刻 wasm 插件
同目录的 ledger_shift.wasm 只有 389 字节,手写解析出 mix32 / derive_seed / transform_batch,常数 0x9e3779b9 / 0x85ebca6b / 0xc2b2ae35 / 0x27d4eb2d,魔数 "WASM"。Python 重写后算出指纹 dd2e9e17488602cfddc41319,和页面上的 Output fingerprint 一字不差。
另一份 edge_context_diag_aarch64 是幌子:反汇编后 main 只在两个值同时为 0 时才失败,任何参数都打印 self-test: OK,里面根本没有 transform。
3. 已知明文爆 seed(关键一步)
导出是”置换 + XOR”,输入一定是 EIPC 容器(头 28 字节已知)。写了个 C 程序对 2^32 穷举,**25 秒命中 seed = 0xcb5c3daf**(s=13, o=7, r=11)。反演出被隔离前的真实输入:
1 | redrive_endpoint=/api/v1/operations/redrives |
交叉验证:WEXP 头里那 16 个未知字节,正好等于 sha256(恢复输入)[0:16],字节级对齐。
4. redrive 命令注入
POST /api/v1/operations/redrives 的 profile 字段直接进 shell:
1 | profile = "archive-jsonl;id" -> uid=1001(ctf) |
rz.py 就成了一条命令通道。坑:输出里带 > 重定向会让接口 502,换 tee 或分段写入。
5. SUID root + PYTHONPATH 劫持
/usr/local/bin/edge-health 是 SUID root,逻辑是:
1 | chdir("/app/workspace") |
/app/workspace 是 ctf 可写。丢一个自己的 edge_health.py 进去,再跑 SUID 程序,就是 root。读 /root/flag,并复制到 /app/workspace/rootflag.copy 复核过(47 字节含换行):
1 | flag{86f8fdbuxGTvYtoY48XM2K0blC959GRvqidG7253} |
顺手拿到了 /var/lib/edgeledger/attestation.key 的 32 字节 HMAC 密钥。

三、silgate · 一次 DoIP 版本字节的旁路
真靶:km902rre.nestctf.cyberpeace.cn:58999(TLS)。vECU 实现 DoIP + ISO-TP + UDS 子集,附件给了协议规格和例程格式。
1. 广播与激活
1 | 03 fc 00 04 00 00 00 49 4c 53 49 4c 47 41 54 45 32 36 4b 37 41 33 39 30 31 0e 80 25 ... |
路由激活 type=0x01 走 memcmp(cred, "SILG1", n),但长度校验 len - 5 >= oem_len 恒不成立 —— 发短包就能无凭据通过,回 0x10。
2. 0x23 越权读
4 字节相对地址是 镜像基址 + offset,没有边界校验,一路读到 0xC00000,拖回 4 MB 固件镜像。8 字节绝对地址能读运行中的 vECU 本体 0x55DDE2288000 + 0x9000(36 KB 动态 ELF,链接 OpenSSL)和会话堆。
主密钥指针在 image+0x9108,指向封存页 0x7FDE0799B000 —— 不在可读窗口里,所以标准通道的 0x27(HMAC-SHA256)没法伪造。
3. DoIP 版本 = 0x02,进隐藏分发器
反汇编里 0x5D18: cmp bpl, 2 -> 0x6045,bpl 是 DoIP 头里的版本字节。把版本改成 0x02(校验字节 0xFD),诊断帧进第二套分发器,而且不走 ISO-TP:[src][dst][UDS...] 裸传。
4. legacy SecurityAccess(弱 16 位 KDF)
legacy 通道的 0x27 子功能是 0x01/0x02:
1 | ecx = ((seed << 7) ^ seed); ecx ^= (ecx & 0xFFFF) >> 9; ecx ^= ecx << 8 |
0x27 0x01 拿 seed,算完发 0x27 0x02 + key,回 67 02,解锁位置位。接着 0x10 0x02 进编程会话(50 02 00 32 01 F4),0x31 04/05 0203 过 RID 握手(71 05 02 03 00)。
5. 刷一段例程去读文件
0x34 申请下载,回 74 20 08 00;0x36 传数据,必须每个都是单帧(≤5 字节数据),多帧会回 NRC 0x24;0x37 回 77,例程在独立线程里从 buffer+0x10 执行。
静态扫描只拦 CC / F4 / 0F 05 / 0F 34 / CD 80,所以别写 syscall。GOT 就在可读的镜像窗口里(0x8E88 / 0x8F28),直接 call libc:
1 | fopen(IMG + 0x9080, "r") # "/home/ctf/flag" 就在 .bss |
例程执行完,用 0x23 把结果页读回来:
1 | flag{hLcV4EaVTTaAtlvX6XPWKJKkgqI1hNcqORtAu4Z0} |
整个链路的坑集中在 0x36:不是协议设计问题,是服务器侧 ISO-TP 的 STmin/BS 判定让多帧全部失败,单帧才稳。

没做完的
- 另一道 Web 题环境一直 502(Caddy 空 200),没起就放了。
踩坑表
| 坑 | 现象 | 解决 |
|---|---|---|
| pickle 的 keystream 对齐 | 125 万组合最高 74/128,全是噪声 | 按 guard 视图内序号,不是绝对偏移 |
| 双编码穿越 | 单次 %2e%2e%2f 被策略拦住 |
%252e%252e%252f,网关和 storage 各 unquote 一次 |
| wasm 侧的 aarch64 自检 | 任何参数都 OK,看着像有逻辑 |
反汇编,失败条件是两个值同时为 0,纯幌子 |
redrive 注入带 > |
接口 502 | 换 tee / 分段写 |
silgate 0x36 多帧 |
NRC 0x24 |
每条单帧,≤5 字节数据 |
silgate 标准 0x27 |
只能回 NRC,密钥读不到 | DoIP 版本改 0x02 走 legacy 弱 KDF |
一句话总结
三道题考的东西不重样:pickle 是”只读字节流也能还原密钥流”,wasmnesia 是”每一层都留了可观测回显,链就断不了”,silgate 则是”一个头字段的版本分支,比任何加密都致命”。真要说共同点:先找到那个能让程序走另一条分支的字节,剩下的都是体力活。
本文作者 Allureluoli
文字版权归作者所有,转载请注明出处;商业用途请先联系二月。