-
-
[原创]看雪·2026 KCTF 第七题:戌时·暗能潜流 WP (AI)
-
发表于: 4天前 16
-
本题由
GPT-5.6-Sol (Max effort) + Codex完成, 耗时约 2 小时
由Claude Opus 5 (xhigh effort) + CC完成的时间为 1 小时 23 分 28 秒, 下文为 GPT 写的 WP
0x00 题目信息
题目给出密钥 121、四组已知明文/密文以及一段待恢复的 FLAG 密文:
TLU -> 94AA48550495
Hello -> 34BB405504B5223594B94C53
2026 -> A48844556485223322356483
abcd! -> 547B475584B5223564BB4553
FLAG 密文:
14CC4655547594BC475584C5848A43551495448C445584C5
D4C9475564C534A84B55A4B574BA4355F495A48844556485
648C495534A5548C4F5584A5B4BB405554B522332235A4B3
题目说明明文按 ASCII 字节处理,算法只包含字节编码、十六进制位运算、模 16 循环偏移、固定位置重排和固定摘要扰动,并提示摘要扰动只与长度有关。
最终恢复结果:
flag{T1u_2026_Kc7f_Crypt0_M4ster!}
0x01 从密文长度判断分组大小
先统计明文和密文长度:
| 明文 | 明文字节数 | 密文十六进制字符数 |
|---|---|---|
TLU |
3 | 12 |
Hello |
5 | 24 |
2026 |
4 | 24 |
abcd! |
5 | 24 |
可以得到:
len(ciphertext) = 12 * ceil(len(plaintext) / 3)
说明算法以 3 个 ASCII 字节为一个逻辑块,每块固定输出 12 个十六进制半字节。不足 3 字节的部分仍然输出完整的一块。
进一步比较样例可知,算法保留最左侧余数块,然后将完整三字节块从右向左输出:
Hello = He | llo
2026 = 2 | 026
abcd! = ab | cd!
对应密文顺序:
enc(llo) | enc(He)
enc(026) | enc(2)
enc(cd!) | enc(ab)
因此密文块:
A48844556485
对应的是 026,不是 202。
0x02 密钥 121 的数值作用
将密钥拆成三个十进制数字:
k0 = 1
k1 = 2
k2 = 1
定义:
S = k0 + k1 + k2 = 4
对于一个 ASCII 字节 b,先拆分高、低半字节:
H = b >> 4
L = b & 0x0F
然后进行模 16 偏移:
ZH = (H + S + k0) mod 16
= (H + 5) mod 16
ZL = (L + S) mod 16
= (L + 4) mod 16
普通字节还对应两类固定摘要值:
D0 = S = 4
D1 = S + k2 = 5
不足 3 字节时使用的长度标记为:
P0 = k1 = 2
P1 = k1 + k2 = 3
所以三个密钥数字在可观测等价算法中的作用为:
- 第一个
1:- 参与公共偏移
S; - 额外作用于高半字节,使其偏移为
S + 1 = 5。
- 参与公共偏移
- 中间的
2:- 参与公共偏移
S; - 作为不足 3 字节时的基础长度标记
2。
- 参与公共偏移
- 最后的
1:- 参与公共偏移
S; - 把固定摘要标记从
4提升到5; - 把长度标记从
2提升到3。
- 参与公共偏移
普通字节和空缺位置可以分别理解为贡献:
普通字节信息:[ZH, ZL, 4, 5]
空缺位置扰动:[2, 2, 3, 3]
随后固定位置重排会将它们打散。
0x03 用样例恢复模 16 偏移
以 TLU 为例:
T = 0x54 -> (5 + 5, 4 + 4) mod 16 = (A, 8)
L = 0x4C -> (4 + 5, C + 4) mod 16 = (9, 0)
U = 0x55 -> (5 + 5, 5 + 4) mod 16 = (A, 9)
得到六个有效半字节:
A 8 9 0 A 9
密文 94AA48550495 在位置:
0, 2, 3, 5, 8, 10
的值为:
9 A A 8 0 9
两者多重集合相同,只发生了位置重排。
继续验证其余样例的最右侧完整三字节块:
| 完整块 | 偏移后的六个半字节 | 密文有效半字节 |
|---|---|---|
TLU |
A890A9 |
9AA809 |
llo |
B0B0B3 |
3BB00B |
026 |
84868A |
A88468 |
cd! |
B7B875 |
57B78B |
四组数据可以唯一对齐六个有效位置。
0x04 完整三字节块的固定重排
设三个字节经偏移后依次得到:
Z0 Z1 Z2 Z3 Z4 Z5
其中:
第一个字节:Z0 Z1
第二个字节:Z2 Z3
第三个字节:Z4 Z5
由样例对齐得到:
C0 = Z5
C2 = Z4
C3 = Z0
C5 = Z1
C8 = Z3
C10 = Z2
其余位置是固定摘要值:
C1 = 4
C4 = 4
C6 = 5
C7 = 5
C9 = 4
C11 = 5
因此完整三字节块的加密模板为:
T3(Z0..Z5) =
Z5 4 Z4 Z0 4 Z1 5 5 Z3 4 Z2 5
即:
[Z5, 4, Z4, Z0, 4, Z1, 5, 5, Z3, 4, Z2, 5]
逆重排为:
Z0 = C3
Z1 = C5
Z2 = C10
Z3 = C8
Z4 = C2
Z5 = C0
然后逆偏移:
H = (ZH - 5) mod 16
L = (ZL - 4) mod 16
b = (H << 4) | L
0x05 长度摘要扰动
剩余一个字节
设该字节产生 Z0 Z1,模板为:
T1(Z0,Z1) =
2 2 3 3 2 2 3 5 Z1 4 Z0 3
即:
[2, 2, 3, 3, 2, 2, 3, 5, Z1, 4, Z0, 3]
以 2026 最左侧的剩余字符 2 为例:
2 = 0x32
Z0 = 3 + 5 = 8
Z1 = 2 + 4 = 6
代入模板:
223322356483
与题目一致。
FLAG 最左侧字符 f:
f = 0x66
Z0 = B
Z1 = A
代入后得到:
22332235A4B3
正好等于 FLAG 密文的最后一块。
剩余两个字节
设两个字节产生:
Z0 Z1 Z2 Z3
模板为:
T2(Z0..Z3) =
2 2 3 5 Z3 4 Z2 Z0 4 Z1 5 3
即:
[2, 2, 3, 5, Z3, 4, Z2, Z0, 4, Z1, 5, 3]
验证 Hello 的剩余块 He:
H = 0x48 -> Z0 Z1 = 9 C
e = 0x65 -> Z2 Z3 = B 9
代入:
223594B94C53
验证 abcd! 的剩余块 ab:
a = 0x61 -> B 5
b = 0x62 -> B 6
代入:
223564BB4553
均与题目一致。
由此可知,摘要扰动只与块长度有关:
- 完整块使用固定
4/5摘要位置; - 一个、两个剩余字节使用固定的
2/3/4/5模板; - 不涉及随机数、IV 或外部状态。
0x06 FLAG 逐块解密
FLAG 密文每 12 个字符切分:
00 14CC46555475
01 94BC475584C5
02 848A43551495
03 448C445584C5
04 D4C9475564C5
05 34A84B55A4B5
06 74BA4355F495
07 A48844556485
08 648C495534A5
09 548C4F5584A5
10 B4BB405554B5
11 22332235A4B3
前 11 块按完整模板解密,最后一块按单字节模板解密:
| 密文序号 | 密文块 | 解出的明文块 |
|---|---|---|
| 00 | 14CC46555475 |
r!} |
| 01 | 94BC475584C5 |
ste |
| 02 | 848A43551495 |
_M4 |
| 03 | 448C445584C5 |
pt0 |
| 04 | D4C9475564C5 |
Cry |
| 05 | 34A84B55A4B5 |
7f_ |
| 06 | 74BA4355F495 |
_Kc |
| 07 | A48844556485 |
026 |
| 08 | 648C495534A5 |
u_2 |
| 09 | 548C4F5584A5 |
{T1 |
| 10 | B4BB405554B5 |
lag |
| 11 | 22332235A4B3 |
f |
密文块顺序和明文块顺序相反,逆序拼接:
f
+ lag
+ {T1
+ u_2
+ 026
+ _Kc
+ 7f_
+ Cry
+ pt0
+ _M4
+ ste
+ r!}
得到:
flag{T1u_2026_Kc7f_Crypt0_M4ster!}
需要注意:
T1u中是数字1;Kc7f大小写如上;Crypt0末尾是数字0;M4ster中是数字4。
0x07 完整解题脚本
完整脚本也保存在同目录的 solve.py:
#!/usr/bin/env python3
"""KCTF 2026 T7 / HexMaze encryptor and decryptor."""
from __future__ import annotations
KEY = (1, 2, 1)
K0, K1, K2 = KEY
BASE = K0 + K1 + K2
HI_SHIFT = BASE + K0
LO_SHIFT = BASE
DIGEST_0 = BASE
DIGEST_1 = BASE + K2
PAD_0 = K1
PAD_1 = K1 + K2
def shifted_nibbles(block: bytes) -> list[int]:
result: list[int] = []
for value in block:
high = value >> 4
low = value & 0x0F
result.append((high + HI_SHIFT) & 0x0F)
result.append((low + LO_SHIFT) & 0x0F)
return result
def encrypt_block(block: bytes) -> str:
z = shifted_nibbles(block)
if len(block) == 3:
z0, z1, z2, z3, z4, z5 = z
out = [
z5, DIGEST_0, z4, z0,
DIGEST_0, z1, DIGEST_1, DIGEST_1,
z3, DIGEST_0, z2, DIGEST_1,
]
elif len(block) == 2:
z0, z1, z2, z3 = z
out = [
PAD_0, PAD_0, PAD_1, DIGEST_1,
z3, DIGEST_0, z2, z0,
DIGEST_0, z1, DIGEST_1, PAD_1,
]
elif len(block) == 1:
z0, z1 = z
out = [
PAD_0, PAD_0, PAD_1, PAD_1,
PAD_0, PAD_0, PAD_1, DIGEST_1,
z1, DIGEST_0, z0, PAD_1,
]
else:
raise ValueError("block length must be 1, 2, or 3")
return "".join(f"{value:X}" for value in out)
def split_plaintext(data: bytes) -> list[bytes]:
if not data:
return []
remainder = len(data) % 3
chunks: list[bytes] = []
offset = 0
if remainder:
chunks.append(data[:remainder])
offset = remainder
while offset < len(data):
chunks.append(data[offset:offset + 3])
offset += 3
return chunks
def encrypt(text: str) -> str:
chunks = split_plaintext(text.encode("ascii"))
return "".join(encrypt_block(block) for block in reversed(chunks))
def restore_byte(zh: int, zl: int) -> int:
high = (zh - HI_SHIFT) & 0x0F
low = (zl - LO_SHIFT) & 0x0F
return (high << 4) | low
def decode_full_block(block: list[int]) -> bytes:
z0 = block[3]
z1 = block[5]
z2 = block[10]
z3 = block[8]
z4 = block[2]
z5 = block[0]
return bytes([
restore_byte(z0, z1),
restore_byte(z2, z3),
restore_byte(z4, z5),
])
def decode_two_byte_block(block: list[int]) -> bytes:
z0 = block[7]
z1 = block[9]
z2 = block[6]
z3 = block[4]
return bytes([
restore_byte(z0, z1),
restore_byte(z2, z3),
])
def decode_one_byte_block(block: list[int]) -> bytes:
z0 = block[10]
z1 = block[8]
return bytes([restore_byte(z0, z1)])
def matches(block: list[int], fixed: dict[int, int]) -> bool:
return all(block[index] == value for index, value in fixed.items())
ONE_BYTE_FIXED = {
0: PAD_0,
1: PAD_0,
2: PAD_1,
3: PAD_1,
4: PAD_0,
5: PAD_0,
6: PAD_1,
7: DIGEST_1,
9: DIGEST_0,
11: PAD_1,
}
TWO_BYTE_FIXED = {
0: PAD_0,
1: PAD_0,
2: PAD_1,
3: DIGEST_1,
5: DIGEST_0,
8: DIGEST_0,
10: DIGEST_1,
11: PAD_1,
}
def decrypt(ciphertext: str) -> str:
if len(ciphertext) % 12 != 0:
raise ValueError("ciphertext length must be a multiple of 12")
blocks = [
[int(ch, 16) for ch in ciphertext[i:i + 12]]
for i in range(0, len(ciphertext), 12)
]
decoded_in_cipher_order: list[bytes] = []
for index, block in enumerate(blocks):
is_last = index == len(blocks) - 1
if is_last and matches(block, ONE_BYTE_FIXED):
decoded = decode_one_byte_block(block)
elif is_last and matches(block, TWO_BYTE_FIXED):
decoded = decode_two_byte_block(block)
else:
decoded = decode_full_block(block)
decoded_in_cipher_order.append(decoded)
plaintext = b"".join(reversed(decoded_in_cipher_order))
return plaintext.decode("ascii")
SAMPLES = {
"TLU": "94AA48550495",
"Hello": "34BB405504B5223594B94C53",
"2026": "A48844556485223322356483",
"abcd!": "547B475584B5223564BB4553",
}
FLAG_CIPHERTEXT = (
"14CC4655547594BC475584C5848A43551495448C445584C5"
"D4C9475564C534A84B55A4B574BA4355F495A48844556485"
"648C495534A5548C4F5584A5B4BB405554B522332235A4B3"
)
def main() -> None:
for plaintext, expected_ciphertext in SAMPLES.items():
assert encrypt(plaintext) == expected_ciphertext
assert decrypt(expected_ciphertext) == plaintext
flag = decrypt(FLAG_CIPHERTEXT)
assert flag == "flag{T1u_2026_Kc7f_Crypt0_M4ster!}"
assert encrypt(flag) == FLAG_CIPHERTEXT
print(flag)
if __name__ == "__main__":
main()
执行结果:
flag{T1u_2026_Kc7f_Crypt0_M4ster!}
脚本不仅解密 FLAG,还会重新加密四组样例与 FLAG。全部断言通过,说明恢复出的偏移、长度模板、位置重排和块顺序可以完整复现题目算法。
0x08 最终答案
flag{T1u_2026_Kc7f_Crypt0_M4ster!}
冰与火的战歌:Windows内核攻防实战高级班!从零到实战,融合AI与Windows内核攻防全技术栈,打造具备自动化能力的内核开发高手。