-
-
[原创] 看雪·2026 KCTF 第九题:丑寅同墟·星海抉择
-
发表于: 2天前 12
-
什么叫网页还没打开已经一堆人做出来了??
人类
(分隔线)
AI
ICTFForCausalLM 模型逆向解题报告 (by GPT 5.5 High)
1. 题目概述
附件给出了一个自定义字符级语言模型 ICTFForCausalLM,以及配套的模型权重和推理脚本。题目要求分析模型代码与权重,恢复隐藏在模型中的秘密输入,使模型输出 <success>。
最终恢复出的输入为:
f1ag2026c7fa1666
若平台要求标准 flag 包裹格式,则提交:
flag{f1ag2026c7fa1666}
本题的核心并不是传统意义上的语言模型预测,而是将 flag 编码成了一个整数线性约束系统。模型的前向传播可以被完整转化为线性代数与整数规划问题。
2. 文件结构分析
附件中主要文件如下:
ictf_model/
config.json
model.safetensors
inference.py
model_def.py
题目要求.txt
其中 model_def.py 定义了 tokenizer 和模型结构,model.safetensors 保存了模型参数,inference.py 用于加载模型并测试输入。
模型配置如下:
{
"vocab_size": 64,
"hidden_size": 21,
"max_position_embeddings": 16
}
这说明模型接受最长 16 个字符的输入,经过一个 21 维隐藏层,最后输出 64 维 logits。
3. Tokenizer 分析
ICTFTokenizer 中定义的字符集为:
charset = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
共有 62 个普通字符:
0-9 -> id 0 到 9
a-z -> id 10 到 35
A-Z -> id 36 到 61
另外两个特殊 token 为:
id 62 -> <success>
id 63 -> <fail>
输入字符串会被逐字符编码成整数。如果输入长度不足 16,则使用 pad_token_id = 0 补齐。
因此,一个输入可以表示为 16 维整数向量:
x = (x0, x1, ..., x15)
其中每个分量满足:
xj ∈ {0, 1, 2, ..., 61}
4. 模型结构还原
模型前向传播代码如下:
def forward(self, input_ids, **kwargs):
x = input_ids.float()
hidden_states = self.dense(x)
hidden_states = self.act(hidden_states)
logits = self.lm_head(hidden_states)
logits = logits.unsqueeze(1)
return {"logits": logits}
也就是说,整个模型实际上只有三步:
z = W x + b
h = ReLU(z)
logits = U h + c
其中:
W: dense.weight,形状为 21 × 16
b: dense.bias,形状为 21
U: lm_head.weight,形状为 64 × 21
c: lm_head.bias,形状为 64
输入不是自然语言 embedding,而是直接把字符 id 当作浮点数参与线性运算。因此模型本质上是一个整数向量约束器。
5. 输出层权重分析
检查 lm_head 的参数后可以发现,绝大多数 token 的输出都被压低:
普通字符 token 0~61 的 bias 全部为 -10000
只有两个特殊 token 有意义:
<success> -> id 62
<fail> -> id 63
其中 <fail> 的输出非常简单:
logit_fail = 0.4
而 <success> 的输出为:
logit_success = h0 - 10^10 * (h1 + h2 + ... + h20) - 376131.21875
也就是说,模型设计成了如下判定器:
如果 h1 到 h20 全部为 0,并且 h0 足够大,则输出 <success>
否则输出 <fail>
因为 10^10 是一个极大的惩罚系数,只要 h1 到 h20 中有任意一个为正,logit_success 就会变成极大的负数,不可能超过 <fail> 的 0.4。
6. ReLU 的数学意义
隐藏层每一维为:
hi = ReLU(zi) = max(0, zi)
其中:
zi = Wi · x + bi
对于第 1 到第 20 个隐藏单元,lm_head 对它们施加了巨大负权重:
-10^10 * hi
因此要让 <success> 获胜,必须满足:
h1 = h2 = ... = h20 = 0
根据 ReLU 定义:
hi = 0 ⇔ zi ≤ 0
所以得到 20 个线性不等式:
W1 · x + b1 ≤ 0
W2 · x + b2 ≤ 0
...
W20 · x + b20 ≤ 0
这就是本题最关键的数学转化:
神经网络隐藏层被 ReLU 和输出层权重转化成了一组线性约束。
7. <success> 与 <fail> 的比较条件
模型最终使用:
next_token_id = torch.argmax(logits[:, -1, :], dim=-1).item()
也就是说,只要:
logit_success > logit_fail
模型就会输出 <success>。
已知:
logit_fail = 0.4
当 h1 到 h20 全部为 0 时:
logit_success = h0 - 376131.21875
所以需要:
h0 - 376131.21875 > 0.4
即:
h0 > 376131.61875
而:
h0 = ReLU(W0 · x + b0)
第 0 行的 bias 为:
b0 = 311558.71875
因此:
W0 · x + 311558.71875 > 376131.61875
整理得:
W0 · x > 64572.9
由于 W0 和 x 都是整数,W0 · x 必然为整数,因此等价于:
W0 · x ≥ 64573
最终模型成功条件变为一个纯整数线性约束问题:
xj ∈ {0, 1, ..., 61}
W1 · x + b1 ≤ 0
W2 · x + b2 ≤ 0
...
W20 · x + b20 ≤ 0
W0 · x ≥ 64573
8. 为什么可以直接解线性方程
虽然从严格意义上讲,成功条件是线性不等式组,但观察权重后可以发现,这 20 个约束被设计得很像“校验方程”。
实际求解时,可以尝试更强的条件:
W1 · x + b1 = 0
W2 · x + b2 = 0
...
W20 · x + b20 = 0
即让所有被惩罚的隐藏单元都恰好落在 ReLU 的零点上。
这相当于求解线性方程组:
A x = -b
其中:
A = dense.weight[1:21]
b = dense.bias[1:21]
A 的形状是 20 × 16,即 20 个方程、16 个未知数。虽然方程数多于未知数,但如果题目构造时确实把 flag 嵌入其中,那么这个超定方程组会有一个精确解。
求解后得到:
x = [15, 1, 10, 16, 2, 0, 2, 6, 12, 7, 15, 10, 1, 6, 6, 6]
映射回字符:
15 -> f
1 -> 1
10 -> a
16 -> g
2 -> 2
0 -> 0
2 -> 2
6 -> 6
12 -> c
7 -> 7
15 -> f
10 -> a
1 -> 1
6 -> 6
6 -> 6
6 -> 6
所以得到:
f1ag2026c7fa1666
9. 求解脚本
下面是一个可复现的求解脚本。它不需要暴力枚举 62^16 种可能,而是直接把模型权重转化为线性方程求解。
from safetensors.torch import load_file
import numpy as np
charset = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
state = load_file("./ictf_model/model.safetensors")
W = state["dense.weight"].numpy()
b = state["dense.bias"].numpy()
# rows 1~20 are the ReLU penalty constraints
A = W[1:]
rhs = -b[1:]
# Solve A x = -b
x, residuals, rank, s = np.linalg.lstsq(A, rhs, rcond=None)
print("rank =", rank)
print("max error =", np.max(np.abs(A @ x - rhs)))
print("raw solution =", x)
ids = np.rint(x).astype(int)
print("ids =", ids.tolist())
flag = "".join(charset[i] for i in ids)
print("flag =", flag)
运行结果为:
rank = 16
max error = 0.0
ids = [15, 1, 10, 16, 2, 0, 2, 6, 12, 7, 15, 10, 1, 6, 6, 6]
flag = f1ag2026c7fa1666
这里 rank = 16 表明矩阵对 16 个未知量具有满列秩,因此该线性系统对输入向量有唯一确定作用。max error = 0.0 说明解正好满足这些校验方程,不是近似碰撞。
10. 验证模型输出
对输入:
f1ag2026c7fa1666
编码后得到:
x = [15, 1, 10, 16, 2, 0, 2, 6, 12, 7, 15, 10, 1, 6, 6, 6]
代入隐藏层:
z0 = 376131.71875
z1 = 0
z2 = 0
...
z20 = 0
经过 ReLU:
h0 = 376131.71875
h1 = h2 = ... = h20 = 0
于是:
logit_success
= h0 - 10^10 * 0 - 376131.21875
= 376131.71875 - 376131.21875
= 0.5
而:
logit_fail = 0.4
因此:
logit_success > logit_fail
模型会输出:
<success>
验证脚本如下:
import torch
from model_def import ICTFForCausalLM, ICTFTokenizer
tokenizer = ICTFTokenizer()
model = ICTFForCausalLM.from_pretrained("./ictf_model")
model.eval()
s = "f1ag2026c7fa1666"
inputs = tokenizer(s, return_tensors="pt")
with torch.no_grad():
outputs = model(inputs["input_ids"])
logits = outputs["logits"]
next_token_id = torch.argmax(logits[:, -1, :], dim=-1).item()
print(tokenizer.id2char[next_token_id])
输出为:
<success>
11. 本题数学原理总结
本题利用神经网络结构伪装了一个线性约束系统。
输入字符串先被 tokenizer 转为整数向量:
x ∈ Z^16
然后模型第一层执行仿射变换:
z = W x + b
ReLU 将每个线性表达式转化为非负惩罚项:
h_i = max(0, W_i · x + b_i)
输出层通过极大负权重惩罚 h1 到 h20:
-10^10 * (h1 + ... + h20)
这迫使输入必须满足:
W_i · x + b_i ≤ 0, i = 1, ..., 20
同时,输出层要求第 0 个隐藏单元足够大:
W0 · x ≥ 64573
因此,看似是一个语言模型,实际是:
整数线性约束 + 阈值判定
而题目构造者进一步把正确输入放在 20 个超平面的交点上:
W_i · x + b_i = 0, i = 1, ..., 20
解出这个超定线性方程组即可得到隐藏的字符 id 向量,再通过 tokenizer 映射回字符串。
12. 最终答案
模型实际接受并验证成功的 16 字符秘密为:
f1ag2026c7fa1666
若需要提交 flag 格式:
flag{f1ag2026c7fa1666}
冰与火的战歌:Windows内核攻防实战高级班!从零到实战,融合AI与Windows内核攻防全技术栈,打造具备自动化能力的内核开发高手。