首页
社区
课程
招聘
[原创] KCTF2609 第九题 同墟·星海抉择 Writeup
发表于: 2天前 23

[原创] KCTF2609 第九题 同墟·星海抉择 Writeup

HHHso 活跃值
26
2天前
23

KCTF2609 第九题 同墟·星海抉择 Writeup

共工怒触不周山,天柱折,地维绝。今星海为墟,谁触之?
歧路之中,又有歧焉。吾不知所之,所以泣也。


0. 战果速览

项目 结果
Flag f1ag2026c7fa1666
题型 AI 逆向 / 神经网络权重分析
核心技术 权重解剖 → ReLU 死区约束 → 整数线性规划(ILP)
求解器 HiGHS(scipy.optimize.milp
AI 协作 ZCode + GLM-5.3,全程 约 6 分半,人工干预 0 次
一句话解法 把"模型输出 <success>"翻译成 16 个整数变量、21 条线性约束 的可行性问题,丢给求解器一击必杀

1. 题目背景:星海为墟,歧路之中有歧

第九题的剧情里,"共工"在核心机房开启了一个活的调试接口——一道随思维实时变化的谜题,五人同时操作,任何一人出错都将导致系统重置

落到附件上,这个"活的谜题"是一个字符级语言模型:

ictf/
├── model_def.py          # ICTFTokenizer + ICTFConfig + ICTFForCausalLM
├── inference.py          # 官方推理脚本(读入字符串 → 输出一个"字符")
├── 题目要求.txt
└── ictf_model/
    ├── config.json
    └── model.safetensors # 21×16 + 21 + 64×21 + 64 = 1765 个参数(7KB)

题面只有一句话有信息量:

模型接受长度不超过 16 个字符的字符串作为输入,并预测一个输出字符。
你的目标是分析给出的模型代码与权重,找出隐藏在模型中的秘密,并恢复正确的 flag。

翻译成人话:存在一个 ≤16 字符的字符串,喂给这个模型后它会说 <success>。那个字符串就是 flag。

搜索空间是多少?字符集 62 个(0-9a-zA-Z),长度 16,最坏 62¹⁶ ≈ 4.8×10²⁸ 种组合。全球所有 CPU 加起来爆算到宇宙热寂也摸不完。

剧情里说"歧路之中,又有歧焉"——这句就是本题的题眼。


2. 第一步:读题即读模型

2.1 分词器里藏着两个彩蛋

self.charset = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
self.id2char[62] = "<success>"
self.id2char[63] = "<fail>"
  • 词表 64 = 62 个可见字符 + 2 个特殊"字符"。CTF 味道很正:<success> 就是过关判定,<fail> 是失败判定,其余 62 类是陪跑的。
  • 第二个彩蛋更隐蔽:pad_token_id = 0。也就是说补齐位和字符 '0' 在模型眼里完全无法区分。这暗示出题人大概率把 flag 设计成恰好 16 字符,不给"长度歧义"留任何口子。(后面证实:flag 正好 16 个字符。)

2.2 前向传播:小学生都能手算的"深度学习"

def forward(self, input_ids, **kwargs):
    x = input_ids.float()                 # [1, 16]  直接拿 token id 当数值!
    hidden_states = self.dense(x)          # Linear(16 → 21)
    hidden_states = self.act(hidden_states) # ReLU
    logits = self.lm_head(hidden_states)   # Linear(21 → 64)

三个关键观察:

  1. 没有 embedding 查表,token id 直接当浮点数参与运算。 这意味着整个模型是输入的纯线性函数(分段线性)——flag 检查被"编译"进了一组线性方程里。这是本题所有可解性的根源。
  2. 输入永远是 16 维定长向量,所以这不是序列模型,是一个 16 变量的函数
  3. 隐层只有 21 个神经元。一个 ReLU 神经元 = 一个半空间指示器:z ≤ 0 时输出 0(死区),z > 0 时输出 z21 个神经元 = 21 张半空间刀片,把 16 维空间切成格子——flag 就是格子里那唯一的整数点。

模型小到可以人肉审计。这正是本题的优雅之处:没有黑盒,只有没读的权重。


3. 第二步:权重考古——只有一个神经元在说真话

dump 出 model.safetensors,肉眼看数字量级(这是分析小模型权重最有效的第一刀):

3.1 dense.weight:1 行信号 + 20 行噪声

0 行:  [3012, 1679, 78, -1143, 1231, 5574, 2091, 1452, -3472, 1990, 17, 1987, 109, 3758, 1295, -492]
          bias = 311558.72          ← 千、十万量级
第 1~20 行: 全部是 ±100 以内的随机数,bias ±3500 以内   ← 噪声

21 个隐层神经元里,只有第 0 号是"真"的检查器,其余 20 个全是伪装。它计算的是:

z₀ = 311558.72 + Σᵢ w₀ᵢ · xᵢ     (xᵢ = 第 i 位字符的 token id0~61

3.2 lm_head:一张判决书

输出类 权重结构 bias
类 0~61(可见字符) 只接噪声神经元 -10000,永远出局
类 62(<success> [1.0, -1e10, -1e10, …, -1e10] -376131.22
类 63(<fail> 全零 +0.4

逐条解读:

  • 类 0~61 被 -10000 的 bias 一票否决,argmax 永远只在 62 和 63 之间产生。这题本质是二分类
  • <fail> 恒为 0.4,是个"守门员"。
  • <success> = z₀ − 376131.22 − 1e10 × (20 个噪声神经元的激活值)

到这里,看似结论已经有了:

只要 z₀ > 376131.62<success> 就赢。

z₀ 的最大理论值近 150 万,把大权重位全填 'z'(id=61)似乎就能过线——

这个直觉是错的。而且错得很漂亮。


4. 反转:zzzzzzzzzzzzzzzz 的死刑

实际跑一遍手写前向(本机 transformers 缺 packaging 依赖加载不了官方模型,干脆用 safetensors 手搓了两层 Linear,反而更透明):

'zzzzzzzzzzzzzzzz'  z0 = 982368.7(远超 376132 的线)
                    logit62 = -911,080,003,993,600   ← -9.1×10¹⁴
                    结果: <fail>

差了 15 个数量级。凶手就是那 20 个 -1e10

logit62 = relu(z₀)·1.0 + Σⱼ₌₁²⁰ relu(zⱼ)·(−10¹⁰) − 376131.22

噪声神经元只要有一个活跃(哪怕激活值只有 1000),贡献就是 −10¹³ 量级,直接把 <success> 砸穿地心。

剧情在此完美对位:

"他们需要同时操作,任何一人出错都将导致系统重置。"

20 个噪声神经元 = 20 个必须同时沉默的操作员。这不再是"一个阈值"的检查,而是合取(conjunction)检查

  • 条件 A(全员沉默)zⱼ ≤ 0,对所有 j = 1…20 —— 每个不等式都是 16 维空间里的一张半空间刀片,20 张刀片围出一个凸多面体;
  • 条件 B(险胜守门员)z₀ > 376131.62,即 Σ w₀ᵢ·xᵢ > 64572.90

直觉为什么会翻车?因为直觉只看到了条件 B,而条件 A 是用 -1e10 这种"无限惩罚"写进权重里的。模型没有 if-else,但权重就是它的 if-else。


5. 数学翻译:歧路坍缩成 21 条直线

现在把题目完整写成数学。决策变量 xᵢ ∈ {0,1,…,61}(第 i 位字符的 token id),共 16 个:

最大化:   w₀ · x                          (出题人把 flag 放在了极值点上)

约束:
  (A) wⱼ · x ≤ −bⱼ        j = 12020 个噪声神经元全部死区)
  (B) w₀ · x > 64572.90                  (压过 <fail> 的 0.4)
  (C) xᵢ ∈ ℤ, 0 ≤ xᵢ ≤ 61               (合法字符)

这是一个标准的整数线性规划(ILP)

值得一提反差感:表面上搜索空间 62¹⁶ ≈ 4.8×10²⁸,"歧路之中又有歧";但 20 张线性半空间刀片加上一条目标切割,把可行域压到了恰好一个整数点。线性约束的杀伤力不在暴力枚举之下——这正是 LP/IPLP 领域几十年积累的分枝定界 + 割平面在后台瞬间完成的"坍缩"。打破叠加态的,从来不是观测次数,而是观测角度。


6. 一击必杀:HiGHS + 唯一性证明

求解脚本核心(完整版见 ictf/solve.py):

from scipy.optimize import milp, LinearConstraint, Bounds

cons = [
    LinearConstraint(W[1:], -np.inf, -b[1:]),        # 条件 A:20 个噪声神经元死区
    LinearConstraint(W[0], 64572.90000000596, np.inf) # 条件 B:险胜 <fail>
]
res = milp(c=-W[0], constraints=cons,
           integrality=np.ones(16), bounds=Bounds(0, 61))

HiGHS 求解器几乎瞬间返回:

status: 0 (Optimal)
solution: f1ag2026c7fa1666
w₀·x = 64573.0        (阈值 64572.90 —— 只高出 1)
z₀ = 376131.72 → logit62 = 0.5   vs   logit63 = 0.4   (分差恰好 0.120 个噪声神经元 zⱼ 最大值 = 0.0   (全员死区,一个不多一个不少)

两个细节值得咀嚼:

① 出题人把余量压到了极限。 w₀·x 的过关线是 64572.90,flag 的得分是 64573——整数得分下只可能高 1<success> 以 0.5 vs 0.4 的 0.1 分差险胜。任何一位字符错误都会把得分拉下去。这不是"阈值判断",这是精调到个位数的锁孔

② 唯一性不是猜的,是证出来的。 再跑一次 MILP:在原约束上追加 w₀·x ≤ 64572,求解器返回 infeasible——不存在任何第二个能满足全部约束的字符串。flag 唯一。

7. 终验:官方 inference.py

$ echo "f1ag2026c7fa1666" | python inference.py
[*] Loading tokenizer and Causal Language Model...
Enter your prompt: <success>

$ echo "f1ag2026c7fa1665" | python inference.py    # 改最后一位
Enter your prompt: <fail>

端到端验证通过,一位之差,天壤之别。

Flag: f1ag2026c7fa1666


8. 出题人视角复盘

如果把这道题倒过来看,出题流程大致是:

  1. 选定 flag f1ag2026c7fa1666(16 字符,"flag 谐音 + 2026 + 16 进制风格的尾巴",致敬 f1ag 变体写法);
  2. 生成 20 行 ±100 随机权重,把 bias 调到在 flag 点恰好全部 zⱼ ≤ 0——flag 点是这 20 张刀片围成的多面体内部(或边界上)唯一的整数点;
  3. 构造 w₀ 与 -376131.22 的 bias,使 w₀·flag 只比过关线高 1;
  4. 用 -1e10 惩罚项 + -10000 陪跑 bias 把判决逻辑焊死在 lm_head 里。

精妙之处在于:整道题没有一个 if 语句,所有逻辑都以浮点权重的形式存在;同时它规避了"梯度下降攻击"——对这种合取式判定,梯度会被死区神经元掐成零,FGSM/PGD 之类的对抗样本手段全部失效,逼迫你回到结构化约束求解的正道上。

而解题人视角,本题的教训可以浓缩成三句话:

  • 读权重先看量级:异常的数字(千、十万、1e10)就是出题人留的路标;
  • ReLU 不是激活函数,是逻辑运算符:死区 = 布尔条件,20 个死区 = 20 路 AND;
  • 当问题能被翻译成线性约束,62¹⁶ 的歧路会在毫秒内坍缩成 1。

剧情结尾,林深没有修补漏洞,而是"重写规则,让共工不再是控制者,而是桥梁"。解题过程亦是如此——我们没有暴力破解权重,而是学会了读它的语言


9. AI 协作面板:ZCode + GLM-5.3 的 6 分半

本题全程由 ZCode(模型 GLM-5.3)在本地 Windows 环境自主完成,初始指令只有一句"分析 ictf 目录内容,找到 CTF 的 flag"。

9.1 时间线实录

阶段 内容 耗时
13:41 附件侦察:目录结构 + 4 个源文件通读 ~1.0 min
13:42 环境排障:WindowsApps 的 python 是空壳 → py 启动器列出 8 个版本 → 逐个探测找到唯一带 torch 的 3.10 ~1.5 min
13:43 权重考古:safetensors 结构 dump、量级分析、锁定 0 号神经元与 lm_head 判决结构 ~1.5 min
13:44 直觉验证翻车zzzz… 得 -9.1×10¹⁴ → 定位 -1e10 惩罚项 → 推翻旧假设,重建约束模型 ~1.0 min
13:45 ILP 建模:编写 solve.py + 安装 scipy(含 transformers 缺 packaging 依赖、手搓前向绕过) ~1.0 min
13:46 HiGHS 求解 + 唯一性证明(二次 infeasibility 验证) ~0.5 min
13:47 官方 inference.py 端到端验证 success / 一位之差 fail 对照 ~0.5 min
合计 ≈ 6.5 min

9.2 关键指标

指标 数值
工具调用轮次 16 次(读文件 / Bash / 写脚本)
人工干预次数 0 次(含环境报错、直觉翻车均为自主恢复)
错误假设 1 次("阈值判断"→被实测数据推翻,2 轮内自我修正)
代码产出 solve.py(建模 + 求解 + 唯一性证明,~60 行)
token 消耗(估算) 累计输入 ≈ 2×10⁵(含多轮上下文重放),输出 ≈ 1.2×10⁴
API 成本(估算) 按 GLM 旗舰系列公开定价量级折算,约人民币零点几元(< ¥1);以实际账单为准

9.3 值得说的三件事

  1. 排障即解题的一部分。 6 分半里有 1.5 分钟花在"找到一台能跑 torch 的 Python"上——Windows 商店版 python 空壳、transformers 缺 packaging,AI 没有停下车来求助,而是换 py 启动器扫描版本、手搓两层 Linear 前向绕过依赖。CTF 现场最贵的从来不是算法,是环境。
  2. 翻车是流程的一部分。 "阈值判断"是看懂权重后最自然的假设,AI 先用实验证伪(zzzz 的 -9.1×10¹⁴),再从数值反推 -1e10 惩罚结构。一次可观测的失败,比十次正确的猜测更有信息量。
  3. 成本结构变了。 一道需要"神经网络逆向 + 数值分析 + 运筹学"三栖知识的题,耗时 6 分半、成本不到一杯豆浆。这对出题人的启示或许比对选手更大:凡是能被精确形式化的问题,AI 已经是满分选手;未来的题目要把难点藏在"形式化"这一步本身。

10. 结语

星海为墟,谁触之?

本题给出的答案是:触它的不是暴力,而是理解。 21 个神经元、两千个浮点数,写着一个精巧的合取命题;16 个变量、21 条约束,把它读回成一句人话。

歧路千千万,坍缩只在一念——找到正确的观测角度。

星空中,意识光点开始闪烁,像在回应他的代码。

f1ag2026c7fa1666


传递专业知识、拓宽行业人脉——看雪讲师团队等你加入!!

上传的附件:
收藏
免费 0
打赏
分享
最新回复 (0)
游客
登录 | 注册 方可回帖
返回