-
-
[原创]KCTF2026 第七题 戌时·暗能潜流(AI)
-
发表于: 14小时前 243
-
FLAG:flag{T1u_2026_Kc7f_Crypt0_M4ster!}
1. 半字节(nibble)
一个字节有 8 个 bit,从中间切开,左右各 4 个 bit,每一半叫一个"半字节"。一个半字节恰好对应一位十六进制字符(0F,也就是 015)。比如字节 0x5C:高半字节是 5,低半字节是 C。这道题所有操作都发生在半字节级别,所以后面我们把密文看成"一串半字节"而不是"一串字节"。
2. mod 16(模 16)加减法
半字节只能装 0~15,加减结果超出这个范围就"绕回来":
记一个口诀就够了:减出来是负数,就加 16。
3. 差分分析(本题最核心的一招)
设想密文长这样:密文 = 明文(打散重排)+ 一堆我们不知道的固定小常数。
现在拿两条长度相同的明文,各自加密一次。因为长度相同,按题目提示"摘要扰动只与输入长度有关",两条密文里混入的固定常数完全一样。于是两个式子相减,常数就消掉了:
也就是说:两条密文的差,就是两条明文的差换了个位置。我们全程不需要知道常数是什么——它已经被减没了。这就是为什么必须挑同长度的一对样本(Hello 和 abcd!,都是 5 字符)。
4. "多重集相同"是什么意思
把一列数无视顺序、只统计"每个值出现几次"。比如 E 7 3 9 8 4 E 和 E 4 9 8 3 E 7,排序后都是 3 4 7 8 9 E E——每个值出现的次数一模一样,只是排列顺序不同。直觉说法:同一批人换了座位,没有人多也没有人少。在差分分析里,"差分值多重集相同"就是"只做了重排、没做混合"的铁证。
题目文件 KCTF07_challenge-fix.txt:
HexMaze 使用固定算法对 ASCII 字符串进行变换。
算法仅包含:字节编码;十六进制位运算;模16循环偏移;固定位置重排;固定摘要扰动。
不包含:随机数;随机 IV;外部状态。相同输入和密钥一定产生唯一输出。
提示:摘要扰动过程只与输入长度有关。
已提供4组已知明文/密文,请分析变换规律并恢复 FLAG。
密钥: 121
已知对(明文先按 ASCII 转成字节,如 TLU → 54 4C 55):
只有 4 组样本、算法又有多层,正面硬猜是不行的。整体策略是:利用"同长度样本做差能消掉未知常数"这一性质,把问题逐层剥开。
第一个脚本 explore.py 只做一件事:把数据解析出来、数长度。
输出:
数一数就得到第一条硬规律:
逐条核对:
翻译成人话:明文先在末尾填充到 3 的倍数那么长,然后每个输入字节固定产出 2 个输出字节。填充里没有随机量(题目声明没有随机 IV),所以填充字节要么是固定值、要么只跟长度有关——这正好对应提示里的"摘要扰动只与输入长度有关"。
第二个脚本 explore2.py 把密文拆成半字节序列,重点观察同长度的 Hello 与 abcd!:
Hello 和 abcd! 都是 5 字节,把它们的 10 个输入半字节上下对齐(暂不管填充):
不同的位置是 {0,1,3,5,7,8,9},共 7 个。
为什么 5 个字节全不同,半字节却只有 7 个位置不同?因为 'e','l','l' 和 'a','b','c' 的高半字节恰好都是 6(小写字母 0x61~0x6F 的高半字节都是 6),所以第 2、4、6 位"撞车"了。这是 ASCII 的巧合,不是规律。
再把两条 24 位的密文上下对齐:
不同的位置是 {0,2,5,8,16,19,21},也是 7 个。
现在回答一个关键问题:输出位和它对应的输入位之间,到底是 +常数、^常数,还是别的什么?方法:把上一节两边的差分值都算出来,看哪一种运算的账能对上。
在输入侧的 7 个差分位上,逐位做 Hello 减 abcd!(mod 16,减出负数就 +16):
得到输入差分序列:E 7 3 9 8 4 E(按位 0,1,3,5,7,8,9 的顺序)。
同样地,在输出侧的 7 个差分位上逐位相减:
得到输出差分序列:E 4 9 8 3 E 7(按位 0,2,5,8,16,19,21 的顺序)。
把两组差分各自排序:
完全一致(两个 E、以及 3、4、7、8、9 各一个,一个不多一个不少)。
这就是"加法 + 纯重排"的铁证,逻辑如下:假设 密文[q] = 明文[σ(q)] + K_q(第 q 个输出位来自某个输入位 σ(q),加上一个固定小常数 K_q)。对两条同长度密文相减时,K_q 在两式中相同,被消掉:
也就是说,每个输出位上的差分,就等于某个输入位上的差分原样搬过来。所以差分值只能是"换位置",不可能变多、变少或变成新值——和观测完全吻合。
试一试异或(XOR):如果运算是 密文[q] = 明文[σ(q)] ^ K_q,那么两条密文的 XOR 差分也应该等于输入的 XOR 差分搬家。算出来看:
排序对比:输出是 {2,6,7,8,9,C,F},输入是 {2,4,7,8,9,E,F}——输出里有 6、C 而输入里没有,输入里有 4、E 而输出里没有。账对不上,异或模型出局。
试一试混合(扩散):如果某个输出位 = 两个输入位相加(比如 明文[a] + 明文[b] + K),那么做差时两个差分也会相加,例如 E + 7 = 5、3 + 9 = C,会凭空造出输入里不存在的差分值;而且"7 个输入差分对应 7 个输出差分"这种一一对应的关系根本无法成立。出局。
结论:变换就是 out[q] = in[σ(q)] + K_q (mod 16)——一次位置置换 + 每位一个小加数。剩下的活儿就是把 σ(谁来自谁)和 K(加多少)解出来。
差分值就像防伪码:输出位上的某个差分值,必然来自输入里差分值相同的那一位。逐个值查账(一个值只在输入/输出各出现一次的,直接配对):
E 的歧义怎么解决? 两种配法都满足差分,但算出的 K 不同。把两种都算出来,再用另外两个样本(TLU、2026)当裁判:
所以输出位 0 ← 输入位 9,输出位 19 ← 输入位 0。
另外还有 5 个"隐形"数据位:Hello 和 abcd! 的输入在第 2、4、6 位恰好相同(都是 6),加上两个填充位——差分法看不见它们去哪了。它们的去向同样靠 TLU / 2026 交叉检验定位(方法同上:假设某输出来自某输入,用四个样本逐一验证 输出 = 输入 + K 是否处处成立)。
先把记号说清楚。填充后的输入共 12 个半字节,编号 in0..in11;b0..b4 是明文的 5 个字节,in2k / in2k+1 是第 k 个字节的高/低半字节。以 Hello 为例:
每个输出位的来源与加数 K(K = 输出 − 来源输入,Hello、abcd! 双向验证一致):
从表里读出两个漂亮规律:
其余输出位(1、4、6、7、9、11、12、13、14、15、17、23)是固定标记(4、5、2、3 之类),不随明文变化。
把 5.2 表里的 b4、b3、b2…… 换成"距末尾第几个字符"(记 e1 = 最后一个字符,e2 = 倒数第二,以此类推):
于是输出的前 12 位(6 字节):
这就是普通块模板 T1:从末尾往前每 3 个字符为一块(e1, e2, e3),产出 6 个输出字节;6 个数据半字节按 e1低, e1高, e3高, e3低, e2低, e2高 的次序散布,中间穿插固定标记 4,4,5,5,4,5。
TLU 恰好 3 个字符、一块、无填充,是验证模板的完美样本。T=0x54, L=0x4C, U=0x55,从后往前 e1='U', e2='L', e3='T'。逐位计算 94 AA 48 55 04 95 的 12 个半字节:
12 个位置全部吻合。再用 Hello 的第一块(末 3 字符 'l','l','o')复核:e1='o'(高6低F):位0 = F+4 = 3、位2 = 6+5 = B;e3='l'(高6低C):位3 = 6+5 = B、位5 = C+4 = 0;e2='l':位8 = C+4 = 0、位10 = 6+5 = B——正好是 34 BB 40 55 04 B5。四个样本的第一块全部如此。
第三个脚本 solve.py 把这个模板写成代码自检:
输出(4 组样本的第一块全部通过):
分析过程中的一个弯路:曾把输出第 7 位误当成随长度变化的摘要位(2026 的一些位确实随长度变化),细查后发现第 7 位恒为 5,真正随长度变化的只有末块里的个别字段(见下节)。
除最后一块外,所有块都用 T1——这一点由 FLAG 直接证实:FLAG 的 12 块里,前 11 块的第 1/4/6/7/9/11 相对位全部等于 (4,4,5,5,4,5)。
最后一块(包含填充字节)用特殊模板,按填充字节数分三种情况(题目说的"摘要扰动只与输入长度有关"就藏在这里)。
就是 TLU 的情况:整串恰好一块,直接用 T1,没有特殊之处。
最后一块的内容是 (P[1], P[0], 填充)——P[0] 是首字符、P[1] 是第二个字符。模板(相对位 0~11):
拿 Hello 的最后 6 字节 22 35 94 B9 4C 53 逐位验证(P1='e'=0x65,P0='H'=0x48):
全对。abcd!(P1='b',P0='a')同样全对。
最后一块的内容是 (P[0], 填充, 填充)——只剩首字符一个真实字节。模板:
注意两个细节:
为什么要"互换"?——解题中最曲折的一步,值得复盘。 2026 的最后 6 字节是 22 33 22 35 64 83,其中第 8、10 位是 6 和 8。按"正向"编码首字符 '2'(0x32,高 3 低 2)应该得到 (3+4, 2+5) = (7,7),和实际的 (6,8) 对不上,一度卡住。后来试着把高低互换:(2+4, 3+5) = (6,8)——完全吻合。再拿这条规则反推 FLAG 的末块(第 8、10 位是 A 和 B):(A−4, B−5) = (6,6) = 'f'。而前面 11 块已经解出 lag{T1u_...,首字符 f 正好拼成 flag{——两个样本同时闭环,规则坐实。
FLAG 末块 22 33 22 35 A4 B3 与 2026 末块 22 33 22 35 64 83 的前 4 字节完全相同,即两者的长度摘要字段一致 → FLAG 与 2026 同属"两填充"类 → FLAG 明文长度 = 34(一举排除 35/36 的可能性)。
第四个脚本 decode_flag.py 对 FLAG 的 12 块逐块解码。每块有 6 个标记位做自检——套错模板会立刻暴露,所以这个解码是"带验尸的",不是碰运气:
逐块解码明细(12 块的标记自检全部通过):
注意阅读顺序:e1 是最后一个字符,所以拼明文要倒着来,从 e34 读到 e1:
明文里还有几处自我印证:2026(第 7 块)、Crypt0_M4ster(第 0~4 块)、flag{...} 的完整格式。
最终脚本 final.py 实现完整的加/解密,对 4 组样本 + FLAG 做精确往返验证:
实际运行输出:
HexMaze 的完整流程:
冰与火的战歌:Windows内核攻防实战高级班!从零到实战,融合AI与Windows内核攻防全技术栈,打造具备自动化能力的内核开发高手。