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

[原创] 看雪·2026 KCTF 第九题:丑寅同墟·星海抉择 WP (AI)

1天前
184

前言

本题由 Claude Opus 5 (xhigh effort) + CC 完成, 用时约 2 分钟

权重里的后门

KCTF · ICTFForCausalLM 题解

flag: f1ag2026c7fa1666

一个只有 1,765 个参数的字符级"语言模型",没有 embedding,没有 attention。
它把 flag 藏在 lm_head 的第 62 行和一组线性不等式里 —— 整道题的本体,
是一道解唯一最优的线性规划。

题目 ICTFForCausalLM
结构 16 → 21 → 64
参数量 1,765
解题依赖 numpy · scipy(不需要 torch

1. 这个模型里没有"语言模型"

model_def.py 的前向传播只有三行有效逻辑,而第一行就足够反常:

def forward(self, input_ids, **kwargs):
    x = input_ids.float()                     # <- 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)
    return {"logits": logits.unsqueeze(1)}

没有 embedding 层。input_ids.float() 直接当成浮点向量喂进全连接层 ——
token id 本身的数值就是特征。于是整个网络退化成 16 维整数格点上的一个分段线性函数:

h      = ReLU(Wd @ x + bd)        # Wd: 21x16   bd: 21
logits = Wl @ h  + bl             # Wl: 64x21   bl: 64
pred   = argmax(logits)

输入是长度 16 的字符串(不足则用 id 0,也就是字符 0 右填充),字符集 62 个,
所以 x ∈ {0,…,61}^16。目标是让 argmax 落在 id 62,也就是 <success>


2. lm_head 的 64 行里,只有一行是活的

model.safetensors 解析开(8 字节小端长度 + JSON 头 + 裸 float32,不装 torch 也能读),
lm_head 的结构一眼就露馅了:

输出 id 权重(21 维) bias 含义
0 – 61 全 0 -10000 普通字符 0-9a-zA-Z
62 [1, -1e10, -1e10, …, -1e10] -376131.21875 <success>
63 全 0 0.4 <fail>

64 行里有 63 行权重全零 —— 它们的 logit 是常数,跟输入毫无关系。
普通字符恒为 −10000,<fail> 恒为 0.4。唯一随输入变化的只有第 62 行。
于是整道题坍缩成一个标量比较:

logit[0..61] = -10000                                   # 常数
logit[63]    = 0.4                                      # 常数, <fail>
logit[62]    = h0 - 1e10 * (h1 + h2 + ... + h20) - 376131.21875

# <success> 胜出  <=>  logit[62] > 0.4

3. 把 argmax 翻译成不等式

ReLU 保证 h >= 0,而 h1…h20 前面挂着 −1e10 的系数。这两件事一撞,条件就被逼死了:
只要任何一个 hj 取到哪怕最小的正数,logit 就会被打到 −1e10 量级,永无翻身之日。于是:

条件 A(可行性) —— 20 个隐藏单元必须被 ReLU 全部压成 0:

W[j] · x + b[j] <= 0        (j = 120)

条件 B(最优性) —— 第 0 个单元必须够大,盖过 <fail> 的 0.4:

h0 = W[0] · x + b[0] > 0.4 + 376131.21875 = 376131.61875

这里已经能闻到线性规划的味道了:20 条线性约束 + 1 个线性目标
出题人把可行性写进了 −1e10,把最优性写进了那个脏兮兮的 bias 常数。


4. 20 个方程,16 个未知数,残差 1.2e−11

先赌一把最干净的可能:条件 A 全部取等dense.weight[1:] 是 20×16 的整数矩阵
(值域 ±100),秩为 16 —— 超定方程组。超定方程组一般无解,但最小二乘一跑,
残差是 1.24 × 10⁻¹¹

A, b = Wd[1:], bd[1:]                  # 20x16, 20
x = np.linalg.lstsq(A, -b, rcond=None)[0]
# residual = 1.2373599e-11
x = np.round(x).astype(int)
# array([15, 1, 10, 16, 2, 0, 2, 6, 12, 7, 15, 10, 1, 6, 6, 6])
assert np.all(A @ x + b == 0)          # 精确成立

完全相容。这说明出题人是先选定 flag、再反算 bias 的。
解出来的 16 个分量全是 0–61 的整数,按字符集 0123456789abc…XYZ 一映射:

[15, 1, 10, 16, 2, 0, 2, 6, 12, 7, 15, 10, 1, 6, 6, 6]
  f  1   a   g  2  0  2  6   c  7   f   a  1  6  6  6

21 个隐藏单元的计算结果

下面 20 行的 Σw·x + b 精确等于 0 —— 不是约等于,是二进制意义上的 0,ReLU 输出全灭。
而 h0 冲到 376131.71875,刚好越过 376131.61875 的门槛,余量只有 0.1

单元 bias Σw·x + b ReLU 输出
h0 311558.71875 376131.71875 376131.71875
h1 -3473 0 0
h2 996 0 0
h3 2471 0 0
h4 2221 0 0
h5 -3293 0 0
h6 -879 0 0
h7 304 0 0
h8 -2018 0 0
h9 2365 0 0
h10 3525 0 0
h11 870 0 0
h12 -1419 0 0
h13 380 0 0
h14 988 0 0
h15 -1404 0 0
h16 -31 0 0
h17 714 0 0
h18 -2790 0 0
h19 673 0 0
h20 -1047 0 0

完整的 21×16 权重矩阵

注意 h0 那一行:数值比其余 20 行大两个数量级 —— 它不是特征提取器,是目标函数。

          [0]   [1]   [2]   [3]   [4]   [5]   [6]   [7]   [8]   [9]  [10]  [11]  [12]  [13]  [14]  [15]
 char       f     1     a     g     2     0     2     6     c     7     f     a     1     6     6     6
 id        15     1    10    16     2     0     2     6    12     7    15    10     1     6     6     6
-------------------------------------------------------------------------------------------------------
 h0      3012  1679    78 -1143  1231  5574  2091  1452 -3472  1990    17  1987   109  3758  1295  -492
 h1        61    23   -13    90    58     0    47    62    -6   -89    67    41    13    64    -8   -71
 h2       -58   -33   -90   -58    44  -100    54    -7   100     9    43    -9    81    -3     2   -52
 h3       -37   -58    19     6   -92    76   -37    56   -20    29   -93   -27    80   -55   -17   -28
 h4       -51    96   -81    12     9    31    21   -91    -6   -90    22   -15   -58    83   -37   -24
 h5        91    37    18   -14   -32    84    86   -70    48    77     3    44    65   100   -21    18
 h6       -47    49   100   -72    10    42    42    67     9   -23    61    35    53    35   -35   -14
 h7        40   -85     4   -12    51    52   -36    -9   -95    68   -95    55   -70   100    22    39
 h8        63    48    25   -69    69    87     5    65    14    52   -50    80   -45   -24    58   100
 h9        38    75   -33   -45    74   100   -50    99   -93   -42   -23   -52    45    14   -19   -57
 h10      -94    84   -38   -15   -57    84    70    50     2   -39   -17   -79     1     1    -8   -95
 h11      -68   -50    24   -89   -36     0    19    22    -1    86    89   -43   -35   -15    60   -74
 h12      -73    18    93    92    12    -4   -28     8   -53    56    34    70    42    36   -99   -92
 h13      -92    -2    43    91    70   -16   -18    51   -87   -73   -28    45    63     1   -32    59
 h14       68   -35   -70    13    -5    89   -41   -35   -98    65   -82    68  -100    54    -4   -18
 h15       79    77   -14   -31    11    20    71   -71   -98     6    20    29   -18    94    95    78
 h16       39   -57    61   -73   -56    49    32    12    10    59   -58    31    52   -44    41     5
 h17       91   -40    -9    18    20   -85   -68    26   -82   -24   -91    60    58    92   -74   -91
 h18       97   -12   -20   -64    45    44    79    59    96    80    58   -61    69   -40    55   -27
 h19      -78   -79    19    12   -13    87   -46    -5   -62    16    49   -25    69    92   -76    54
 h20       68    74   -54    30   -91    86    61    85   -20   -29    75  -100   -41   -91    82    -4

5. 唯一性:这其实是一道线性规划

到这里很容易犯一个错 —— "20 个约束、秩 16、实数解唯一,所以 flag 唯一"。
这个论证是错的。 条件 A 是 <= 0 而不是 = 0,可行域是一个 16 维多面体,不是一个点。
逐坐标跑 LP 量一下它的尺寸:

for i in range(16):
    lo = linprog( e_i, A_ub=A, b_ub=-b, bounds=[(0,61)]*16).fun
    hi = linprog(-e_i, A_ub=A, b_ub=-b, bounds=[(0,61)]*16).fun

# 逐维跨度 max(hi - lo) = 31.145  ->  可行域是实心多面体, 不是单点

最宽的一维跨度有 31.1,多面体里塞着大量整点。真正把解锁死的,是被忽略的条件 B:

步骤
1 W[0] = [3012, 1679, 78, -1143, …] ⊂ ℤ, gcd = 1 目标函数系数全是整数,且互质
2 x ∈ ℤ¹⁶ ⟹ W[0]·x ∈ ℤ 所以目标值只能落在整数上
3 条件 B ⟺ W[0]·x > 64572.9W[0]·x >= 64573 整数性把开区间的门槛顶到 64573(下界
4 max { W[0]·x : Ax+b <= 0, 0 <= x <= 61 } = 64573.0000000001 LP 最优值恰好就是 64573(上界
下界 = 上界 = 64573 ⟹ 解唯一 flag 精确坐在最优顶点上,一步都挪不动

再用 MILP 把 16 个坐标 × 61 个取值共 976 种情况逐一固定做可行性测试,另解数量:0

回头看那两个丑陋的常数就通透了:dense.bias[0] = 311558.71875
lm_head.bias[62] = -376131.21875 不是训练出来的,是手工配的 ——
专门把判定门槛卡在 64572.9,让唯一的整数可行解恰好压线通过。
这个模型从头到尾没"学"过任何东西,它是一台被硬编码的约束求解器。


6. 完整脚本

不需要 torch,也不需要 transformers:

import json, struct, numpy as np

def load(path):
    with open(path, 'rb') as f:
        n    = struct.unpack('<Q', f.read(8))[0]
        hdr  = json.loads(f.read(n))
        blob = f.read()
    out = {}
    for k, v in hdr.items():
        if k == '__metadata__':
            continue
        s, e = v['data_offsets']
        out[k] = np.frombuffer(blob[s:e], dtype=np.float32).reshape(v['shape'])
    return out

W = load('ictf_model/model.safetensors')
Wd, bd, bl = W['dense.weight'], W['dense.bias'], W['lm_head.bias']

A,  b  = Wd[1:], bd[1:]      # 条件 A: 这 20 个单元必须被 ReLU 压成 0
w0, b0 = Wd[0],  bd[0]       # 条件 B: 这个单元必须盖过 <fail>

x = np.round(np.linalg.lstsq(A, -b, rcond=None)[0]).astype(int)
assert np.all(A @ x + b == 0)
assert w0 @ x + b0 > bl[63] - bl[62]

charset = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
print("".join(charset[i] for i in x))      # -> f1ag2026c7fa1666

验证

用 float32 精确复现原模型前向。留意相邻输入的失败方式 ——
h0 未必不够大,往往是条件 A 先崩,两个条件必须同时成立:

输入 输出 h0 原因
f1ag2026c7fa1666 <success> 376131.72 A 全满足,h0 压线 0.1 过关(logit 0.5 vs 0.4)
f1ag2026c7fa1665 <fail> 376623.72 h0 反而更大,但 13 个单元被激活 → A 崩
f1ag2026c7fa1667 <fail> 375639.72 h0 掉到门槛下,且 7 个单元被激活 → A、B 双崩
hello <fail> 393447.72 右填充成 hello00000000000,11 个单元被激活

直接跑官方的 inference.py 也可以:

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

Flag

f1ag2026c7fa1666

冰与火的战歌:Windows内核攻防实战高级班!从零到实战,融合AI与Windows内核攻防全技术栈,打造具备自动化能力的内核开发高手。

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