首页
社区
课程
招聘
[原创] 看雪·2026 KCTF 第九题:丑寅同墟·星海抉择
发表于: 2天前 12

[原创] 看雪·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 是一个极大的惩罚系数,只要 h1h20 中有任意一个为正,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

h1h20 全部为 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

由于 W0x 都是整数,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)

输出层通过极大负权重惩罚 h1h20

-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内核攻防全技术栈,打造具备自动化能力的内核开发高手。

最后于 2天前 被mb_mgodlfyn编辑 ,原因:
收藏
免费 0
打赏
分享
最新回复 (0)
游客
登录 | 注册 方可回帖
返回