-
-
[原创]KCTF 2026 第七题:戌时·暗能潜流 writeup
-
发表于: 2026-8-20 19:45 39
-
写在前面:这题最先加密的是我的思路
这题打到一半,官方把附件换成了 KCTF07_challenge-fix.txt。
好消息是四组样例和 FLAG 密文都没动,不用从头重算;坏消息是,刚把 121 往 CRC、Rule 121、卷积核、GF(16) 等方向各猜了一轮,官方才把工具箱盖子掀开,明确告诉我们算法由这些东西组成:
字节编码
十六进制位运算
模16循环偏移
固定位置重排
固定摘要扰动
还特意补了一句:摘要扰动只和输入长度有关。
怎么形容呢,大概就是迷宫走到一半,工作人员终于递来一张纸:
吐槽归吐槽,下面的分析也只以修正版题面为准。目标很明确:不是猜一条“长得像”的 FLAG,而是先做出能完整复现四组样例的加解密流程,再去碰目标密文。
题目给出的数据如下:
key = 121
TLU -> 94AA48550495
Hello -> 34BB405504B5223594B94C53
2026 -> A48844556485223322356483
abcd! -> 547B475584B5223564BB4553
1. 长度先把块大小卖了
先看最没脾气、也最不容易骗人的东西:长度。
| 明文 | ASCII 长度 | 密文 hex 长度 |
|---|---|---|
TLU |
3 | 12 |
Hello |
5 | 24 |
2026 |
4 | 24 |
abcd! |
5 | 24 |
规律很明显:
len(cipher_hex) = 12 * ceil(len(plain) / 3)
也就是说,算法以三个 ASCII 字符为一个容量单位,每个字符占四个十六进制 nibble;不满三个字符时,会用某种只依赖长度的东西补足。
这时还不能急着喊“ECB 三字节块”。能确认的只有输出按 12 个 nibble 对齐,块到底怎么排,后面还有坑。
2. 先别管迷宫怎么走,数一数里面有什么
题面明确有“固定位置重排”。重排会改变顺序,但不会改变每种 nibble 出现的次数,所以可以先暂时忘掉位置,只比较 Counter。
设一个 ASCII 字节是:
x = 0xHL
H、L 分别是高、低 nibble。按照四位十六进制形式,它原本可以写成:
0, 0, H, L
把四个位置的模 16 偏移放进 16^4 的空间里枚举,再用四组样例的 nibble 计数筛选,可以得到一组有效编码:
W(0xHL) = [4, 5, (H + 5) mod 16, (L + 4) mod 16]
也就是对 00HL 加上:
4554
举几个例子:
T = 0x54 -> W(T) = 45A8
L = 0x4C -> W(L) = 4590
U = 0x55 -> W(U) = 45A9
于是 TLU 在位置重排前的原料是:
45A8 4590 45A9
继续对比四组明密文的 Counter,还能看到每缺一个字符槽,就会多出:
{2, 2, 3, 3}
计数只能确定这四个值,不能确定排列顺序。为了后面写表方便,我固定记成:
D = 2233
换成 2323 之类也不是世界末日,只要同步调整后面的置换表,实际效果不变。
此时四组数据可以拆成:
TLU:
45A8 4590 45A9
Hello:
459C 45B9 45B0 45B0 45B3 2233
2026:
4586 4584 4586 458A 2233 2233
abcd!:
45B5 45B6 45B7 45B8 4575 2233
它们分别与对应密文拥有完全相同的 nibble Counter。
到这里,我们已经知道迷宫里用了哪些砖,接下来要找的是每块砖被搬去了哪里。
3. 三字符块到底取哪三个字符
TLU 正好三个字符,没有歧义。麻烦的是另外三组:第一个 12-nibble 密文块到底对应明文中的哪三个字符?
这里可以枚举每条明文中的有序三字符选择,并要求四条记录共享同一张 12-position 置换表。最后只剩六组解,而这六组只是同一组三字符的共同 3! 换位:
TLU
llo
026
cd!
选择保持字符原顺序的写法后,分块关系就是:
| 明文 | 留到尾块的前缀 | 完整三字符块 |
|---|---|---|
TLU |
空 | TLU |
Hello |
He |
llo |
2026 |
2 |
026 |
abcd! |
ab |
cd! |
换句话说,算法从字符串右边取完整的三字符组;最左边剩下的一两个字符,留到最后和长度摘要一起处理。
4. 完整三字符块的置换
对完整块 abc,先拼接:
X = W(a) || W(b) || W(c)
用四组样例的首块做列匹配,可以恢复出一张有效的固定位置表。这里用“输出位置取哪个输入位置”的写法:
PF = (11, 0, 10, 2, 4, 3, 1, 5, 7, 8, 6, 9)
即:
cipher_block[i] = X[PF[i]]
为了看得更直观,把三个字符经过模 16 偏移后的高、低 nibble 分别记为:
A_H, A_L
B_H, B_L
C_H, C_L
展开位置表后,一个完整块其实就是:
E3(a,b,c) =
C_L, 4, C_H,
A_H, 4, A_L,
5, 5, B_L,
4, B_H, 5
逆向提取也很简单:
a.high = (CT[3] - 5) mod 16
a.low = (CT[5] - 4) mod 16
b.high = (CT[10] - 5) mod 16
b.low = (CT[8] - 4) mod 16
c.high = (CT[2] - 5) mod 16
c.low = (CT[0] - 4) mod 16
剩下的位置应该固定为 4 或 5,解密时顺手检查即可。
拿 TLU 验一次:
X = 45A8 4590 45A9
过 PF 后:
94AA48550495
继续验证另外三个完整块:
E3(T,L,U) = 94AA48550495
E3(l,l,o) = 34BB405504B5
E3(0,2,6) = A48844556485
E3(c,d,!) = 547B475584B5
TLU 对上时还可以说是巧合,后面三条一起对上,这张表就算能拿来干活了。
5. 尾块:看起来像填充,但不完全是填充
令:
r = len(plaintext) % 3
prefix = plaintext[:r]
body = plaintext[r:]
当 r == 1,尾块的原料是:
D || D || W(prefix[0])
根据 2026 的第二块可以恢复出直接表:
P1 = (0, 1, 2, 3, 4, 5, 6, 9, 11, 8, 10, 7)
展开后:
E1(a) = 2 2 3 3 2 2 3 5 A_L 4 A_H 3
所以:
E1('2') = 223322356483
当 r == 2,尾块原料是:
D || W(prefix[0]) || W(prefix[1])
Hello 和 abcd! 长度相同,长度摘要也相同,正好可以一起把第二张表钉出来:
P2 = (0, 1, 2, 5, 11, 4, 10, 6, 8, 7, 9, 3)
展开后:
E2(a,b) = 2 2 3 5 B_L 4 B_H A_H 4 A_L 5 3
验算:
E2('H','e') = 223594B94C53
E2('a','b') = 223564BB4553
加上前面的完整块,四组公开向量已经全部复现:
Hello = E3(llo) | E2(H,e)
= 34BB405504B5 | 223594B94C53
2026 = E3(026) | E1(2)
= A48844556485 | 223322356483
abcd! = E3(cd!) | E2(a,b)
= 547B475584B5 | 223564BB4553
2233 虽然长得很像普通 padding,但它不是安静地躺在字符串末尾,而是和剩余字符一起进入尾块位置表。直接拿 PKCS#7 去套,只会得到一串很有规律的错答案。
6. 原来迷宫是从右边进的
完整算法从明文右端开始,每次取三个字符。块内字符顺序不变,但完整块列表从右向左输出;最左边剩下的 prefix 最后进入尾块:
明文:
prefix | B0 | B1 | ... | Bk
密文块顺序:
E3(Bk) | ... | E3(B1) | E3(B0) | tail(prefix)
四个公开样例在这样拆分后都只有一个完整块,所以单看样例,其实还看不出“多块消息是否整体逆序”。真正把这个顺序暴露出来的是 FLAG。
把目标密文每 12 个 hex 字符切一块,对前 11 块使用 E3 的逆变换,会按密文顺序得到:
r!}
ste
_M4
pt0
Cry
7f_
_Kc
026
u_2
{T1
lag
这已经不像乱码了,更像一句话被人每三个字符切开后,把整摞纸条倒着放在桌上。
最后一个块:
22332235A4B3
按 r == 1 的尾块规则逆回去,得到:
D || D || W('f')
所以最左侧前缀是:
f
把前 11 个完整块倒序,再把 f 放回最前面:
f
+ lag
+ {T1
+ u_2
+ 026
+ _Kc
+ 7f_
+ Cry
+ pt0
+ _M4
+ ste
+ r!}
得到:
flag{T1u_2026_Kc7f_Crypt0_M4ster!}
这次不是“看起来很像 FLAG”,而是重新加密后能完整回到题目给出的 144 个十六进制字符。
7. 密钥 121 怎么理解
题目要求解释 121 三个数字分别做了什么。现有数据只有这一把密钥,没法唯一恢复作者的 key schedule;不过结合修正版提到的位运算,可以写出一个很自然、也能精确生成 4554 和 2233 的形式:
a, b, c = 1, 2, 1
u = a << b = 4
v = u | c = 5
offset = (u, v, v, u)
= (4, 5, 5, 4)
digest = (b, b, b | c, b | c)
= (2, 2, 3, 3)
这样三个数字都有落点:
- 第一个
1作为偏移初始值; 2作为左移位数,同时参与生成摘要常量;- 最后一个
1用来补上最低位,把4变成5、把2变成3。
这里留个余地:题目没有给第二把密钥,所以 << 和 | 不是唯一可能的源码写法。比如对当前数值来说,4 | 1、4 + 1 和 4 ^ 1 都等于 5。能够确定的是最终常量关系 121 -> 4554 / 2233;“左移再 OR”是最贴合题目提示的一种解释。
8. 解题脚本
KEY = "121"
PF = (11, 0, 10, 2, 4, 3, 1, 5, 7, 8, 6, 9)
P1 = (0, 1, 2, 3, 4, 5, 6, 9, 11, 8, 10, 7)
P2 = (0, 1, 2, 5, 11, 4, 10, 6, 8, 7, 9, 3)
def params(key=KEY):
a, b, c = map(int, key)
u = (a << b) & 0xF
v = (u | c) & 0xF
shifts = (u, v, v, u)
digest = (b, b, b | c, b | c)
return shifts, digest
def permute(values, table):
return tuple(values[i] for i in table)
def unpermute(values, table):
result = [0] * len(table)
for dst, src in enumerate(table):
result[src] = values[dst]
return tuple(result)
def encode_word(ch):
value = ord(ch)
if value > 0x7F:
raise ValueError("ASCII only")
high, low = value >> 4, value & 0xF
raw = (0, 0, high, low)
shifts, _ = params()
return tuple((x + k) & 0xF for x, k in zip(raw, shifts))
def decode_word(word):
shifts, _ = params()
raw = tuple((x - k) & 0xF for x, k in zip(word, shifts))
if raw[:2] != (0, 0):
raise ValueError("bad marker")
value = (raw[2] << 4) | raw[3]
if value > 0x7F:
raise ValueError("not ASCII")
return chr(value)
def encode_full(block):
stage = tuple(x for ch in block for x in encode_word(ch))
return permute(stage, PF)
def decode_full(block):
stage = unpermute(block, PF)
return "".join(decode_word(stage[i:i + 4]) for i in (0, 4, 8))
def encode_tail(prefix):
r = len(prefix)
_, digest = params()
stage = digest * (3 - r)
stage += tuple(x for ch in prefix for x in encode_word(ch))
return permute(stage, P1 if r == 1 else P2)
def decode_tail(block, r):
table = P1 if r == 1 else P2
stage = unpermute(block, table)
_, digest = params()
digest_size = 4 * (3 - r)
if stage[:digest_size] != digest * (3 - r):
raise ValueError("bad digest")
return "".join(
decode_word(stage[i:i + 4])
for i in range(digest_size, 12, 4)
)
def encrypt(plaintext):
plaintext.encode("ascii")
r = len(plaintext) % 3
prefix, body = plaintext[:r], plaintext[r:]
blocks = [body[i:i + 3] for i in range(0, len(body), 3)]
out = []
for block in reversed(blocks):
out.extend(encode_full(block))
if r:
out.extend(encode_tail(prefix))
return "".join(f"{x:X}" for x in out)
def decrypt(ciphertext):
ciphertext = "".join(ciphertext.split()).upper()
if len(ciphertext) % 12:
raise ValueError("bad ciphertext length")
nibbles = tuple(int(x, 16) for x in ciphertext)
all_blocks = [nibbles[i:i + 12] for i in range(0, len(nibbles), 12)]
candidates = []
for r in (0, 1, 2):
try:
blocks = list(all_blocks)
if r and not blocks:
raise ValueError("missing tail block")
prefix = decode_tail(blocks.pop(), r) if r else ""
body = "".join(decode_full(block) for block in reversed(blocks))
plaintext = prefix + body
if encrypt(plaintext) == ciphertext:
candidates.append(plaintext)
except (ValueError, UnicodeError):
pass
if len(candidates) != 1:
raise ValueError(f"got {len(candidates)} candidates")
return candidates[0]
SAMPLES = {
"TLU": "94AA48550495",
"Hello": "34BB405504B5223594B94C53",
"2026": "A48844556485223322356483",
"abcd!": "547B475584B5223564BB4553",
}
for plain, cipher in SAMPLES.items():
assert encrypt(plain) == cipher
assert decrypt(cipher) == plain
flag_cipher = (
"14CC4655547594BC475584C5848A43551495448C445584C5D4C9475564C5"
"34A84B55A4B574BA4355F495A48844556485648C495534A5548C4F5584A5"
"B4BB405554B522332235A4B3"
)
flag = decrypt(flag_cipher)
print(flag)
assert encrypt(flag) == flag_cipher
运行结果:
flag{T1u_2026_Kc7f_Crypt0_M4ster!}
9. 一点补充
公开数据毕竟只有四组,没法保证每个表下标和作者源码一模一样。比如 ASCII 编码中的几个槽位永远是相同常量,交换它们并同步修改置换表,不会改变任何实际输出。
同样,四个样例各自只有一个完整三字符块,所以“多个完整块整体逆序”不是靠样例单独钉死的;它是在目标密文逐块解开后暴露出来,再由完整重加密和平台通过共同确认。
这也是这题最值得记住的一点:Counter 对上,只代表原料对了;字符串读起来顺,也不等于解密完成。真正能收工的标准还是那三个——公开样例全部复现、目标密文可以完整往返、平台提交通过。
PS: 乱拼拼成 flag{Kc7f_2026_Crypt0_M4ster_T1u!} 也很合理对吧?(笑)
最终 FLAG:
flag{T1u_2026_Kc7f_Crypt0_M4ster!}
冰与火的战歌:Windows内核攻防实战高级班!从零到实战,融合AI与Windows内核攻防全技术栈,打造具备自动化能力的内核开发高手。