
8 月 10 日 12:00,2026 KCTF 攻防对抗赛正式开赛。本届赛事沿用多维度积分体系,难度、火力、精致度三重积分同步核算,攻防双向计分机制兼顾趣味性与竞技性。
其中,签到题《辰时·钟鸣破晓》在赛事周期内持续开放,比赛全程都可以提交 flag 获取对应攻击积分。
截止今天中午 12 点,第七题《戌时·暗能潜流》答题通道已关闭。本题是一道 Crypto 密码题,围观人数 2148,共 29 支战队成功提交 flag。『SROokie』战队以 1 小时 08 分 18 秒拿下「一血」,『唐完了就是宋』战队、『您的名称过长』战队紧随其后,分别用时 1 小时 8 分 46 秒、2 小时 17 分 27 秒。题目整体难度偏高,各路战队在密码逻辑上展开了激烈博弈。

本题 KCTF 组委会点评:
HexMaze 是一道设计思路较为完整的自定义 CTF 密码题。题目没有依赖随机参数,而是通过固定编码、模 16 变换、结构扰动等多个可逆步骤,构造出具有分析价值的密码流程。
题目难度控制合理,既要求选手具备从密文特征中发现规律的能力,也考察对已知明文分析、算法拆解和自动化验证的综合能力。密钥 121 的设计增加了题目的解释空间,使选手不仅需要得到 FLAG,还需要理解参数在算法中的实际作用。
整体来看,HexMaze 兼顾了趣味性、可解性和分析深度,是一道符合 CTF 精神的密码分析题。
一起来看看本题的设计思路与解题思路。
一、设计思路
出题战队:FUA_KCTF
战队成员 ID:FUA陈探乐、mb_pdwoeqop

在 CTF 比赛中,Crypto 题的设计一直存在一个平衡问题:如果算法过于简单,选手可能凭借经验直接套用已有模板;如果算法完全依赖复杂数学结构,又容易变成黑盒猜解或者纯暴力分析。
因此,在设计 2026 KCTF Crypto · HexMaze 时,我们希望构造一道偏向「密码分析」的自定义算法题:
- 算法完全确定,不依赖随机因素;
- 选手可以通过公开样例逐步分析;
- 加密流程包含多个可逆步骤;
- 最终解题过程需要理解算法结构,而不是依靠猜测 FLAG。
HexMaze 的核心设计理念是:
将多个简单、可解释的可逆变换组合起来,让选手从 HEX 数据中寻找隐藏规律。
一、题目定位
HexMaze 定位为:
Crypto / 自定义密码分析
考察方向包括:
- 输入输出长度关系分析;
- 字节编码规律发现;
- 固定变换逆向;
- 自定义密钥语义分析;
- 编写自动化解密脚本。
题目并不希望考察复杂密码算法,而是希望选手具备:
- 从密文特征中寻找结构;
- 从已知明文推导规则;
- 对可逆流程进行拆解。
设计说明中将主要考察点定义为:
长度关系与明文编码方式观察、双层结构识别、base 移位差分还原、后处理逆向、统一密钥 121 的语义解释。
二、设计原则
1. 不引入随机因素
为了保证题目的分析价值,HexMaze 不使用:
相同输入在相同密钥下,始终产生唯一结果。这样选手可以利用已知明文、密文差分、重复结构逐步恢复算法。
题面向选手明确说明:
相同输入和密钥一定产生唯一输出。
2. 算法必须可逆
CTF Crypto 题的重点不是制造不可破解的系统,而是设计一个:
看似复杂,但可以通过分析还原的变换流程。
因此 HexMaze 中所有步骤均保持可逆:
明文
|
v
字符编码
|
v
HEX 位变换
|
v
固定结构扰动
|
v
密文
选手最终可以通过逆向每一步恢复原始明文。
三、整体算法设计
HexMaze 使用双层结构。
第一层:
Base 编码层
每个字符独立处理。字符首先转换为固定长度 HEX:
例如:
T
对应 ASCII:
0x54
扩展:
0054
随后进行模 16 位移:
[1,2,1,2]
即:
第1位 +1
第2位 +2
第3位 +1
第4位 +2
该设计保证:
- 每个字符都有固定结构;
- 已知明文可以直接差分分析;
- 不需要复杂数学工具。
设计文档中将该层定义为:
Unicode 代码点转 4 位 HEX,再进行逐位 +k mod 16 变换。
四、为什么加入多层扰动?
如果只保留 Base 层:
字符 -> 4位HEX
那么选手可以快速建立字符映射,题目难度不足。
因此增加第二层:
固定扰动层
包含:
- 固定摘要扰动;
- 数据交错;
- 顺序变化;
- 固定位置重排。
目的不是提供密码学安全性,而是增加分析路径。
选手需要先观察:
再恢复内部逻辑。
设计中的后处理流程包括:
摘要扰动、颠倒提取、双移位、填充、位置置换等步骤。
五、密钥 121 的设计
HexMaze 的密钥:
121
不是传统意义上的大整数密码密钥。它被设计为三个控制参数:
1 2 1
分别参与算法流程。
设计目标:让选手不仅需要恢复 FLAG,还需要回答:
为什么密钥是 121?
而不是简单把密钥作为一个固定常量。
官方设计说明中:
统一密钥 121 的三位数字需要在 Writeup 中解释其作用。
六、已知样例设计
题目提供四组明文/密文:

样例覆盖:
设计目标是让选手可以通过差分逐步恢复规则,而不是依靠枚举。
七、难度控制
HexMaze 希望处于:
有经验 CTF 选手可以分析,初学者也能通过观察获得突破。
预期解题路径:
第一阶段:观察
发现:
- 密文只包含 HEX;
- 长度存在规律;
- 输入字符与输出存在固定关系。
第二阶段:推导
恢复:
第三阶段:实现
编写脚本:
八、设计总结
HexMaze 并不是为了模拟真实世界中的安全密码系统,而是设计一道适合 CTF 的分析型 Crypto 题。
它希望考察的是:
- 发现规律的能力;
- 拆解复杂流程的能力;
- 从有限信息恢复完整规则的能力。
一个好的 CTF Crypto 题,不应该只是让选手面对无法理解的黑盒,而应该让选手在分析过程中不断获得反馈:
从 HEX 中发现结构;从结构中恢复算法;从算法中得到答案。
这也是 HexMaze 设计时希望带给选手的体验。
后续文章将继续介绍选手视角下的完整分析过程,包括如何从四组已知明文中恢复编码结构、推导密钥 121 含义,并最终还原 FLAG。
二、赛题解析
该解题思路由论坛会员 『Magic丶』 提供。


FLAG = flag{T1u_2026_Kc7f_Crypt0_M4ster!}
本文所有断言均由同目录下的 wp_experiments.py 实测输出支撑。运行方式:
python3 wp_experiments.py
脚本末尾输出 ALL EXPERIMENTS PASSED 表示 4 个样例逐字符复现 + FLAG round-trip 全部通过。
1. 题目信息与观察
已知 4 组明文/密文样例,以及待解的 FLAG 密文,密钥为 121:

观察 1:长度规律
密文长度 = ceil(明文长度 / 3) × 12。3 个字符对应 12 个 hex,即一个字符 4 个 hex(8 bit 的字符被摊成了 4 个半字节,其中只有 2 个携带信息)。
FLAG 密文 144 hex ⇒ 12 个块 ⇒ 明文 34~36 字符。
观察 2:满块模板
TLU(唯一的纯满块样例)的密文按位看(实验 0 输出):
密文 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
把 4 个样例的所有 12-hex 满块对齐后,下标 1, 4, 6, 7, 9, 11 恒为 4,4,5,5,4,5,其余 6 个下标随明文变化。脚本实验 0 用两个内容完全不同的满块逐位取交集,直接实测出该模板:
满块模板 (a,b,c 三个字符): ?4??4?55?4?5
-> 与题面观察到的 '?4??4?55?4?5' 一致
观察 3:残块前缀
24-hex 样例的第二块都以固定串开头:Hello/abcd! 的第二块是 2235…,2026 的第二块是 22332235…。这两个前缀与明文内容无关。
2. 分析思路
2.1 从模板推出「空槽 + 定值偏移」
模板里 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】输出:
【推导 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)
三个格子各自恰好有 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 能复现样例。)
2.2 数据位装的是什么
以 TLU 为例,把 6 个数据槽减掉各自偏移(实验 0【推导 B】输出):
【推导 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 个 -> 块内字符左旋一格
刚好还原出 T、L、U。于是确认:每个格子存放一个字符,低半字节和高半字节各占一个数据位,另外两个位填 0。
同时暴露出两件事:
- 块内字符左旋一格:cell0 存的是块内第 3 个字符
U,cell1 存第 1 个 T,cell2 存第 2 个 L。即块内第 i 个字符进第 (i+1)%k 个被使用的格子。
- 偏移与半字节种类的关系:低半字节落在偏移 +4 的位置,高半字节落在偏移 +5 的位置。
2.3 块序倒置
Hello 的密文是 34BB405504B5 + 223594B94C53。带固定前缀 2235 的是第二块,说明「不足 3 字符的残块」被放在了密文的末尾;而残块必然来自明文的开头(He)。反推得:
- 明文从末尾每 3 字符切一块,剩下的 1~2 字符构成最前面的残块;
- 密文块的输出顺序是块序整体倒置——第 1 个密文块编码消息的最后 3 个字符。
用样例逐一验证:Hello 首块明文 llo、2026 首块 026、abcd! 首块 cd!。
交叉验证一个强证据:2026 的首块密文 A48844556485 与 FLAG 密文的第 8 块(下标 7)完全相同,而 FLAG 中恰好含有子串 026(flag{T1u_2026_…)且对齐到同一块边界。
2.4 残块的填充
残块的前面若干个格子整体不用。这些格子里填的不是 0(实验 4 中令 FILL = 0x0,Hello 的密文变成 34BB405504B5445594B94C55,残块前缀变为 4455,与样例不符),实测填的是半字节 0xE:
0xE + CELL_OFF = 0xE + (5,4,4,5) = (3,2,2,3)
一个填充格子按位置 p=0..3 产生 3,2,2,3。cell0 的 p=0 落在回绕的槽 11 上,所以那个 3 跑到了整块的最后一位,可见前缀由 p=1,2,3 → 2,2,3 构成(实验 4 输出):
残块前缀由来:一个被填充的格子按位置 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'
这就是 2235 / 22332235 前缀的完整来源。没有填充字节进入明文,也没有任何链式状态(每个块独立编码,同样的 3 字符在任何位置都产生同样的 12 hex——026 在 2026 和在 FLAG 中密文一致即为证)。
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 # 未使用格子的填充半字节
- 分块:把明文从末尾每 3 字符切一块;不足 3 的残块留在最前面。例:
Hello → 消息序 ["He", "llo"]。
- 块序倒置:按
["llo", "He"] 的顺序逐块编码并拼接。
- 编码一个块(块内 k 个字符,k ∈ {1,2,3}):
- 取字符
ch = block[(j-1) mod k](块内字符左旋一格);
- 取密钥数字
d = KEY[j];
- 格内 4 个位置初始化为 0,然后
位置 d ← ch 的低半字节,位置 (d+2) mod 4 ← ch 的高半字节。
- 准备 12 个半字节槽。
- 前
3-k 个格子不使用,它们的每个槽写入 FILL = 0xE。
- 对第 j 个被使用的格子(j = 0..k-1,实际格子编号为
j + (3-k)):
- 每个槽的最终值 =
(半字节 + CELL_OFF[格内位置]) mod 16,按 0..11 输出为大写 hex。
满块展开公式
块内 3 个字符按消息序记为 a、b、c,则 12 个槽为:
槽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
实验 6 对 855 个块穷举核对该公式与实现完全一致:
'TLU' 公式 94AA48550495 实现 94AA48550495 一致
'llo' 公式 34BB405504B5 实现 34BB405504B5 一致
'026' 公式 A48844556485 实现 A48844556485 一致
'cd!' 公式 547B475584B5 实现 547B475584B5 一致
对 95x3x3 = 855 个块穷举核对: True
4. 密钥 121 中每个数字的作用(重点)
密钥的三位数字不是加法/异或密钥,而是「走线选择器」:第 j 位数字 d 决定第 j 个被使用的格子里,字符的低/高半字节各落在哪两个格内位置:
低半字节 -> 格内位置 d
高半字节 -> 格内位置 (d + 2) mod 4
其余两个位置 -> 0
因为 CELL_OFF = (5,4,4,5),四种取值展开后是:

4.1 第 1 位「1」——第 0 号格子走 (1,3) 车道
- 控制范围:满块中的槽位
[0, 1, 2, 11],也就是回绕格子 cell0 = (11,0,1,2);在 k=2 残块中控制槽 [3,4,5,6],在 k=1 残块中控制槽 [7,8,9,10](残块里第 0 个被使用的格子往后挪)。
- 具体作用:把该格子承载的字符(满块中是块内的第 3 个字符,因为左旋)的低半字节写到
槽0、高半字节写到 槽2,槽1 和 槽11 留 0 → 输出常量 '4'(0+CELL_OFF[2])和 '5'(0+CELL_OFF[0])。这就是满块模板 ?4??4?55?4?5 中槽1 恒为 4、槽11 恒为 5 的来源。
- 消融证据(实验 2,把 121 改成 221):
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
改一位数字,恰好且仅有那 4 个槽位发生变化,其余 8 个槽一字不变;且 1→2 的改动等价于把这个格子的 4-hex 码整体反转(594A → A495)。
4.2 第 2 位「2」——第 1 号格子换向(迷宫蛇形)
- 控制范围:满块中的槽位
[3, 4, 5, 6](cell1);在 k=2 残块中控制槽 [7,8,9,10];k=1 残块只有一个格子,第 2 位不参与。
- 具体作用:
d=2 让高半字节落在 槽3、低半字节落在 槽5,空位在 槽4(→'4') 和 槽6(→'5')。注意这和 d=1 的顺序正好相反:d=1 是「空、低、空、高」,d=2 是「高、空、低、空」。
- 为什么是「换向」而不是别的:因为
CELL_OFF = (5,4,4,5) 是回文,且 d 与 3-d 关于格心对称,所以:
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'
两式完全互为逆序。实测(实验 3)对全部 95 个可打印字符验证:
对全部 95 个可打印字符验证: code(d=1)[::-1] == code(d=2) -> True
对全部 95 个可打印字符验证: code(d=0)[::-1] == code(d=3) -> True
对照表节选(实验 3 输出,逐行摘录):
字符 hi lo | d=0 d=1 d=2 d=3 | reverse(d=1) reverse(d=0)
----------------------------------------------------------------------------
'T' 5 4 | 9495 584A A485 5949 | A485 5949
'L' 4 C | 1485 5049 9405 5841 | 9405 5841
'H' 4 8 | D485 5C49 94C5 584D | 94C5 584D
'e' 6 5 | A4A5 594B B495 5A4A | B495 5A4A
'!' 2 1 | 6465 5547 7455 5646 | 7455 5646
reverse(d=1) 列与 d=2 列逐字符相等。所以密钥 1-2-1 的语义是:第 1、3 个格子正着走,第 2 个格子倒着走——一条在迷宫里蛇形(boustrophedon)折返的路径。这正是题名 HexMaze 的含义。
- 消融证据(把 121 改成 111,即取消换向):
121 -> 111 (只改第 2 位)
第一个不匹配的样例: 'TLU'
期望 = 94AA48550495
得到 = 94A584A50495
标记 = ^^^^
首个不匹配位置 = hex[3],全部不匹配位置 = [3, 4, 5, 6]
第 2 位数字在 k=3 满块中控制的槽位: [3, 4, 5, 6] (= 第 1 号格子 (3, 4, 5, 6))
在 k=2 残块中控制的槽位: [7, 8, 9, 10]
在 k=1 残块中控制的槽位: (不参与)
'TLU' 中第 1 号格子(槽(3, 4, 5, 6)) 的 4-hex 码: 'A485' -> '584A' 互为逆序? True
槽 3~6 由 A485 变成 584A——同一个字符 T 的 4-hex 码被整体反转,正好把「换向」取消掉了。
4.3 第 3 位「1」——第 2 号格子又走回 (1,3) 车道
- 控制范围:满块中的槽位
[7, 8, 9, 10](cell2)。它只在满块中出现——k=1 和 k=2 的残块里第 3 个格子不会被使用,所以第 3 位数字对残块毫无影响。
- 具体作用:低半字节 →
槽8,高半字节 → 槽10,空位 槽7(→'5')、槽9(→'4'),构成模板尾部的 5?4?。
- 消融证据:
121 -> 122 (只改第 3 位)
第一个不匹配的样例: 'TLU'
期望 = 94AA48550495
得到 = 94AA48594055
标记 = ^^^^
首个不匹配位置 = hex[7],全部不匹配位置 = [7, 8, 9, 10]
第 3 位数字在 k=3 满块中控制的槽位: [7, 8, 9, 10] (= 第 2 号格子 (7, 8, 9, 10))
在 k=2 残块中控制的槽位: (不参与)
在 k=1 残块中控制的槽位: (不参与)
'TLU' 中第 2 号格子(槽(7, 8, 9, 10)) 的 4-hex 码: '5049' -> '9405' 互为逆序? True
槽 7~10 由 5049 变成 9405,同样是 4-hex 码被整体反转(字符 L = 0x4C)。
4.4 三位数字联合唯一性
实验 1 暴力枚举全部 4³ = 64 种 (d0, d1, d2)(每位取 0..3),逐个跑 4 个样例:
共测试 64 种组合;能同时复现全部 4 个样例的组合:
KEY = (1, 2, 1) -> 121
结论:唯一解 = [(1, 2, 1)] (即密钥 121)
命中样例数分布 (命中数: 组合数) = {0: 62, 1: 1, 4: 1}
命中 3 个样例的组合: []
只有 (1,2,1) 能全部复现,62 种组合一个样例都命中不了。
逐位穷举(其余两位保持正确):
逐位穷举(其余两位保持正确),表内数字 = 命中的样例数 / 4:
位置 d=0 d=1 d=2 d=3
第1位 0/4 *4/4 0/4 1/4
第2位 0/4 0/4 *4/4 0/4
第3位 0/4 *4/4 0/4 0/4
(*) 标记的是正确取值;可见每一位都只有一个取值能命中 4/4
表中唯一的非零干扰项是「第 1 位取 3 时命中 1/4」。原因可精确解释:d 与 (d+2) mod 4 只是把 hi/lo 对调,当写入该格子的所有字符恰好满足 hi == lo 时,d=1 与 d=3 不可区分。实测逐样例核对:
'TLU' 受第 1 位支配的字符 = 'U'(hi=5,lo=5) 全部 hi==lo? True KEY=321 命中? True
'Hello' 受第 1 位支配的字符 = 'o'(hi=6,lo=F) 'e'(hi=6,lo=5) 全部 hi==lo? False KEY=321 命中? False
'2026' 受第 1 位支配的字符 = '6'(hi=3,lo=6) '2'(hi=3,lo=2) 全部 hi==lo? False KEY=321 命中? False
'abcd!' 受第 1 位支配的字符 = '!'(hi=2,lo=1) 'b'(hi=6,lo=2) 全部 hi==lo? False KEY=321 命中? False
'U' = 0x55 是唯一的 hi==lo 字符,所以只有 TLU 会被 321 蒙对;只要再加任何一个样例,d0=3 立刻被排除。样例集足以把密钥的每一位唯一钉死。
4.5 各位数字的作用速查

5. 非密钥结构常量的必要性
密钥之外还有 5 项结构约定(块序倒置、CELL_OFF、FILL、块内左旋、格子分组起点)。实验 4 / 实验 5 证明它们同样被样例唯一钉死:
块序倒置(必需)
[复现失败] 块序不倒置(按消息顺序输出密文块) -> 首个失败样例 'Hello': 期望 34BB405504B5223594B94C53 / 得到 223594B94C5334BB405504B5,首个不匹配 hex[0]
[复现失败] 从头每 3 字符切块(残块在末尾) -> 首个失败样例 'Hello': 期望 34BB405504B5223594B94C53 / 得到 223534BB405304B94C5594B5,首个不匹配 hex[0]
CELL_OFF = (5,4,4,5)(16⁴ 全穷举唯一)
CELL_OFF 全穷举 16^4 = 65536 种,能复现 4 个样例的只有:[(5, 4, 4, 5)]
[复现失败] CELL_OFF = (0, 0, 0, 0) -> 'TLU' 得到 50550400C040,首个不匹配 hex[0]
[复现失败] CELL_OFF = (4, 4, 4, 4) -> 'TLU' 得到 949948440484,首个不匹配 hex[2]
[复现失败] CELL_OFF = (5, 5, 5, 5) -> 'TLU' 得到 A5AA59551595,首个不匹配 hex[0]
[复现失败] CELL_OFF = (4, 5, 5, 4) -> 'TLU' 得到 A59959441584,首个不匹配 hex[0]
[复现失败] CELL_OFF = (5, 4, 5, 4) -> 'TLU' 得到 959A49450585,首个不匹配 hex[1]
FILL = 0xE(16 种全穷举唯一)
FILL 全穷举 0x0..0xF,能复现 4 个样例的只有:['0xe']
[复现失败] FILL = 0x0 -> 'Hello' 得到 34BB405504B5445594B94C55,首个不匹配 hex[12] (残块前缀变了)
[复现失败] FILL = 0xd -> 'Hello' 得到 34BB405504B5112594B94C52,首个不匹配 hex[12] (残块前缀变了)
[复现失败] FILL = 0xf -> 'Hello' 得到 34BB405504B5334594B94C54,首个不匹配 hex[12] (残块前缀变了)
块内左旋 1 格(0/1/2 穷举唯一)
[复现失败] 块内旋转 rot=0(正确值 rot=1) -> 'TLU' 期望 94AA48550495 / 得到 84A9405594A5,首个不匹配 hex[0]
[复现失败] 块内旋转 rot=2(正确值 rot=1) -> 'TLU' 期望 94AA48550495 / 得到 049A495584A5,首个不匹配 hex[0]
rot 穷举 0/1/2,能复现的只有:[1]
格子分组必须回绕(12 种起点穷举唯一)
格子分组起点 shift 穷举 0..11,能复现的只有:[11] -> cell = [(11, 0, 1, 2), (3, 4, 5, 6), (7, 8, 9, 10)]
[复现失败] 分组不回绕 (0,1,2,3)/(4,5,6,7)/(8,9,10,11) -> 'TLU' 期望 94AA48550495 / 得到 594AA4855049,首个不匹配 hex[0]
联合唯一性(避免「一个参数错了另一个参数补回来」的可能)——同时枚举 KEY(64) × 分组起点(12) × 旋转(3) = 2304 种组合:
能同时复现 4 个样例的组合共 1 种:
KEY=121 shift=11 (cell=[(11, 0, 1, 2), (3, 4, 5, 6), (7, 8, 9, 10)]) rot=1
6. 解密流程
- 把密文按 12 hex 切块,块序整体倒置(最后一个密文块是明文的开头)。
- 对每个 12-hex 块:
- 按
CELL_SLOTS 分成 3 个格子,每个槽减掉对应的 CELL_OFF[p](模 16)得到原始半字节。
- 四个半字节全部等于
0xE 的格子是未使用的填充格;剩下的就是被使用的格子,个数记为 k。
'abc' -> 74BB455564B5 减偏移后各格 = ['0306', '6010', '0206'] 被使用的格子 = [0, 1, 2] -> k=3
'He' -> 223594B94C53 减偏移后各格 = ['EEEE', '0506', '4080'] 被使用的格子 = [1, 2] -> k=2
'2' -> 223322356483 减偏移后各格 = ['EEEE', 'EEEE', '0203'] 被使用的格子 = [2] -> k=1
- 对第 j 个被使用的格子(j=0..k-1),取密钥数字
d = KEY[j],字符 = chr(格内位置[(d+2)%4] << 4 | 格内位置[d]),放回块内的第 (j-1) mod k 个位置(撤销左旋)。
- 把所有块的明文按倒置后的顺序拼起来。
7. FLAG
对 144-hex 密文解密,得到 12 个块(实验 7 输出):
FLAG 逐块还原(密文块顺序 = 消息块倒序):
密文块# 密文块 -> 明文块
0 14CC46555475 'r!}'
1 94BC475584C5 'ste'
2 848A43551495 '_M4'
3 448C445584C5 'pt0'
4 D4C9475564C5 'Cry'
5 34A84B55A4B5 '7f_'
6 74BA4355F495 '_Kc'
7 A48844556485 '026' <- 与样例 '2026' 的首块密文完全相同
8 648C495534A5 'u_2'
9 548C4F5584A5 '{T1'
10 B4BB405554B5 'lag'
11 22332235A4B3 'f' <- k=1 残块,前缀 '22332235'
倒序拼接: f + lag + {T1 + u_2 + 026 + _Kc + 7f_ + Cry + pt0 + _M4 + ste + r!}
第 7 块与样例 2026 的首块密文逐字符相同,是「每块独立编码、无链式状态」的直接证据。
FLAG = flag{T1u_2026_Kc7f_Crypt0_M4ster!}
验证(实验 7):
OK 'TLU' enc-> 94AA48550495 dec-> 'TLU'
OK 'Hello' enc-> 34BB405504B5223594B94C53 dec-> 'Hello'
OK '2026' enc-> A48844556485223322356483 dec-> '2026'
OK 'abcd!' enc-> 547B475584B5223564BB4553 dec-> 'abcd!'
FLAG = flag{T1u_2026_Kc7f_Crypt0_M4ster!}
长度 = 34 字符 -> ceil(34/3) = 12 个块 -> 144 hex = 密文长度 144 一致
全可打印 = True
re-encrypt = True
34 个字符全部可打印,长度与密文长度自洽,重新加密逐字符等于原密文。
附录 A:完整 encrypt / decrypt 代码
#!/usr/bin/env python3
"""KCTF07 HexMaze - key 121"""
KEY = (1, 2, 1) # 密钥 121
CELL_OFF = (5, 4, 4, 5) # 格内位置 p=0..3 的固定偏移
CELL_SLOTS = [(11, 0, 1, 2), (3, 4, 5, 6), (7, 8, 9, 10)] # 回绕分组
FILL = 0xE # 未使用格子的填充半字节
def split_blocks(msg):
"""消息顺序的分块;残块(1~2 字符)在最前面。"""
cut = len(msg) % 3
return ([msg[:cut]] if cut else []) + [msg[i:i + 3] for i in range(cut, len(msg), 3)]
def enc_block(chars):
k = len(chars)
out = [None] * 12
for j in range(3 - k): # 未使用的格子在前面,填 0xE
for p, s in enumerate(CELL_SLOTS[j]):
out[s] = (FILL + CELL_OFF[p]) & 0xF
for j in range(k): # 第 j 个"被使用"的格子
ch = ord(chars[(j - 1) % k]) # 块内字符左旋一格
d = KEY[j] # 密钥第 j 位决定走线
nib = [0, 0, 0, 0]
nib[d] = ch & 0xF # 低半字节 -> 位置 d
nib[(d + 2) % 4] = ch >> 4 # 高半字节 -> 位置 (d+2)%4
for p, s in enumerate(CELL_SLOTS[j + 3 - k]):
out[s] = (nib[p] + CELL_OFF[p]) & 0xF
return ''.join('%X' % v for v in out)
def dec_block(blk):
raw = [[(int(blk[s], 16) - CELL_OFF[p]) & 0xF for p, s in enumerate(cell)]
for cell in CELL_SLOTS]
used = [j for j in range(3) if raw[j] != [FILL] * 4]
k = len(used)
chars = [None] * k
for j in range(k):
d, c = KEY[j], raw[used[j]]
chars[(j - 1) % k] = chr((c[(d + 2) % 4] << 4) | c[d])
return ''.join(chars)
def encrypt(msg):
return ''.join(enc_block(b) for b in split_blocks(msg)[::-1]) # 块序倒置
def decrypt(ct):
return ''.join(dec_block(ct[i:i + 12]) for i in range(len(ct) - 12, -1, -12))
if __name__ == "__main__":
assert encrypt("TLU") == "94AA48550495"
assert encrypt("Hello") == "34BB405504B5223594B94C53"
assert encrypt("2026") == "A48844556485223322356483"
assert encrypt("abcd!") == "547B475584B5223564BB4553"
CT = ("14CC4655547594BC475584C5848A43551495448C445584C5D4C9475564C534A84B55A4B5"
"74BA4355F495A48844556485648C495534A5548C4F5584A5B4BB405554B522332235A4B3")
flag = decrypt(CT)
assert encrypt(flag) == CT
print(flag) # flag{T1u_2026_Kc7f_Crypt0_M4ster!}
附录 B:实验脚本
wp_experiments.py —— 参数化实现 + 7 组消融/唯一性实验,直接 python3 wp_experiments.py 运行(约 0.5 秒),本文引用的所有输出片段均来自该脚本的真实输出。




这道 HexMaze 题的设计与解析,完整展现了 CTF Crypto 题从「构造可逆变换」到「选手逆向分析」的完整闭环。如果你对密码分析、逆向工程或 CTF 赛事感兴趣,欢迎来云栈社区交流讨论,一起沉淀技术实战经验。