首页
社区
课程
招聘
[推荐]看雪CTF2026 第七题 WP
发表于: 2026-8-20 15:46 28

[推荐]看雪CTF2026 第七题 WP

2026-8-20 15:46
28

KCTF 2026 第七题 HexMaze Writeup

题目信息

题目给了一个固定密钥 121、四组已知明密文,以及一段 FLAG 密文:

TLU    -> 94AA48550495
Hello  -> 34BB405504B5223594B94C53
2026   -> A48844556485223322356483
abcd!  -> 547B475584B5223564BB4553

提示里最有用的几句话是:算法只包含字节编码、十六进制位运算、模 16 循环偏移、固定位置重排和固定摘要扰动;摘要扰动只与输入长度有关。

最终恢复的 FLAG 为:

flag{T1u_2026_Kc7f_Crypt0_M4ster!}

下面记录完整分析过程。重点不是根据单词形状猜 FLAG,而是还原一个能够把四个样例和 FLAG 全部重新加密回原密文的等价算法。

1. 先看长度和分组

四组数据的长度关系是:

明文 3 bytes  -> 密文 12 hex nibbles
明文 4 bytes  -> 密文 24 hex nibbles
明文 5 bytes  -> 密文 24 hex nibbles

即:

len(cipher_hex) = 12 * ceil(len(plain) / 3)

这个长度关系提示我们先按 3 个 ASCII 字节对应 12 个十六进制位来分组观察;后面的重加密会验证这一等价模型。最后不足 3 字节时,输出仍会补成完整块。

这里很容易因为“3 字节一组”先想到 Base64。我一开始也沿着这个方向看过,但同长度样例的差分说明它本质上是在搬运 ASCII 字节的高、低半字节,而不是 Base64 编码。

2. 用同长度样例消掉摘要扰动

Hello 和 abcd! 都是 5 字节,因此它们受到的长度摘要扰动完全相同。先写成 ASCII 十六进制:

Hello : 48 65 6C 6C 6F
abcd! : 61 62 63 64 21

两行各有 10 个 hex nibble,在 10 个对齐位置中正好有 7 个不同。再逐 nibble 比较两条密文,也正好只有 7 个位置不同。

更强的一点是:只取发生变化的位置,按 Hello - abcd! (mod 16) 计算差值,明文与密文得到的差值多重集完全相同:

{E, E, 7, 3, 9, 8, 4}

这强烈提示存在一种等价模型:明文 nibble 经过模 16 偏移后再做固定位置重排,另一半输出位置则是固定标记或长度摘要。这个判断还不能只靠一组差分下结论,后面要用全部样例和正向重加密继续验证。

把四组样例的 nibble 计数放在一起整理,可以构造出下面的字节展开,并由全部样例验证。每个真实字节 x = (h << 4) | l 对输出贡献四个值:

{ 5, (h + 5) mod 16, 4, (l + 4) mod 16 }

例如 ASCII 字符 o = 0x6F:

h = 6, l = F
贡献为 {5, B, 4, 3}

其中 F + 4 = 3 (mod 16),正好体现了提示中的“模 16 循环偏移”。若临时按 [5,h+5,4,l+4] 排列,T、L、U 三组分别是 5A48、5940、5A49,合起来的 nibble 多重集与密文 94AA48550495 完全相同。每缺少一个字节,则会多出一组 {2, 2, 3, 3}。

到这里已经能解释密文中所有 nibble 的取值,但顺序还没有解决。

3. 不是块内逆序,而是整条明文逆序

我先按原明文从左向右分块,发现每块的 nibble 多重集总对不上。例如 Hello 的第一块密文并不对应 Hel,反而对应末尾三个字节 oll。

对四组样例做同样检查后,分块关系变得非常整齐:

TLU    -> ULT
Hello  -> oll | eH
2026   -> 620 | 2
abcd!  -> !dc | ba

所以算法的第一步是把整条 ASCII 字节串反转,再从左向右每 3 字节分块。等价地说,就是从原明文右端开始,每次取最多 3 字节,并把取出的字节也反过来。

这个细节很关键:只在原来的每个 3 字节块内部逆序,会在非 3 倍数长度的样例上得到错误结果。

4. 恢复单字节码字和固定路由

上一节只讨论无序的 nibble 多重集。为了让固定路由写起来更简洁,这里选取低半字节在前的等价有序表示。设一个字节的高、低 nibble 分别为 h、l,定义四 nibble 码字:

C(x) = [5, (l + 4) mod 16, 4, (h + 5) mod 16]

再定义两种固定走法:

T1(x) = C(x)
T2(x) = reverse(C(x))

对反转后的完整三字节块 q = [q0, q1, q2],字节槽的访问顺序是:

[0, 2, 1]

在本文采用的等价参数化中,可以把密钥 121 解释成依次选择三条路,即正向、反向、正向:

core = T1(q0) || T2(q2) || T1(q1)
block = ROL_nibble(core, 1)

这里 ROL_nibble(..., 1) 表示把 12 个 hex nibble 整体向左循环移动一位,并不是做 12-bit 旋转。下文将它简写成 ROL1,逆操作简写成 ROR1。

拿最短的样例 TLU 验证。整条反转后为 ULT,固定槽序实际访问 U、T、L:

C(U)          = 594A
reverse(C(T)) = A485
C(L)          = 5049

core          = 594AA4855049
ROL1(core)    = 94AA48550495

结果与题目逐字符一致。

这个路由也可以直接写成一张 nibble 位置 P-box。我在恢复时实际是把“输出位置 -> 输入位置”做匹配求出来的;不过上面的“正、反、正”写法更直观,也更容易看出本文对 121 的解释。

5. 长度摘要其实也能统一进同一条公式

尾块里会出现明显的 2/3 重复模式。把每块公共的 ROL1 撤销以后,可以发现每个缺失槽真正对应的固定摘要字是:

G = 3223

设当前块有 r 个真实字节,缺失字节数为 m = 3 - r。计算尾块 core 时,仍按槽序 [0,2,1] 访问,但过滤不存在的槽,并只使用 121 的前 r 个路由。随后在 core 前放 m 份 G,和完整块一样统一做一次 ROL1:

pre   = G * m || core
block = ROL1(pre)

因此这里没有随机 IV,也不需要为尾块设计另一套加密算法。摘要内容固定,出现几次只由输入长度模 3 的结果决定,正好对应题目提示。

例如 Hello 全局反转后的尾块是 eH:

G              = 3223
C(e)           = 594B
reverse(C(H))  = 94C5

pre            = 3223 | 594B | 94C5
ROL1(pre)      = 223594B94C53

再看 2026 的尾块,里面只有字符 2,因此要补两份摘要:

pre       = 3223 | 3223 | C(2)
          = 3223 | 3223 | 5648
ROL1(pre) = 223322356483

同样与题目完全一致。

6. 密钥 121 中三个数字的作用

在只有 key=121 样例的前提下,可以把恢复出的常量写成如下与全部观测、以及题目“位运算”提示一致的参数化。令:

(a, b, c) = (1, 2, 1)

BASE = a << b       = 4
HIGH = BASE | c     = 5
G    = [b|c,b,b,b|c] = [3, 2, 2, 3]

于是单字节码字就是:

C(x) = [HIGH, l + BASE, BASE, h + HIGH]  (mod 16)

在本文采用的等价参数化中,三个 key digit 同时对应三个访问位置采用的路由:

1 -> 码字正向
2 -> 码字反向
1 -> 码字正向

需要严谨说明一点:题目只给了密钥 121,没有提供其他密钥下的样例,因此不可能从信息论上唯一证明作者对“任意密钥”的参数生成代码一定就是上面这几行。能严格闭环的是 key=121 时的加密函数;上述写法是与全部样例、题目提示以及可观测路由完全一致的自然参数化。

另外,码字中的 4、5 是恒定槽,摘要里也有重复的 2、3。固定置换若只交换这些永远相等的源位置,输出不会发生任何变化。因此仅凭明密文无法唯一还原作者源码里 P-box 数组的每个下标,但可以恢复与其对所有有效输入函数等价的变换。这不影响解密和重加密验证。

7. 逆向 FLAG

解密就是把上述步骤倒过来:

  1. 每 12 个 hex nibble 分成一块;

  2. 每块先 ROR1,撤销最后的循环移位;

  3. 最后一块开头有几份 3223,就说明缺了几个字节;

  4. 每四个 nibble 取一个码字,按 121 的前 r 位撤销正反方向;

  5. 从四 nibble 码字恢复原字节:

    C(x) = [5, L', 4, H']
    l = L' - 4 (mod 16)
    h = H' - 5 (mod 16)
    x = (h << 4) | l
    
  6. 撤销槽序 [0,2,1](不存在的槽直接跳过),最后把整条字节流再次反转。

FLAG 最后一个密文块尤其直观:

22332235A4B3 --ROR1--> 3223 | 3223 | 5A4B

前面有两份摘要,所以最后一块只有一个真实字节;5A4B = C(f),恢复出反向字节流的最后一个字符 f。由 12 个密文块也可同时确定原文长度为:

11 * 3 + 1 = 34 bytes

逐块解出的反向字节流为:

}!r | ets | 4M_ | 0tp | yrC | _f7 |
cK_ | 620 | 2_u | 1T{ | gal | f

连接后整体反转,得到:

flag{T1u_2026_Kc7f_Crypt0_M4ster!}

8. 完整验证脚本

#!/usr/bin/env python3

KEY = (1, 2, 1)
a, b, c = KEY
BASE = a << b
HIGH = BASE | c
G = [b | c, b, b, b | c]


def C(x):
    h, l = x >> 4, x & 0xF
    return [HIGH, (l + BASE) & 0xF,
            BASE, (h + HIGH) & 0xF]


def C_inv(w):
    assert w[0] == HIGH and w[2] == BASE
    l = (w[1] - BASE) & 0xF
    h = (w[3] - HIGH) & 0xF
    return (h << 4) | l


def encrypt(s):
    raw = s.encode('ascii')[::-1]
    out = []

    for off in range(0, len(raw), 3):
        q = list(raw[off:off + 3])
        order = [i for i in (0, 2, 1) if i < len(q)]

        core = []
        for route, index in zip(KEY, order):
            w = C(q[index])
            core += w if route == 1 else w[::-1]

        z = G * (3 - len(q)) + core
        out += z[1:] + z[:1]       # ROL1

    return ''.join(f'{x:X}' for x in out)


def decrypt(ct):
    v = [int(x, 16) for x in ct]
    assert len(v) % 12 == 0
    blocks = [v[i:i + 12] for i in range(0, len(v), 12)]

    # ROR1 后,末块前面有几份 G,就缺几个字节。
    last = blocks[-1][-1:] + blocks[-1][:-1]
    missing = 0
    while missing < 2 and last[4 * missing:4 * missing + 4] == G:
        missing += 1

    reversed_plain = []
    for bi, y in enumerate(blocks):
        z = y[-1:] + y[:-1]        # ROR1
        m = missing if bi == len(blocks) - 1 else 0
        assert z[:4 * m] == G * m
        core = z[4 * m:]
        width = 3 - m

        order = [i for i in (0, 2, 1) if i < width]
        q = [0] * width
        for j, (route, index) in enumerate(zip(KEY, order)):
            w = core[4 * j:4 * (j + 1)]
            q[index] = C_inv(w if route == 1 else w[::-1])
        reversed_plain += q

    return bytes(reversed_plain[::-1]).decode('ascii')


samples = {
    'TLU':   '94AA48550495',
    'Hello': '34BB405504B5223594B94C53',
    '2026':  'A48844556485223322356483',
    'abcd!': '547B475584B5223564BB4553',
}

flag_ct = (
    '14CC4655547594BC475584C5848A43551495448C445584C5'
    'D4C9475564C534A84B55A4B574BA4355F495A48844556485'
    '648C495534A5548C4F5584A5B4BB405554B522332235A4B3'
)

for pt, ct in samples.items():
    assert encrypt(pt) == ct
    assert decrypt(ct) == pt

flag = decrypt(flag_ct)
assert flag == 'flag{T1u_2026_Kc7f_Crypt0_M4ster!}'
assert encrypt(flag) == flag_ct
print(flag)

运行结果:

flag{T1u_2026_Kc7f_Crypt0_M4ster!}

冰与火的战歌:Windows内核攻防实战高级班!从零到实战,融合AI与Windows内核攻防全技术栈,打造具备自动化能力的内核开发高手。

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