首页
社区
课程
招聘
[原创]KCTF 2026 第七题:戌时·暗能潜流 writeup
发表于: 2026-8-20 19:45 39

[原创]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内核攻防全技术栈,打造具备自动化能力的内核开发高手。

收藏
点赞 ・1
打赏
分享
最新回复 (0)
游客
登录 | 注册 方可回帖
返回