-
-
KCTF2026: 第七题:戌时·暗能潜流
-
发表于: 1天前 195
-
FLAG = flag{T1u_2026_Kc7f_Crypt0_M4ster!}
本文所有断言均由同目录下的 wp_experiments.py 实测输出支撑。运行方式:
脚本末尾输出 ALL EXPERIMENTS PASSED 表示 4 个样例逐字符复现 + FLAG round-trip 全部通过。
已知 4 组明文/密文样例,以及待解的 FLAG 密文,密钥为 121:
密文长度 = ceil(明文长度 / 3) × 12。3 个字符对应 12 个 hex,即一个字符 4 个 hex(8 bit 的字符被摊成了 4 个半字节,其中只有 2 个携带信息)。
FLAG 密文 144 hex ⇒ 12 个块 ⇒ 明文 34~36 字符。
TLU(唯一的纯满块样例)的密文按位看(实验 0 输出):
把 4 个样例的所有 12-hex 满块对齐后,下标 1, 4, 6, 7, 9, 11 恒为 4,4,5,5,4,5,其余 6 个下标随明文变化。脚本实验 0 用两个内容完全不同的满块逐位取交集,直接实测出该模板:
24-hex 样例的第二块都以固定串开头:Hello/abcd! 的第二块是 2235…,2026 的第二块是 22332235…。这两个前缀与明文内容无关。
模板里 6 个恒定槽位的取值只有 4 和 5 两种。假设算法是「往 12 个半字节槽里写值,再对每个槽加一个固定偏移」,那么恒定槽的原始值必然是同一个常量。取该常量 = 0,则恒定槽的输出值本身就是偏移:
只有 4 和 5 两个值,而 12 个槽分成 3 组、每组 4 个的话,偏移应该以 4 为周期。要让槽 11 拿到 5、槽 1 拿到 4、槽 4 拿到 4、槽 6 拿到 5、槽 7 拿到 5、槽 9 拿到 4 且三组用同一套「格内位置 p → 偏移」的映射,唯一自洽的分组是从槽 11 起、回绕的分组 —— 实验 0【推导 A】输出:
三个格子各自恰好有 2 个空位(值 0)和 2 个数据位。**cell0 与 cell2 的空位在 p=0,2,数据位在 p=1,3;cell1 反过来,空位在 p=1,3,数据位在 p=0,2。**这个「1 号格子和另外两个不一样」的结构,正是密钥 121 中间那位与两边不同的直接体现。
(该分组的唯一性由实验 4 的 shift 穷举反过来确证:12 种分组起点里只有 shift=11 能复现样例。)
以 TLU 为例,把 6 个数据槽减掉各自偏移(实验 0【推导 B】输出):
刚好还原出 T、L、U。于是确认:每个格子存放一个字符,低半字节和高半字节各占一个数据位,另外两个位填 0。
同时暴露出两件事:
Hello 的密文是 34BB405504B5 + 223594B94C53。带固定前缀 2235 的是第二块,说明「不足 3 字符的残块」被放在了密文的末尾;而残块必然来自明文的开头(He)。反推得:
用样例逐一验证:Hello 首块明文 llo、2026 首块 026、abcd! 首块 cd!。
交叉验证一个强证据:2026 的首块密文 A48844556485 与 FLAG 密文的第 8 块(下标 7)完全相同,而 FLAG 中恰好含有子串 026(flag{T1u_2026_…)且对齐到同一块边界。
残块的前面若干个格子整体不用。这些格子里填的不是 0(实验 4 中令 FILL = 0x0,Hello 的密文变成 34BB405504B5445594B94C55,残块前缀变为 4455,与样例不符),实测填的是半字节 0xE:
一个填充格子按位置 p=0..3 产生 3,2,2,3。cell0 的 p=0 落在回绕的槽 11 上,所以那个 3 跑到了整块的最后一位,可见前缀由 p=1,2,3 → 2,2,3 构成(实验 4 输出):
这就是 2235 / 22332235 前缀的完整来源。没有填充字节进入明文,也没有任何链式状态(每个块独立编码,同样的 3 字符在任何位置都产生同样的 12 hex——026 在 2026 和在 FLAG 中密文一致即为证)。
块内 3 个字符按消息序记为 a、b、c,则 12 个槽为:
实验 6 对 855 个块穷举核对该公式与实现完全一致:
密钥的三位数字 不是加法/异或密钥,而是「走线选择器」:第 j 位数字 d 决定第 j 个被使用的格子里,字符的低/高半字节各落在哪两个格内位置:
因为 CELL_OFF = (5,4,4,5),四种取值展开后是:
改一位数字,恰好且仅有那 4 个槽位发生变化,其余 8 个槽一字不变;且 1→2 的改动等价于把这个格子的 4-hex 码整体反转(594A → A495)。
两式完全互为逆序。实测(实验 3)对全部 95 个可打印字符验证:
对照表节选(实验 3 输出,逐行摘录):
reverse(d=1) 列与 d=2 列逐字符相等。所以密钥 1-2-1 的语义是:第 1、3 个格子正着走,第 2 个格子倒着走 —— 一条在迷宫里蛇形(boustrophedon)折返的路径。这正是题名 HexMaze 的含义。
槽 3~6 由 A485 变成 584A —— 同一个字符 T 的 4-hex 码被整体反转,正好把「换向」取消掉了。
槽 7~10 由 5049 变成 9405,同样是 4-hex 码被整体反转(字符 L = 0x4C)。
实验 1 暴力枚举全部 4³ = 64 种 (d0, d1, d2)(每位取 0..3),逐个跑 4 个样例:
只有 (1,2,1) 能全部复现,62 种组合一个样例都命中不了。
逐位穷举(其余两位保持正确):
表中唯一的非零干扰项是「第 1 位取 3 时命中 1/4」。原因可精确解释:d 与 (d+2) mod 4 只是把 hi/lo 对调,当写入该格子的所有字符恰好满足 hi == lo 时,d=1 与 d=3 不可区分。实测逐样例核对:
'U' = 0x55 是唯一的 hi==lo 字符,所以只有 TLU 会被 321 蒙对;只要再加任何一个样例,d0=3 立刻被排除。样例集足以把密钥的每一位唯一钉死。
密钥之外还有 5 项结构约定(块序倒置、CELL_OFF、FILL、块内左旋、格子分组起点)。实验 4 / 实验 5 证明它们同样被样例唯一钉死:
块序倒置(必需)
CELL_OFF = (5,4,4,5)(16⁴ 全穷举唯一)
FILL = 0xE(16 种全穷举唯一)
块内左旋 1 格(0/1/2 穷举唯一)
格子分组必须回绕(12 种起点穷举唯一)
联合唯一性(避免「一个参数错了另一个参数补回来」的可能)——同时枚举 KEY(64) × 分组起点(12) × 旋转(3) = 2304 种组合:
对 144-hex 密文解密,得到 12 个块(实验 7 输出):
第 7 块与样例 2026 的首块密文逐字符相同,是「每块独立编码、无链式状态」的直接证据。
验证(实验 7):
34 个字符全部可打印,长度与密文长度自洽,重新加密逐字符等于原密文。
wp_experiments.py —— 参数化实现 + 7 组消融/唯一性实验,直接 python3 wp_experiments.py 运行(约 0.5 秒),本文引用的所有输出片段均来自该脚本的真实输出。
| 明文 | 长度 | 密文 | 密文长度 |
|---|---|---|---|
TLU |
3 | 94AA48550495 |
12 |
Hello |
5 | 34BB405504B5223594B94C53 |
24 |
2026 |
4 | A48844556485223322356483 |
24 |
abcd! |
5 | 547B475584B5223564BB4553 |
24 |
| FLAG | ? | 14CC4655…5A4B3(144 hex) |
144 |
| 槽位 | 1 | 4 | 6 | 7 | 9 | 11 |
|---|---|---|---|---|---|---|
| 恒定输出 | 4 | 4 | 5 | 5 | 4 | 5 |
| d | 格内布局 (p=0,1,2,3) | 加偏移后的 4-hex 码 |
|---|---|---|
| 0 | lo, 0, hi, 0 | lo+5 | '4' | hi+4 | '5' |
| 1 | 0, lo, 0, hi | '5' | lo+4 | '4' | hi+5 |
| 2 | hi, 0, lo, 0 | hi+5 | '4' | lo+4 | '5' |
| 3 | 0, hi, 0, lo | '5' | hi+4 | '4' | lo+5 |
| 密钥位 | 值 | 决定的对象 | 满块中控制的 hex 槽 | 残块 k=2 | 残块 k=1 | 语义 |
|---|---|---|---|---|---|---|
| 第 1 位 | 1 |
第 0 个被使用的格子 | 0, 1, 2, 11(回绕) | 3,4,5,6 | 7,8,9,10 | 正向走线:'5' | lo+4 | '4' | hi+5 |
| 第 2 位 | 2 |
第 1 个被使用的格子 | 3, 4, 5, 6 | 7,8,9,10 | 不参与 | 反向走线(第 1 位的逆序):hi+5 | '4' | lo+4 | '5' |
| 第 3 位 | 1 |
第 2 个被使用的格子 | 7, 8, 9, 10 | 不参与 | 不参与 | 正向走线,与第 1 位相同 |
| 实验 | 内容 |
|---|---|
| 0 | 基准:4 样例逐字符复现 + 实测满块模板 |
| 1 | 密钥 4³=64 组合暴力,唯一解 (1,2,1) |
| 2 | 逐位消融 121→221/111/122,定位每位控制的 hex 槽位 |
| 3 | d=1 与 d=2 的 4-hex 码互为逆序(95 字符全验证) |
| 4 | 块序 / CELL_OFF(16⁴) / FILL(16) / 旋转(3) / 分组起点(12) 的必要性与唯一性 |
| 5 | 联合唯一性 KEY×分组×旋转 = 2304 组合,唯一解 |
| 6 | 满块展开公式与实现的 855 块穷举核对 |
| 7 | 最终断言:4 样例 + FLAG round-trip |
python3 wp_experiments.py
密文 94AA48550495 下标 0 1 2 3 4 5 6 7 8 9 10 11
值 9 4 A A 4 8 5 5 0 4 9 5
满块模板 (a,b,c 三个字符): ?4??4?55?4?5
-> 与题面观察到的 '?4??4?55?4?5' 一致
【推导 A】恒定槽的取值就是偏移 CELL_OFF
恒定槽位 = [1, 4, 6, 7, 9, 11],取值 = ['4', '4', '5', '5', '4', '5']
cell0 = 槽(11, 0, 1, 2) 恒定位: 槽11(p=0)='5', 槽1(p=2)='4'
cell1 = 槽(3, 4, 5, 6) 恒定位: 槽4(p=1)='4', 槽6(p=3)='5'
cell2 = 槽(7, 8, 9, 10) 恒定位: 槽7(p=0)='5', 槽9(p=2)='4'
-> 每格恰好 2 个恒定位,且格内位置 p 与取值一一对应: CELL_OFF = (5, 4, 4, 5)
【推导 B】'TLU' 的 6 个数据槽减掉偏移即还原出 T/L/U
密文 94AA48550495 下标 0 1 2 3 4 5 6 7 8 9 10 11
值 9 4 A A 4 8 5 5 0 4 9 5
lo(c) = hex[0]='9' - 4 = 5
hi(c) = hex[2]='A' - 5 = 5
hi(a) = hex[3]='A' - 5 = 5
lo(a) = hex[5]='8' - 4 = 4
lo(b) = hex[8]='0' - 4 = C
hi(b) = hex[10]='9' - 5 = 4
-> a=0x54='T' b=0x4C='L' c=0x55='U' 拼回 = 'TLU'
cell0 存的是块内第 3 个字符, cell1 存第 1 个, cell2 存第 2 个 -> 块内字符左旋一格
0xE + CELL_OFF = 0xE + (5,4,4,5) = (3,2,2,3)
残块前缀由来:一个被填充的格子按位置 p=0..3 产生 0xE+CELL_OFF = [3, 2, 2, 3] = 3223
该格子的 p=0 落在**回绕**的槽位上,所以 cell0 的 '3' 跑到了整块的最后一位 (槽11),
可见的前缀由 p=1,2,3 -> '2','2','3' 组成:
k=2 残块 ("He"): 223594B94C53 槽0..2 = '223' (填充) + 槽3 = '5' (第 1 位 d=1 的 0 值位) + 槽11 = '3'
k=1 残块 ("2") : 223322356483 槽0..6 = '2233223' (两个填充格) + 槽7 = '5' + 槽11 = '3'
参数:KEY = (1, 2, 1) # 密钥 121 的三位数字
CELL_OFF = (5, 4, 4, 5) # 格内位置 p=0..3 的固定加法偏移(模 16)
CELL_SLOTS = [(11,0,1,2), (3,4,5,6), (7,8,9,10)] # 3 个格子占用的 hex 槽(回绕)
FILL = 0xE # 未使用格子的填充半字节
槽0 = lo(c)+4 槽1 = 0+4 槽2 = hi(c)+5 槽3 = hi(a)+5
槽4 = 0+4 槽5 = lo(a)+4 槽6 = 0+5 槽7 = 0+5
槽8 = lo(b)+4 槽9 = 0+4 槽10 = hi(b)+5 槽11 = 0+5
'TLU' 公式 94AA48550495 实现 94AA48550495 一致
'llo' 公式 34BB405504B5 实现 34BB405504B5 一致
'026' 公式 A48844556485 实现 A48844556485 一致
'cd!' 公式 547B475584B5 实现 547B475584B5 一致
对 95x3x3 = 855 个块穷举核对: True
低半字节 -> 格内位置 d
高半字节 -> 格内位置 (d + 2) mod 4
其余两个位置 -> 0
121 -> 221 (只改第 1 位)
第一个不匹配的样例: 'TLU'
期望 = 94AA48550495
得到 = 495A4855049A
标记 = ^^^ ^
首个不匹配位置 = hex[0],全部不匹配位置 = [0, 1, 2, 11]
第 1 位数字在 k=3 满块中控制的槽位: [0, 1, 2, 11] (= 第 0 号格子 (11, 0, 1, 2))
在 k=2 残块中控制的槽位: [3, 4, 5, 6]
在 k=1 残块中控制的槽位: [7, 8, 9, 10]
'TLU' 中第 0 号格子(槽(11, 0, 1, 2)) 的 4-hex 码: '594A' -> 'A495' 互为逆序? True
d=1: 位置(1)=lo, 位置(3)=hi -> [0+5, lo+4, 0+4, hi+5] = '5' | lo+4 | '4' | hi+5
d=2: 位置(2)=lo, 位置(0)=hi -> [hi+5, 0+4, lo+4, 0+5] = hi+5 | '4' | lo+4 | '5'