找回密码
立即注册
搜索
热搜: Java Python Linux Go
发回帖 发新帖

5941

积分

0

好友

767

主题
发表于 4 天前 | 查看: 3| 回复: 0

2026 KCTF 第七题《戌时·暗能潜流》科幻风格赛题主视觉图

8 月 10 日 12:00,2026 KCTF 攻防对抗赛正式开赛。本届赛事沿用多维度积分体系,难度、火力、精致度三重积分同步核算,攻防双向计分机制兼顾趣味性与竞技性。

其中,签到题《辰时·钟鸣破晓》在赛事周期内持续开放,比赛全程都可以提交 flag 获取对应攻击积分。

截止今天中午 12 点,第七题《戌时·暗能潜流》答题通道已关闭。本题是一道 Crypto 密码题,围观人数 2148,共 29 支战队成功提交 flag。『SROokie』战队以 1 小时 08 分 18 秒拿下「一血」,『唐完了就是宋』战队、『您的名称过长』战队紧随其后,分别用时 1 小时 8 分 46 秒、2 小时 17 分 27 秒。题目整体难度偏高,各路战队在密码逻辑上展开了激烈博弈。

一只像素风格的黄色岩石手势,表达 CTF 选手的斗志与攻防氛围

本题 KCTF 组委会点评:

HexMaze 是一道设计思路较为完整的自定义 CTF 密码题。题目没有依赖随机参数,而是通过固定编码、模 16 变换、结构扰动等多个可逆步骤,构造出具有分析价值的密码流程。

题目难度控制合理,既要求选手具备从密文特征中发现规律的能力,也考察对已知明文分析、算法拆解和自动化验证的综合能力。密钥 121 的设计增加了题目的解释空间,使选手不仅需要得到 FLAG,还需要理解参数在算法中的实际作用。

整体来看,HexMaze 兼顾了趣味性、可解性和分析深度,是一道符合 CTF 精神的密码分析题。

一起来看看本题的设计思路与解题思路。

一、设计思路

出题战队:FUA_KCTF

战队成员 ID:FUA陈探乐、mb_pdwoeqop

FUA_KCTF 战队信息页面截图,展示战队签名、创建者、总分及注册时间

在 CTF 比赛中,Crypto 题的设计一直存在一个平衡问题:如果算法过于简单,选手可能凭借经验直接套用已有模板;如果算法完全依赖复杂数学结构,又容易变成黑盒猜解或者纯暴力分析。

因此,在设计 2026 KCTF Crypto · HexMaze 时,我们希望构造一道偏向「密码分析」的自定义算法题:

  • 算法完全确定,不依赖随机因素;
  • 选手可以通过公开样例逐步分析;
  • 加密流程包含多个可逆步骤;
  • 最终解题过程需要理解算法结构,而不是依靠猜测 FLAG。

HexMaze 的核心设计理念是:

将多个简单、可解释的可逆变换组合起来,让选手从 HEX 数据中寻找隐藏规律。

一、题目定位

HexMaze 定位为:

Crypto / 自定义密码分析

考察方向包括:

  • 输入输出长度关系分析;
  • 字节编码规律发现;
  • 固定变换逆向;
  • 自定义密钥语义分析;
  • 编写自动化解密脚本。

题目并不希望考察复杂密码算法,而是希望选手具备:

  • 从密文特征中寻找结构;
  • 从已知明文推导规则;
  • 对可逆流程进行拆解。

设计说明中将主要考察点定义为:

长度关系与明文编码方式观察、双层结构识别、base 移位差分还原、后处理逆向、统一密钥 121 的语义解释。

二、设计原则

1. 不引入随机因素

为了保证题目的分析价值,HexMaze 不使用:

  • 随机数;
  • 随机 IV;
  • 外部状态。

相同输入在相同密钥下,始终产生唯一结果。这样选手可以利用已知明文、密文差分、重复结构逐步恢复算法。

题面向选手明确说明:

相同输入和密钥一定产生唯一输出。

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 明文与测试目的对照表,涵盖大写、小写、数字和特殊符号四个测试样例

样例覆盖:

  • 大写;
  • 小写;
  • 数字;
  • 特殊符号。

设计目标是让选手可以通过差分逐步恢复规则,而不是依靠枚举。

七、难度控制

HexMaze 希望处于:

有经验 CTF 选手可以分析,初学者也能通过观察获得突破。

预期解题路径:

第一阶段:观察

发现:

  • 密文只包含 HEX;
  • 长度存在规律;
  • 输入字符与输出存在固定关系。

第二阶段:推导

恢复:

  • 字符编码方式;
  • 位变换规则;
  • 固定扰动结构。

第三阶段:实现

编写脚本:

  • 自动解密;
  • 验证样例;
  • 还原 FLAG。

八、设计总结

HexMaze 并不是为了模拟真实世界中的安全密码系统,而是设计一道适合 CTF 的分析型 Crypto 题。

它希望考察的是:

  • 发现规律的能力;
  • 拆解复杂流程的能力;
  • 从有限信息恢复完整规则的能力。

一个好的 CTF Crypto 题,不应该只是让选手面对无法理解的黑盒,而应该让选手在分析过程中不断获得反馈:

从 HEX 中发现结构;从结构中恢复算法;从算法中得到答案。

这也是 HexMaze 设计时希望带给选手的体验。

后续文章将继续介绍选手视角下的完整分析过程,包括如何从四组已知明文中恢复编码结构、推导密钥 121 含义,并最终还原 FLAG。

二、赛题解析

该解题思路由论坛会员 『Magic丶』 提供。

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

HexMaze 明文长度与密文长度对照表,含 TLU、Hello、2026、abcd!、FLAG 五组数据

观察 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 个恒定槽位的取值只有 45 两种。假设算法是「往 12 个半字节槽里写值,再对每个槽加一个固定偏移」,那么恒定槽的原始值必然是同一个常量。取该常量 = 0,则恒定槽的输出值本身就是偏移

槽位与恒定输出值对照表,展示槽位 1、4、6、7、9、11 的恒定偏移值

只有 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 个 -> 块内字符左旋一格

刚好还原出 TLU。于是确认:每个格子存放一个字符,低半字节和高半字节各占一个数据位,另外两个位填 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 首块明文 llo2026 首块 026abcd! 首块 cd!

交叉验证一个强证据:2026 的首块密文 A48844556485 与 FLAG 密文的第 8 块(下标 7)完全相同,而 FLAG 中恰好含有子串 026flag{T1u_2026_…)且对齐到同一块边界。

2.4 残块的填充

残块的前面若干个格子整体不用。这些格子里填的不是 0(实验 4 中令 FILL = 0x0Hello 的密文变成 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——0262026 和在 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),四种取值展开后是:

密钥位 d 值 0-3 对应的格内布局及加偏移后的 4-hex 码表格

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 码整体反转(594AA495)。

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) 是回文,且 d3-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=1d=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 各位数字的作用速查

密钥位与格子使用、hex 槽控制及残块 k 值对应关系速查表

5. 非密钥结构常量的必要性

密钥之外还有 5 项结构约定(块序倒置、CELL_OFFFILL、块内左旋、格子分组起点)。实验 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. 解密流程

  1. 把密文按 12 hex 切块,块序整体倒置(最后一个密文块是明文的开头)。
  2. 对每个 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
  1. 对第 j 个被使用的格子(j=0..k-1),取密钥数字 d = KEY[j],字符 = chr(格内位置[(d+2)%4] << 4 | 格内位置[d]),放回块内的第 (j-1) mod k 个位置(撤销左旋)。
  2. 把所有块的明文按倒置后的顺序拼起来。

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 实验设计表格,涵盖基准测试、密钥穷举、消融实验及唯一性验证等七组实验

动画演示黑色矩形块的旋转重叠过程,象征着加密块中的位置变化

蓝色 DNA 双螺旋结构图案,象征密码算法中的编码与结构

蓝黄柱状图动画演示,展示不同变量在实验中的分布变化


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




上一篇:SFT之后还有哪些Post-Training方法?RLHF、DPO、GRPO全解析
下一篇:Linux 逆向苹果 Find My 协议:无苹果硬件合规读取好友实时定位
您需要登录后才可以回帖 登录 | 立即注册

手机版|小黑屋|网站地图|云栈社区 ( 苏ICP备2022046150号-2 )

GMT+8, 2026-8-26 00:55 , Processed in 1.711815 second(s), 42 queries , Gzip On.

Powered by Discuz! X3.5

© 2025-2026 云栈社区.

快速回复 返回顶部 返回列表