首页
社区
课程
招聘
[原创]第七题:戌时·暗能潜流
发表于: 4天前 10

[原创]第七题:戌时·暗能潜流

4天前
10

HexMaze Writeup

奇奇怪怪的题目(),看第一第二两个大哥都是opus5出的,果然还是claude更适合misc吗,不懂crypto,不清楚在开始那种约束下为什么能解出来flag,gpt跟我说好像不唯一,等作者分析了()

题目数据

题目给出 4 组明文和密文,密钥为 121

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

FLAG 密文为:

14CC4655547594BC475584C5848A43551495448C445584C5D4C9475564C534A84B55A4B574BA4355F495A48844556485648C495534A5548C4F5584A5B4BB405554B522332235A4B3

题目说明算法处理 ASCII 字符串,输出只包含 0-9A-F,并且包含字节编码、十六进制位运算、模 16 循环偏移、固定位置重排和固定摘要扰动。

外层格式剥离

观察样例可知,密文每 12 个十六进制字符为一组。满 3 字节块的格式为:

q = ab cd ef
ct = a4 bc 4d 55 e4 f5

也就是在真实中间码 abcdef 中插入固定字符 45545。因此满块取密文块的第 0,2,3,5,8,10 个字符即可恢复 3 个中间字节。

例如:

TLU -> 94AA48550495

9 4 A A 4 8 5 5 0 4 9 5
^   ^ ^   ^     ^   ^

剥离得到:9A A8 09

尾块格式由明文长度对 3 取模决定。

剩 2 字节时:

q = ab cd
ct = 2235 a4 bc 4d 53

真实中间码位于第 4,6,7,9 个字符。

剩 1 字节时:

q = ab
ct = 22332235 a4 b3

真实中间码位于第 8,10 个字符。

按这个规则剥离样例,得到 inner 中间码:

TLU    -> 9AA809
Hello  -> 3BB00B9B9C
2026   -> A8846868
abcd!  -> 57B78B6BB5

FLAG 外层剥离后得到:

1CC6579BC78C88A31948C48CDC976C3A8BAB7BA3F9A8846868C93A58CF8ABBB05BAB

外层只依赖块长度插入固定模板,尾部的 223522332235 也是长度相关标记,内容不参与这些固定字符的计算。

inner 字节变换

先看完整 3 字节样例 TLU

明文字节为:

T = 0x54
L = 0x4C
U = 0x55

inner 为:

9A A8 09

若先把字节拆成高、低两个十六进制半字节,并尝试模 16 偏移:

hi = high + 5 mod 16
lo = low  + 4 mod 16

则:

U = 0x55 -> (5+5, 5+4) = A9
T = 0x54 -> (5+5, 4+4) = A8
L = 0x4C -> (4+5, C+4) = 90

再结合密钥 121,把每个输出位置的两个半字节做循环旋转:

key digit = 1 -> 两个半字节旋转 1 位,即交换 hi/lo
key digit = 2 -> 两个半字节旋转 2 位,即回到原序

得到:

输出位置 0,源字节 U:A9 经过 key 1 交换 -> 9A
输出位置 1,源字节 T:A8 经过 key 2 保持 -> A8
输出位置 2,源字节 L:90 经过 key 1 交换 -> 09

正好匹配 TLU -> 9AA809

继续验证 2026。如果输出源字节顺序为 6,0,2,2

6 = 0x36 -> (3+5, 6+4) = 8A -> key 1 交换 -> A8
0 = 0x30 -> (3+5, 0+4) = 84 -> key 2 保持 -> 84
2 = 0x32 -> (3+5, 2+4) = 86 -> key 1 交换 -> 68
2 = 0x32 -> (3+5, 2+4) = 86 -> key 1 交换 -> 68

得到 A8846868,与样例一致。

因此 inner 的字节级编码为:

byte -> high/low nibble
high += 5 mod 16
low  += 4 mod 16
按输出位置循环使用 key=121 旋转两个 nibble

密钥 121 中每个数字的作用为:

1: 两个 nibble 旋转 1 位,即交换
2: 两个 nibble 旋转 2 位,即保持原序
1: 两个 nibble 旋转 1 位,即交换

按输出位置循环后就是:

swap, keep, swap, swap, keep, swap, ...

位置重排

撤销 nibble 偏移和 key 旋转后,可以得到一个“重排后的明文字节流”。

对四个样例分别处理:

9AA809       -> UTL
3BB00B9B9C   -> olleH
A8846868     -> 6022
57B78B6BB5   -> !cdba

这些结果和原文之间的关系如下:

TLU    -> UTL    下标顺序 [2,0,1]
2026   -> 6022   下标顺序 [3,1,2,0]
Hello  -> olleH  下标顺序 [4,2,3,1,0]
abcd!  -> !cdba  下标顺序 [4,2,3,1,0]

可以归纳出重排规则:

  1. 先将所有明文字节整体逆序;
  2. 再对每 3 个输出位置做局部重排 [0,2,1],也就是交换每组三元组里的第 2 项和第 3 项。

用下标表示,长度为 n 时:

order = list(reversed(range(n)))
for i in range(0, n, 3):
    if i + 2 < n:
        order[i + 1], order[i + 2] = order[i + 2], order[i + 1]

几个样例长度对应的顺序为:

n = 3: reverse [2,1,0]     -> [2,0,1]
n = 4: reverse [3,2,1,0]   -> [3,1,2,0]
n = 5: reverse [4,3,2,1,0] -> [4,2,3,1,0]

这正好同时解释 4 组样例。

完整加密流程

设明文长度为 n

第一步,计算输出字节的源下标顺序:

order = reversed(range(n))
每 3 个位置交换第 2、3 项

第二步,按 order 取明文字节,对每个字节做十六进制半字节偏移:

hi = (byte >> 4) + 5 mod 16
lo = (byte & 0xf) + 4 mod 16

第三步,按输出位置 j 使用 key[j % 3]

key 1 -> swap(hi, lo)
key 2 -> keep(hi, lo)

第四步,将得到的 inner 字节按外层模板写成最终十六进制密文:

满 3 字节:ab cd ef -> a4 bc 4d 55 e4 f5
剩 2 字节:ab cd    -> 2235 a4 bc 4d 53
剩 1 字节:ab       -> 22332235 a4 b3

解密 FLAG

FLAG 外层剥离后的 inner 为:

1CC6579BC78C88A31948C48CDC976C3A8BAB7BA3F9A8846868C93A58CF8ABBB05BAB

先逐字节撤销 key 旋转和模 16 偏移,可得到重排后的明文:

}r!est4_M0ptyCr_7fc_K6022u_1{Tglaf

FLAG 长度为 34。长度 34 的位置顺序为:

[33, 31, 32, 30, 28, 29, 27, 25, 26, 24, 22, 23, 21, 19, 20, 18, 16, 17, 15, 13, 14, 12, 10, 11, 9, 7, 8, 6, 4, 5, 3, 1, 2, 0]

把重排后的明文字节按该顺序放回原位置,得到:

flag{T1u_2026_Kc7f_Crypt0_M4ster!}

并且将该 flag 按完整加密流程重新加密,可以得到题目给出的 FLAG 密文。

Exp

from __future__ import annotations


KEY = "121"

SAMPLES = {
    b"TLU": "94AA48550495",
    b"Hello": "34BB405504B5223594B94C53",
    b"2026": "A48844556485223322356483",
    b"abcd!": "547B475584B5223564BB4553",
}

FLAG_CT = (
    "14CC4655547594BC475584C5848A43551495448C445584C5D4C9475564C534A84B55"
    "A4B574BA4355F495A48844556485648C495534A5548C4F5584A5B4BB405554B522332235A4B3"
)


def byte_order(n: int) -> list[int]:
    order = list(reversed(range(n)))
    for i in range(0, n, 3):
        if i + 2 < n:
            order[i + 1], order[i + 2] = order[i + 2], order[i + 1]
    return order


def encode_inner(plain: bytes) -> bytes:
    out = bytearray()
    order = byte_order(len(plain))
    for j, src in enumerate(order):
        b = plain[src]
        hi = ((b >> 4) + 5) & 0xF
        lo = ((b & 0xF) + 4) & 0xF
        if int(KEY[j % len(KEY)]) % 2:
            hi, lo = lo, hi
        out.append((hi << 4) | lo)
    return bytes(out)


def wrap_outer(q: bytes) -> str:
    parts: list[str] = []
    full = len(q) // 3
    rem = len(q) % 3

    for i in range(full):
        hx = f"{q[3 * i]:02X}{q[3 * i + 1]:02X}{q[3 * i + 2]:02X}"
        parts.append(
            hx[0]
            + "4"
            + hx[1]
            + hx[2]
            + "4"
            + hx[3]
            + "55"
            + hx[4]
            + "4"
            + hx[5]
            + "5"
        )

    if rem == 1:
        hx = f"{q[3 * full]:02X}"
        parts.append("22332235" + hx[0] + "4" + hx[1] + "3")
    elif rem == 2:
        hx = f"{q[3 * full]:02X}{q[3 * full + 1]:02X}"
        parts.append("2235" + hx[0] + "4" + hx[1] + hx[2] + "4" + hx[3] + "53")

    return "".join(parts)


def encrypt(plain: bytes) -> str:
    return wrap_outer(encode_inner(plain))


def peel_outer(cipher_hex: str, plain_len: int) -> bytes:
    blocks = [cipher_hex[i : i + 12] for i in range(0, len(cipher_hex), 12)]
    out = bytearray()
    full = plain_len // 3
    rem = plain_len % 3

    for i in range(full):
        c = blocks[i]
        out.extend(bytes.fromhex(c[0] + c[2] + c[3] + c[5] + c[8] + c[10]))

    if rem == 1:
        c = blocks[full]
        out.extend(bytes.fromhex(c[8] + c[10]))
    elif rem == 2:
        c = blocks[full]
        out.extend(bytes.fromhex(c[4] + c[6] + c[7] + c[9]))

    return bytes(out)


def decode_inner(q: bytes) -> bytes:
    plain = bytearray(b"\x00" * len(q))
    order = byte_order(len(q))

    for j, y in enumerate(q):
        hi = y >> 4
        lo = y & 0xF
        if int(KEY[j % len(KEY)]) % 2:
            hi, lo = lo, hi
        b = (((hi - 5) & 0xF) << 4) | ((lo - 4) & 0xF)
        plain[order[j]] = b

    return bytes(plain)


def decrypt(cipher_hex: str, plain_len: int) -> bytes:
    return decode_inner(peel_outer(cipher_hex, plain_len))


def main() -> None:
    for pt, ct in SAMPLES.items():
        assert encrypt(pt) == ct
        assert decrypt(ct, len(pt)) == pt

    flag = decrypt(FLAG_CT, 34)
    assert encrypt(flag) == FLAG_CT
    print(flag.decode())


if __name__ == "__main__":
    main()

运行结果:

flag{T1u_2026_Kc7f_Crypt0_M4ster!}

唯一性讨论

如果题目只给出“固定摘要扰动只与输入长度有关”,同时没有限定扰动表的生成方式和规模,样例无法证明所有可能算法中只有这一种还原方式。长度相关扰动可以是一张固定表。

设上面还原出的可逆主体算法为 Core,外层剥离后的 FLAG 中间码为 C34。任选另一个 34 字节、格式正确、全可打印的字符串:

flag{not_unique_without_hint_0000}

定义新的长度扰动表:

Mask[3] = 0
Mask[4] = 0
Mask[5] = 0
Mask[34] = C34 xor Core(flag{not_unique_without_hint_0000})

令新算法中间层为:

AltInner(p) = Core(p) xor Mask[len(p)]

该构造仍然只使用字节编码、十六进制位运算、模 16 偏移、固定位置重排,以及只由输入长度决定的固定扰动。由于样例长度分别为 3、4、5,扰动均为 0,四组样例全部保持一致;长度为 34 的 FLAG 密文会被解成上面选定的替代字符串。

该替代字符串对应的长度 34 固定扰动为:

00421FD364C4041E32830CB6402B57B64070416A62F436B3F401006300A100000000

严格唯一性需要额外限定摘要扰动的具体生成方式或表规模。在由样例直接归纳出的最小自然模型中,算法可逆,FLAG 为:

flag{T1u_2026_Kc7f_Crypt0_M4ster!}

传递专业知识、拓宽行业人脉——看雪讲师团队等你加入!!

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