吾爱破解 - 52pojie.cn

 找回密码
 注册[Register]

QQ登录

只需一步,快速开始

查看: 995|回复: 8
收起左侧

[CTF] 2026春节题目

[复制链接]
仿佛_一念成佛 发表于 2026-3-4 01:03
本帖最后由 仿佛_一念成佛 于 2026-3-5 23:09 编辑

又到了每年一度的解题环节了,我也是等了蛮久的,去年的这个时候都已经结束好久了,今年的春节太后面了,导致我以为都不会有了。

好吧,话不多说,我上一下自己的解题思路吧。纯小白

Windows 初级题(二)

用 Ida 打开后,shift + F12 可以看到如下关键词

双击点进[+] Correct flag:,可以看到一个关键函数sub_BCD130,这个函数就是验证函数了。

在左侧的窗口中找到start函数,按F5反编译,发现调用了验证函数。

result = sub_BCD130(dword_BDF024);

双击点进,就看到了这个验证函数的反编译代码了,分析一下这个函数的流程:

  1. 首先就打印了一个横幅,表示一些提示信息,告诉我们正确的 flag 长度是关键。

  1. 接着就让用户输入密码了,然后读取用户输入的内容

  1. 检查输入内容(sub_B01740) (第一个陷阱)

这一步检查了输入的内容,其内容为

中间有一个v2 = byte_BD3019[v1++]。这里是一个关键点,它是一个数组,保存着一个假 Flag,也就是52pojie_2026_HappyNewYear,这是一个诱饵。通过 Hex view 可以看到这个诱饵。

函数是从53(ASCII '5')开始的,然后逐字符读取byte_BD3019里的内容。

  1. 前 16 字符对比 (第二个陷阱)

这一步是对输入的前十六个字符进行对比,这里也有一个数组(byte_BD3032), 其内容为52pojie2026Happy. 如果此刻输入的内容前 16 为刚好匹配,就会进入You're getting closer 的分支,提示我们输入的内容已经很接近了。

  1. 长度验证

通过前面两个陷阱,我们知道了两个线索,现在让我们继续分析下一部分的代码

此处代码提示我们密码必须要求为 31 个字符。也就是说,正确的 flag 长度必须为 31 个字符。

  1. XOR 缓冲区比对

这是核心的验证部分了。程序调用了一个函数sub_B016D0来判断是否是正确的 flag

双击进入这个函数,查看具体的实现

在这里有一个非常关键的函数,也就是sub_B01620。这一步直接就告诉了我们正确的答案。

通过这里的分析,可以知道每个字节进行了 XOR 0x42 的操作。交给 AI 来帮我们写一个简单的脚本来解密这个 XOR 缓冲区,得到正确的 flag。

import struct

buf = bytearray(32)
struct.pack_into('<I', buf,  0, 758280311)
struct.pack_into('<I', buf,  4, 1663511336)
struct.pack_into('<I', buf,  8, 1880974179)
struct.pack_into('<I', buf, 12, 494170226)
struct.pack_into('<I', buf, 16, 842146570)
struct.pack_into('<I', buf, 20, 657202491)
struct.pack_into('<I', buf, 24, 658185525)
struct.pack_into('<H', buf, 28, 12323)
buf[30] = 99

for i in range(31):
    buf[i] ^= 0x42

password = buf[:31].decode('ascii')
print(f"Password: {password}")
print(f"Length:   {len(password)}")

checksum = sum((i + 1) * c for i, c in enumerate(password.encode()))
print(f"Checksum: {checksum} (expected: 44709)")

输出:

Password: 52pojie!!!_2026_Happy_new_year!
Length:   31
Checksum: 44709 (expected: 44709)

输出的结果和我们前面知道的线索完全匹配,长度为 31,前 16 字符也不完全匹配,校验和也正确。

哦对了,忘记说怎么知道这个 checksum 了。

  1. Checksum 验证

很简单,它直接写死了哈哈

至于计算方法也很简单

checksum = Σ (i × password[i-1])   for i = 1, 2, ..., len
         = 1×p[0] + 2×p[1] + 3×p[2] + ... + 31×p[30]
  1. 最终验证

从上面的分析结果,我们知道了正确的 flag 是52pojie!!!_2026_Happy_new_year!,我们可以直接输入这个 flag 来验证一下。

至此,Windows 初级(二)就算是完全的解出来了。

Android 初级题(一)

简单的 Android 初级题,对于这个,咱有两个解法,一是好好的完成游戏然后 flag 就会跳出来了。二是直接反编译这个 APK,找到关键函数,分析一下就知道了。

咱们这里的选择是第二种,反编译分析。

先用jadx打开这个 APK,搜索 int[][], 找到一个关键数组。这个数据很有可能就是加密后的数据,后面的解密也证实了确实是加密后的重要 flag 数据。另外按照经验,通常会用 XOR 来加密,所以咱们现在来找一下是谁调用了这个关键的数组

既然找到了这个关键数组,根据经验,52 的春节题目一般的都是 XOR 加密的,所以现在来找一下密钥在哪里。

定位到 F.C.q0 方法,并且在下方看到了一个可疑的数据,假设这个 bytes 数据就是密钥,接下来找一下解密方法在哪里

已知在传入的时候定义了 25,在代码中往上面翻一番,找到 case25 的分支,发现了这个解密

好了,现在知道了加密数据和密钥了,接下来就可以写一个简单的脚本来解密了。

key = [0x36, 0x01, 0x16, 0x1C]
data = [
    [80, 109, 119, 123, 77],
    [97, 116, 34, 45, 105],
    [102, 49, 124, 45, 5, 94],
    [4, 49, 36, 42, 105],
    [101, 113, 100, 45, 88, 102, 73],
    [112, 50, 101, 104, 7, 119, 34, 112, 75]
]

flag = ''
for part in data:
    for i, val in enumerate(part):
        flag += chr(val ^ key[i % len(key)])

print(flag)  # flag{Wu41_P0j13_2026_Spr1ng_F3st1v4l}

输出:

flag{Wu41_P0j13_2026_Spr1ng_F3st1v4l}

或者咱们静态分析,直接用 Frida 来 hook 这个解密函数,直接把解密后的结果打印出来也是可以的。这里就不赘述了。

Windows 初级题(一)

先用Ida打开随便看一看,发现有PyInstaller特征,故此说明这是一个python程序被打包成了exe,知道这个就好办了。

pyinstxtractor 来解压这个exe,得到一个crakeme_easy.pyc的文件

再用一个工具来反编译这个pyc文件,得到汇编代码

import dis, marshal

with open('crackme_easy.pyc', 'rb') as f:
    f.read(16)  # 跳过 pyc 头
    code = marshal.load(f)

dis.dis(code)

汇编代码特别清晰,直接复制交给AI分析。后得出一个python脚本来验证这个 flag

import base64

enc_data = 'e3w+fiRvfW18fnx4ZAZ6Pj43YwB9OWMXfXo8Dg4O'
encrypted = base64.b64decode(enc_data)
key = 78

flag = bytearray(byte ^ key for byte in encrypted).decode('utf-8')
print(flag)

Windows 中级题(一)

这道题和前面差不多,区别的是前者是用PyInstaller,后者是用Nuitka打包的,其他的流程基本上是一样的。

Step 1:提取 PE 资源

使用 Python pefile 库提取 RCDATA 资源(type=10, name=27):

import pefile
import zstandard

pe = pefile.PE("【2026春节】解题领红包之五 {Windows 中级题} 出题老师:云在天.exe")

for entry in pe.DIRECTORY_ENTRY_RESOURCE.entries:
    if entry.id == 10:  # RT_RCDATA
        for sub in entry.directory.entries:
            if sub.id == 27:
                data_rva = sub.directory.entries[0].data.struct.OffsetToData
                size = sub.directory.entries[0].data.struct.Size
                data = pe.get_data(data_rva, size)

Step 2:解析 Nuitka Onefile 格式

提取的资源数据格式:

  • 前 3 字节:KAY 签名头
  • 之后:文件条目流(文件名 + 大小 + zstd 压缩数据)

每个条目格式:

  1. 2 字节宽字符文件名(以 \0\0 结尾)
  2. 8 字节文件大小(小端序)
  3. zstd 压缩的文件数据
# 跳过 KAY 头
pos = 3
while pos < len(data):
    # 读取文件名(UTF-16LE)
    name_end = data.index(b'\x00\x00', pos)
    filename = data[pos:name_end+1].decode('utf-16-le')
    pos = name_end + 2

    # 读取文件大小
    file_size = int.from_bytes(data[pos:pos+8], 'little')
    pos += 8

    # zstd 解压
    compressed = data[pos:]
    decompressor = zstandard.ZstdDecompressor()
    decompressed = decompressor.decompress(compressed, max_output_size=file_size)
    pos += len(compressed)  # 流式处理

Step 3:提取结果

解压得到 11 个文件:

文件名 大小 说明
crackme_hard.dll 5.9MB 核心逻辑 DLL
python313.dll 5.4MB Python 3.13 运行时
libcrypto-3-x64.dll 4.9MB OpenSSL 加密库
libssl-3-x64.dll 813KB OpenSSL SSL 库
_hashlib.pyd 34KB Python hashlib
select.pyd 26KB Python select
_socket.pyd 83KB Python socket
unicodedata.pyd 1.1MB Unicode 数据
libffi-8.dll 40KB FFI 库
_ctypes.pyd 134KB Python ctypes
_decimal.pyd 259KB Python decimal

重点目标:crackme_hard.dll

分析 crackme_hard.dll

Step 4:提取 Nuitka 常量 Blob

crackme_hard.dll 也是 Nuitka 编译产物。其内部 RCDATA 资源(type=10, name=3)包含 Nuitka 的常量 Blob——存储了原始 Python 代码中的所有常量值(字符串、数字、bytes 等)。

Blob 大小约 5.6MB,格式参考 Nuitka 源码 HelpersConstantsBlob.c

[8字节头部]
[命名段1: 名称 + 常量数量 + 常量数据...]
[命名段2: ...]
...

Step 5:定位 __main__

遍历常量 Blob,找到 __main__ 段(偏移 0x55F787),包含 83 个常量

Step 6:提取加密参数

__main__ 的常量中发现关键数据:

_parts = [
    b'\xdc', b'!a;', b'\x60b\x11', b'cacg',
    b'/\x19e!!', b'(\x0e\x1fb&', b'\x0e\x08be#',
    b'ppp', ...  # 共10个bytes片段
]
_key = 81        # 0x51
_total_len = 30  # flag总长度30字节

解密 Flag

Step 7:XOR 解密

算法非常简单:将所有 _parts 拼接后,逐字节与 _key(0x51) 异或:

parts = [
    b'\xdc', b'!a;', b'\x60b\x11',
    b'cacg', b'/\x19e!!',
    b'(\x0e\x1fb&', b'\x0e\x08be#',
    b'ppp'
]
# 实际提取的完整 encrypted bytes (30字节)

key = 81  # 0x51
encrypted = b''.join(parts)  # 拼接所有分片

flag = bytes([b ^ key for b in encrypted])
print(flag.decode())

解密过程示意

加密字节: dc 21 61 3b 60 62 11 63 61 63 67 2f 19 65 21 21 28 0e 1f 62 26 0e 08 62 65 23 70 70 70 ...
XOR 0x51: ^  ^  ^  ^  ^  ^  ^  ^  ^  ^  ^  ^  ^  ^  ^  ^  ^  ^  ^  ^  ^  ^  ^  ^  ^  ^  ^  ^  ^
结果:      35 70 30 6a 31 33 40 32 30 32 36 7e 48 34 70 70 79 5f 4e 33 77 5f 59 33 34 72 21 21 21
ASCII:     5  p  0  j  1  3  @  2  0  2  6  ~  H  4  p  p  y  _  N  3  w  _  Y  3  4  r  !  !  !

Flag

52p0j13@2026~H4ppy_N3w_Y34r!!!

番外篇

小猫题,玩一下就过了。很简单。或者分析一下得出结果。

用zip的方式打开exe,发现有一个main.lua. 打开它,搜索关键词flag,发现调用了assert/flag.dat,打开看一下,发现是乱码,猜测是加密过的。不管它,往下面翻一翻,看见了加密key和方式,直接写个脚本来解密就好了。

data = open("extracted/assets/flag.dat", "rb").read()
key = b"52pojie"
result = bytes([b ^ key[i % len(key)] for i, b in enumerate(data)])
print(result.decode())

Windows 中级题(二)

IDA 中打开 CM1.exe,发现只有 3 个函数,段名为 UPX1,典型 UPX 加壳特征:

UPX 脱壳

IDA 中打开 CM1.exe,发现只有 3 个函数,段名为 UPX1,典型 UPX 加壳特征:

函数列表:
  0x1400261E0  start         (UPX 入口)
  0x140026212  sub_140026212 (UPX memcpy)
  0x140026250  sub_140026250 (UPX 解压主体)

解压后跳转至 0x1400013D0(真正的 OEP)。使用 UPX 命令行脱壳:

upx -d CM1.exe -o CM1_unpacked.exe
# 122880 <- 102400  83.33%  win64/pe  CM1_unpacked.exe

字符串分析

脱壳后提取关键字符串:

虚拟地址 字符串 用途
0x14000A000 https://www.52pojie.cn/?176017 关于链接
0x14000A028 52pojie_2026_\x00 (14字节) 加密盐值
0x14000A048 flag.png.encrypted 输入文件名
0x14000A05B flag.png 输出文件名
0x14000A064 flag{HEX_ME} GUI 编辑框默认文本
0x14000A270 256 字节数据 AES S-Box

字符串 flag{HEX_ME} 通过 SetDlgItemText(hWnd, 4, "flag{HEX_ME}") 设置为密码输入框的默认文本,是一个提示而非密码本身。


加密算法逆向

文件格式

flag.png.encrypted 的格式如下:

偏移    大小    内容
0x00    4       魔数 "CM26" (0x36324D43)
0x04    4       明文 CRC-32 校验值 (~CRC32)
0x08    8       IV 初始化向量
0x10    N       密文数据(8 字节对齐,含填充)

实际数据:

00000000: 434d 3236 a245 848d f569 7360 01cb 35bc  CM26.E...is`..5.
00000010: fbdd 1b92 2bef e323 10eb 814c 4504 4895  ....+..#...LE.H.
...

密钥派生

函数 0x140008720 实现完整的解密流程:

① CRC-64 初始化 (0x140008640)

使用多项式 0xC96C5795D7870F42(CRC-64/ECMA-182)构建 256 项查找表。初始值 0xFFFFFFFFFFFFFFFF

② CRC-64 更新 (0x140008500)

依次处理 14 字节盐值 "52pojie_2026_\x00" 和用户输入的密码:

crc64_update(ctx, "52pojie_2026_\x00", 14);  // 盐值
crc64_update(ctx, password, strlen(password)); // 密码

③ CRC-64 终结 (0x140008580)

对 CRC 值追加 4 字节计数器并取反:

crc = crc64_update(crc, counter_bytes, 4);
hash = ~crc;

最终得到 64 位密钥哈希。

分组加密

块密码 (0x140008080),8 字节分组,CBC 模式:

密钥变换(每个分组前执行):

key = ROL(key, 3);                  // 循环左移 3 位
for (int i = 0; i < 8; i++) {
    high_byte = key >> 56;
    key = (key << 8) | AES_SBOX[high_byte];  // S-Box 字节替换
}

解密公式

明文[i] = 密文[i] ⊕ 变换后密钥[i] ⊕ IV[i]

IV 更新(CBC 模式):

IV = 当前密文块    // 用于下一块解密

完整性校验

CRC-32 验证 (0x140008480 + 0x1400082E0),多项式 0xEDB88320

  • 解密过程中对所有明文计算 CRC-32
  • 终结时比对 ~CRC32 与文件头偏移 0x04 处存储的值
  • 匹配则密码正确

已知明文攻击

题目提示"暴力枚举不可取",引导我们使用已知明文攻击

原理

PNG 文件固定以 8 字节魔数开头:

89 50 4E 47 0D 0A 1A 0A

这恰好等于加密的分组大小(8 字节)。由解密公式:

明文 = 密文 ⊕ 密钥 ⊕ IV

可以反推:

密钥 = 密文 ⊕ IV ⊕ 明文

计算

从加密文件中提取:

IV(偏移 0x08):      F5 69 73 60 01 CB 35 BC
第一密文块(偏移 0x10): FB DD 1B 92 2B EF E3 23
PNG 文件头(已知明文):  89 50 4E 47 0D 0A 1A 0A

逐字节异或:

字节 密文 IV 明文 密钥 = CT⊕IV⊕PT
0 FB F5 89 87
1 DD 69 50 E4
2 1B 73 4E 26
3 92 60 47 B5
4 2B 01 0D 27
5 EF CB 0A 2E
6 E3 35 1A CC
7 23 BC 0A 95

得到变换后密钥(LE 字节序):87 E4 26 B5 27 2E CC 95

对应 64 位整数:0x95CC2E27B526E487

逆推原始 CRC-64 哈希

① 逆 AES S-Box

变换后密钥的每个字节都经过了 S-Box 替换。对每个字节查 AES 逆 S-Box 表,还原 ROL(hash, 3) 的值:

inv_sbox = [0] * 256
for i in range(256):
    inv_sbox[AES_SBOX[i]] = i

original_bytes = bytes([inv_sbox[b] for b in tk_bytes])
# rol3_value = 0xAD27C33DD223AEEA

② 逆循环左移

crc64_hash = ROR(rol3_value, 3)
# crc64_hash = 0x55A4F867BA4475DD

完整解密

利用恢复的密钥,无需知道密码即可解密全部数据:

import struct

key = 0x55A4F867BA4475DD     # 恢复的 CRC-64 哈希
current_iv = enc[8:16]        # 8 字节 IV
ciphertext = enc[16:]         # 密文数据
plaintext = bytearray()

for block_start in range(0, len(ciphertext), 8):
    block = ciphertext[block_start:block_start+8]
    saved_ct = list(block)

    # 密钥变换:ROL 3 + S-Box×8
    key = ((key << 3) | (key >> 61)) & 0xFFFFFFFFFFFFFFFF
    for _ in range(8):
        high = (key >> 56) & 0xFF
        key = ((key << 8) & 0xFFFFFFFFFFFFFFFF) | AES_SBOX[high]

    key_bytes = struct.pack('<Q', key)

    # 解密:明文 = 密文 ⊕ 密钥 ⊕ IV
    for i in range(8):
        plaintext.append(block[i] ^ key_bytes[i] ^ current_iv[i])

    current_iv = bytes(saved_ct)  # CBC: IV 更新为当前密文块

# 移除 PKCS 填充
pad = plaintext[-1]
plaintext = plaintext[:-pad]

运行结果:

首 8 字节: 89504e470d0a1a0a  ← 合法 PNG 文件头!
填充字节: 2(移除 2 字节填充)
解密数据: 350 字节

成功解密出 flag.png,图像为像素字体 "HEX_ME"(产品标志)。


提取 Flag

解密后检查 PNG 的元数据,发现 tEXt 块中隐藏了 flag:

Chunk: tEXt (Software)
  → Pixilart (Pixel Art Editor)

Chunk: tEXt (Comment)
  → Post-processed with a hex editor    ← 提示 flag 是用 hex 编辑器写入的

Chunk: tEXt (Description)
  → flag{EncrypTIoN_Is_haRd_52p0jIE_2o26_m62Tc4uj78maAq1C}    ← FLAG!

Comment 字段的 "Post-processed with a hex editor" 与题目名 "HEX_ME" 相呼应,暗示 flag 是通过十六进制编辑器写入 PNG 元数据的。


Flag

flag{EncrypTIoN_Is_haRd_52p0jIE_2o26_m62Tc4uj78maAq1C}

Android 中级题(二)

分析过后发现是在so层,分析困难度较大,放弃了。

Web 中级题

先打开玩了一下,发现验证码巨长,我又听不太懂说的啥,感觉是个大坑,换一条路。

WASM 逆向分析

工具链

使用 wabt 工具链进行 WASM 反编译:

# 提取 WASM 二进制
node -e "eval(require('fs').readFileSync('assets/verify.wasm.js','utf8')); \
  require('fs').writeFileSync('verify.wasm', Buffer.from(getWasmBuffer()))"

# 反编译为 WAT 文本格式(23,344 行)
wasm2wat verify.wasm -o verify.wat

# 反编译为 C 代码(363,903 行)
wasm2c verify.wasm -o verify.c

gen 函数对应 w2c_verify_gen_0(verify.c 第 357396 行),是整个题目的核心。

gen() 函数内部流程

第一步:获取 17 字节随机数
// verify.c:357436-357439
var_i0 = var_l3 + 80;   // 缓冲区地址
var_i1 = 17;             // 长度
w2c_wbg_getRandomValues(instance, var_i0, var_i1);
// random[0..16] 存储在 var_l3+80 到 var_l3+96

通过 hook 确认:getRandomValues 在整个 gen() 过程中仅被调用一次,请求恰好 17 字节。

第二步:构建 37 字节种子缓冲区
// verify.c:357778-357836
var_l9 = malloc(37, 1);  // 分配 37 字节

// bytes 0-3: random[0..3] XOR uid 各字节(字节序反转)
var_l9[0] = random[0] ^ (uid & 0xFF);         // XOR uid 低 8 位
var_l9[1] = random[1] ^ ((uid >> 8) & 0xFF);  // XOR uid 次低 8 位
var_l9[2] = random[2] ^ ((uid >> 16) & 0xFF); // XOR uid 次高 8 位
var_l9[3] = random[3] ^ ((uid >> 24) & 0xFF); // XOR uid 高 8 位

// bytes 4-11: random[0..7] 直接复制
memcpy(var_l9 + 4, random, 8);

// bytes 12-19: random[8..15] 直接复制
memcpy(var_l9 + 12, random + 8, 8);

// byte 20: random[16] 直接复制
var_l9[20] = random[16];

至此前 21 字节已填充完毕。

第三步:HMAC-SHA256 计算填充 bytes 21-36
// verify.c:357849-358037 (简化)

// 初始化 SHA-256 状态(HMAC 内层)
// 从地址 1295967 加载 14 字节初始值
memcpy(buffer, mem+1295967, 14);

// 将前 64 字节数据与 0x36 XOR(HMAC ipad)
for (int i = 0; i < 64; i += 4) {
    buffer[i]   ^= 0x36;
    buffer[i+1] ^= 0x36;
    buffer[i+2] ^= 0x36;
    buffer[i+3] ^= 0x36;
}

// SHA-256 压缩(内层 hash = SHA256(ipad || seed_buffer))
w2c_verify_f9(instance, sha_state, buffer, 1);

// 将同一 buffer 再与 0x6A XOR(0x36 ^ 0x6A = 0x5C = HMAC opad)
for (int i = 0; i < 64; i += 4) {
    buffer[i]   ^= 0x6A;  // 0x36 ^ 0x6A = 0x5C
    buffer[i+1] ^= 0x6A;
    buffer[i+2] ^= 0x6A;
    buffer[i+3] ^= 0x6A;
}

// SHA-256 压缩(外层 hash = SHA256(opad || inner_hash))
w2c_verify_f9(instance, sha_state2, buffer, 1);

这实际上是一个 HMAC-SHA256 计算。最终取前 16 字节写入 var_l9[21..36],完成 37 字节种子缓冲区。

第四步:Base64-like 编码(37 字节 → 50 字符)
// verify.c:358799-358933 (简化伪代码)

char *code_array = malloc(200, 4);  // 50 个 u32 元素
int char_idx = 0;
int bit_acc = 0;     // 位累加器
int bit_pos = 0;     // 当前位位置
byte *ptr = seed;    // 指向 37 字节种子

for (int byte_idx = 0; byte_idx < 37; byte_idx++) {
    // 将当前字节加入位累加器
    bit_acc = *ptr | (bit_acc << 8);

    // 循环提取 6-bit 块
    while (bit_pos + 2 > 5) {
        int idx = (bit_acc >> (bit_pos + 2)) & 0x3F;
        char ch = charset[idx];   // charset 在内存地址 1295903
        code_array[char_idx++] = ch;
        bit_pos -= 6;
    }
    bit_pos += 8;
    ptr++;
}

// 处理最后剩余的 bits
if (remaining_bits > 0) {
    int idx = (last_byte << (6 - remaining_bits)) & 0x3F;
    code_array[char_idx++] = charset[idx];
}

字符表位于 WASM 线性内存地址 1295903

abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789?!

编码过程类似 Base64:每 6 bit 索引一个字符,37 字节 = 296 bit → 296/6 = 49.33 → 上取整得到 50 个字符

第五步:计算验证哈希

WASM 内部对编码后的 50 字符验证码做 8230 次 SHA-256 迭代,生成 64 位 hex 字符串作为 hash h

第六步:生成 TTS 语音

WASM 内部包含一个 TTS 引擎,将 50 个字符逐个合成为中文语音朗读(根据 voice 参数选择方言)。生成 24kHz 16bit 单声道 WAV 音频,约 38 秒。

语音朗读方式:

  • 普通话(c):区分大小写,如"大写A"、"小写b"、"数字3"、"问号"、"叹号"
  • 粤语(y):粤语发音
  • 中文美式(e):美式英语发音
第七步:清零所有中间数据

最关键的保护措施:在构建返回对象之前,WASM 将以下数据全部清零/释放:

  • 37 字节种子缓冲区
  • 50 元素字符数组
  • 所有 SHA-256 中间状态
  • HMAC 计算缓冲区

这意味着验证码明文从未通过任何 JS 导入函数传出——它完全在 WASM 内部生成、编码、哈希、清零,最终只有 hash 和音频数据通过 JS 导入的 set 函数设置到返回对象上。


既然数据在 WASM 内部被生成后立即销毁,那就修改 WASM 字节码,在数据被销毁之前将其"泄漏"到 JS 层。

WASM 二进制热补丁 + 双通道执行

补丁 1:消除 XOR 混淆

WASM 内部在将字符传递给后续处理前,会对每个字符的 ASCII 值做 XOR 0xCC。这会将正常 ASCII 字符变成高位字节,无法作为可识别字符传出。

在 WASM 字节码中搜索所有 i32.const 204; i32.xor 指令序列并替换为 i32.const 0; i32.xor(等价于 no-op):

原始字节码:41 CC 01 73    →  i32.const 204 (0xCC); i32.xor
补丁字节码:41 80 00 73    →  i32.const 0;         i32.xor
const xorPattern = Buffer.from([0x41, 0xCC, 0x01, 0x73]);
const xorReplace = Buffer.from([0x41, 0x80, 0x00, 0x73]);
let p = 0;
while (p < patchedWasmBuffer.length - 4) {
    const idx = patchedWasmBuffer.indexOf(xorPattern, p);
    if (idx === -1) break;
    xorReplace.copy(patchedWasmBuffer, idx);
    p = idx + 1;
}

补丁 2:重定向函数调用

在 WASM 内部,每个编码后的字符会通过 call 19(一个内部字符串构建函数 w2c_verify_f19)进行处理。我们将偏移量 33810 处的 call 19 重定向为 call 4——而 call 4 恰好是 getRandomValues 的导入函数。

这样,每个字符的 ASCII 值会作为 len 参数传递到我们 hook 的 getRandomValues 回调中:

WASM 偏移 33810:
原始字节码:10 13    →  call 19 (内部函数)
补丁字节码:10 04    →  call 4  (getRandomValues 导入)
patchedWasmBuffer[33810] = 0x04;

JS 层 hook:捕获字符

Hook getRandomValues 导入函数。第一次调用是真正的随机数请求(17 字节),后续调用是我们补丁注入的字符泄漏——len 参数就是字符的 ASCII 值:

imports.wbg.__wbg_getRandomValues = function(arg0, arg1) {
    randomCallCount++;
    if (randomCallCount === 1) {
        // 第一次:真正的 getRandomValues,注入固定随机数
        arr.set(fixedRandom);
    } else {
        // 后续:补丁注入的字符泄漏
        // arg1 (len) = 字符的 ASCII 值
        if (len >= 33 && len <= 122 && CHARSET.includes(String.fromCharCode(len))) {
            capturedChars.push(String.fromCharCode(len));
        }
    }
};

双通道执行策略

由于补丁修改了 XOR 常量,打补丁的 WASM 生成的 hash 与原始 WASM 不同。因此需要两次执行:

Pass 1(打补丁的 WASM)
  ├── 注入固定 17 字节随机数
  ├── XOR 被置零 → 字符保持明文 ASCII
  ├── call 19 → call 4 → 每个字符通过 getRandomValues 泄漏
  └── 输出:捕获 50 个明文字符

Pass 2(原始未修改的 WASM)
  ├── 注入相同的 17 字节随机数
  ├── 正常执行所有逻辑
  └── 输出:正确的 SHA-256 hash

验证:SHA-256(捕获的 code, 8230 次) == Pass 2 的 hash
  └── Match: true ✓

关键在于:相同的随机数 → 相同的验证码。voice 参数只影响语音合成,不影响验证码内容和 hash。

最终验证

let current = Buffer.from(code, 'utf-8');
for (let i = 0; i < 0x2026; i++) {
    current = crypto.createHash('sha256').update(current).digest();
}
const computedHash = current.toString('hex');
console.log(`Match: ${computedHash === originalHash}`);
// → Match: true

脚本

const fs = require('fs');
const path = require('path');
const crypto = require('crypto');

const CHARSET = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789?!";

// 加载 WASM 二进制
globalThis.window = globalThis;
globalThis.atob = (b64) => Buffer.from(b64, 'base64').toString('binary');
eval(fs.readFileSync('assets/verify.wasm.js', 'utf-8'));
const originalWasmBuffer = Buffer.from(globalThis.getWasmBuffer());

// 生成固定随机数(确保两次执行使用相同的种子)
const fixedRandom = crypto.randomBytes(17);

// ====== 创建打补丁的 WASM ======
const patchedWasmBuffer = Buffer.from(originalWasmBuffer);

// 补丁 1: XOR 0xCC → XOR 0x00
const xorPattern = Buffer.from([0x41, 0xCC, 0x01, 0x73]);
const xorReplace = Buffer.from([0x41, 0x80, 0x00, 0x73]);
let p = 0;
while (p < patchedWasmBuffer.length - 4) {
    const idx = patchedWasmBuffer.indexOf(xorPattern, p);
    if (idx === -1) break;
    xorReplace.copy(patchedWasmBuffer, idx);
    p = idx + 1;
}

// 补丁 2: call 19 → call 4 (偏移 33810)
patchedWasmBuffer[33810] = 0x04;

// ====== 通用 WASM 执行函数 ======
function runWasm(wasmBuf, onGetRandom, onSetProp) {
    // 构建所有 wasm-bindgen 所需的导入函数
    // 其中 getRandomValues 和 set 被 hook
    const imports = { wbg: { /* ... 所有导入 ... */ } };

    const module = new WebAssembly.Module(wasmBuf);
    const instance = new WebAssembly.Instance(module, imports);
    instance.exports.__wbindgen_start();

    // 调用 gen(734555, "c")
    const ret = instance.exports.gen(734555, voicePtr, voiceLen);
    return result;
}

// ====== Pass 1: 打补丁版本捕获字符 ======
const capturedChars = [];
runWasm(patchedWasmBuffer, (arr, ptr, len) => {
    if (firstCall) {
        arr.set(fixedRandom);  // 注入固定随机数
    } else {
        capturedChars.push(String.fromCharCode(len));  // 捕获字符
    }
}, ...);

// ====== Pass 2: 原始版本获取正确 hash ======
let originalHash = null;
runWasm(originalWasmBuffer, (arr) => {
    arr.set(fixedRandom);  // 注入相同的随机数
}, (obj, key, val) => {
    if (key === 'h') originalHash = val;
});

// ====== 验证 ======
const code = capturedChars.join('');  // 50 字符
let current = Buffer.from(code, 'utf-8');
for (let i = 0; i < 0x2026; i++) {
    current = crypto.createHash('sha256').update(current).digest();
}
console.log(`Match: ${current.toString('hex') === originalHash}`);  // true
console.log(`FLAG: flag{${code}}`);

运行结果

$ node extract_code.js

  • WASM buffer loaded: 4001674 bytes
  • Fixed random bytes: 23a43e72bb29cfb8fa3130be2e48a8d298 === STEP 1: Run patched WASM to capture characters ===   [PATCHED] getRandomValues: wrote fixed random 23a43e72bb29cfb8fa3130be2e48a8d298   [PATCHED] Hash: 80e2e0c9d0238767c8b9b8ba8f9338003977e6bd8be1c1360bb6d89392e745f6   [PATCHED] Captured 50 chars: "Eje1CIoKpNk7kC?4?JeWVI5iQnkyjOF7XN7T408Q4HDtu3Vv6a" === STEP 2: Run original WASM with same random bytes ===   [ORIGINAL] getRandomValues: wrote fixed random   [ORIGINAL] Hash: 49cc46a21f1b91c242181dbdb226736343420d952198c85bbb96d3557f231b31 === STEP 3: Verify ===
  • Captured code (50 chars): "Eje1CIoKpNk7kC?4?JeWVI5iQnkyjOF7XN7T408Q4HDtu3Vv6a"
  • Original hash: 49cc46a21f1b91c242181dbdb226736343420d952198c85bbb96d3557f231b31
  • Computed hash: 49cc46a21f1b91c242181dbdb226736343420d952198c85bbb96d3557f231b31
  • Match: true ========================================     CODE: Eje1CIoKpNk7kC?4?JeWVI5iQnkyjOF7XN7T408Q4HDtu3Vv6a     FLAG: flag{Eje1CIoKpNk7kC?4?JeWVI5iQnkyjOF7XN7T408Q4HDtu3Vv6a} ========================================
  • Windows 高级题

    啥都分析不出来,放弃了。

    MCP 中级题

    这一题是直接交给AI来做的,用Claude跑了有一个小时,后面给出了一个python脚本,直接运行就得到了正确的 flag。

    import subprocess
    import json
    import hashlib
    
    MCP_URL = "https://9863968daeea51ea32f40575dd41dd113.52pojie.cn:3000/mcp"
    PASSPHRASE = "玄霄密令"
    
    def init_mcp():
        """初始化 MCP 传输层会话,从响应头提取 Mcp-Session-Id"""
        data = json.dumps({
            "jsonrpc": "2.0", "id": 1, "method": "initialize",
            "params": {
                "protocolVersion": "2024-11-05",
                "capabilities": {},
                "clientInfo": {"name": "solver", "version": "1.0"}
            }
        }).encode()
        result = subprocess.run(
            ["curl", "-s", "-D", "-", "-X", "POST", MCP_URL,
             "-H", "Content-Type: application/json",
             "-H", "Accept: application/json, text/event-stream",
             "--data-binary", "@-"],
            input=data, capture_output=True, timeout=30
        )
        for line in result.stdout.split(b"\n"):
            if b"mcp-session-id:" in line.lower():
                return line.split(b":", 1)[1].strip().decode()
        raise RuntimeError("无法获取 MCP Session ID,请检查服务器连接")
    
    def tool_call(mcp_sid, tool_name, args):
        """发起工具调用,解析 SSE 响应"""
        data = json.dumps({
            "jsonrpc": "2.0", "id": 1,
            "method": "tools/call",
            "params": {"name": tool_name, "arguments": args}
        }, ensure_ascii=False).encode("utf-8")
        result = subprocess.run(
            ["curl", "-s", "-X", "POST", MCP_URL,
             "-H", "Content-Type: application/json",
             "-H", "Accept: application/json, text/event-stream",
             "-H", f"Mcp-Session-Id: {mcp_sid}",
             "--data-binary", "@-"],
            input=data, capture_output=True, timeout=30
        )
        for line in result.stdout.split(b"\n"):
            if line.startswith(b"data:"):
                try:
                    return json.loads(line[5:].strip().decode("utf-8"))
                except Exception:
                    pass
        raise RuntimeError(f"工具调用 {tool_name} 无响应,原始输出:{result.stdout[:200]}")
    
    def get_content(r):
        """从工具调用响应中提取 JSON 内容"""
        try:
            return json.loads(r["result"]["content"][0]["text"])
        except Exception:
            return {}
    
    def main():
        print("=" * 50)
        print("新岁数字异界 MCP CTF 自动解题脚本")
        print("=" * 50)
    
        # 第一步:建立 MCP 传输层会话
        print("\n[1/9] 初始化 MCP 会话...")
        mcp_sid = init_mcp()
        print(f"  MCP Session ID: {mcp_sid}")
    
        # 第二步:建立应用层会话
        print("[2/9] 启动应用层会话...")
        r = tool_call(mcp_sid, "start_session", {})
        app_sess = get_content(r).get("session_id", "")
        if not app_sess:
            raise RuntimeError("无法获取应用会话 ID")
        print(f"  App Session ID: {app_sess}")
    
        # 第三步:读取所有公开文档(必须,否则审阅会返回 ctf_clues_required)
        print("[3/9] 读取公开文档(解锁审阅权限)...")
        docs_resp = get_content(tool_call(mcp_sid, "list_documents", {"session_id": app_sess}))
        if isinstance(docs_resp, list):
            for doc in docs_resp:
                doc_id = doc.get("id", "")
                tool_call(mcp_sid, "get_document", {"session_id": app_sess, "doc_id": doc_id})
                print(f"  已读取: {doc_id}")
    
        # 第四步:对最终目标文档发起访问,触发拒绝,获取 audit_log_id
        print("[4/9] 触发拒绝访问,获取 audit_log_id...")
        d = get_content(tool_call(mcp_sid, "get_document", {
            "session_id": app_sess,
            "doc_id": "secret:/board/resolutions"
        }))
        aid = d.get("audit_log_id", "")
        if not aid:
            raise RuntimeError(f"未获取到 audit_log_id,服务器返回:{d}")
        print(f"  audit_log_id: {aid}")
    
        # 第五步:计算 seal
        print("[5/9] 计算 seal...")
        seal = hashlib.sha256((aid + PASSPHRASE).encode("utf-8")).hexdigest()[:8]
        print(f"  seal: {seal}")
    
        # 第六步:审阅文档,获取 tool_access_token(仅能调用一次!)
        print("[6/9] 审阅 confidential:/audit/logs,获取 tool_access_token...")
        review_notes = f"ctf_request={PASSPHRASE}\naudit_log_id={aid}\nseal={seal}"
        d = get_content(tool_call(mcp_sid, "review_document", {
            "session_id": app_sess,
            "doc_id": "confidential:/audit/logs",
            "review_notes": review_notes,
            "access_token": seal
        }))
        if d.get("status") != "challenge_issued":
            raise RuntimeError(f"审阅失败,返回:{d}")
        token = d.get("tool_access_token", "")
        if not token:
            raise RuntimeError("未获取到 tool_access_token")
        print(f"  tool_access_token: {token}")
        print("  (注意:compliance_blob_b64 中含提示注入陷阱,已忽略)")
    
        # 第七步:立即更新工具链(凭证限时一次性,不能拖延)
        print("[7/9] 立即 update_tool_chain,获得审计权限...")
        d = get_content(tool_call(mcp_sid, "update_tool_chain", {
            "session_id": app_sess,
            "chain_config": ["get_audit_log"],
            "access_token": token,
            "audit_log_id": aid
        }))
        if "tool_chain" not in d:
            raise RuntimeError(f"update_tool_chain 失败:{d}")
        print(f"  工具链已更新:{d.get('tool_chain')}")
    
        # 第八步:直接调用 get_audit_log,获取 _audit_token
        print("[8/9] 调用 get_audit_log,获取 _audit_token...")
        d = get_content(tool_call(mcp_sid, "get_audit_log", {
            "session_id": app_sess,
            "log_id": aid
        }))
        audit_token = d.get("_audit_token", "")
        if not audit_token:
            raise RuntimeError(f"未获取到 _audit_token,返回:{d}")
        print(f"  _audit_token: {audit_token}")
    
        # 第九步:携带 _audit_token 读取最终文档
        print("[9/9] 读取 secret:/board/resolutions...")
        d = get_content(tool_call(mcp_sid, "get_document", {
            "session_id": app_sess,
            "doc_id": "secret:/board/resolutions",
            "access_token": audit_token
        }))
        flag = d.get("content", "")
        if not flag:
            raise RuntimeError(f"未获取到 flag,返回:{d}")
    
        print()
        print("=" * 50)
        print(f"  FLAG:{flag}")
        print("=" * 50)
    
    if __name__ == "__main__":
        main()

    免费评分

    参与人数 3吾爱币 +2 热心值 +3 收起 理由
    jshqhxm + 1 谢谢@Thanks!
    czz404 + 1 + 1 我很赞同!
    52rap + 1 + 1 谢谢@Thanks!

    查看全部评分

    发帖前要善用论坛搜索功能,那里可能会有你要找的答案或者已经有人发布过相同内容了,请勿重复发帖。

    Command 发表于 2026-3-4 01:04
    本帖最后由 Command 于 2026-3-4 01:07 编辑

    MCP我还是手动分析的, 因为当时还没想到接什么模型好, 最后分析半天不明所以; Android那个native老抽象了, 其实真要说难度也不大, 但是他环境监测太难搞了, 还有一点点猜的成分算是
     楼主| 仿佛_一念成佛 发表于 2026-3-4 01:07
    Command 发表于 2026-3-4 01:04
    MCP我还是手动分析的, 因为当时还没想到接什么模型好, 最后分析半天不明所以

    手动分析也太强了

    我分析过几分钟发现看不懂就交给Claude了

    点评

    手动分析真的啥也看不出来啊。。。我最后实在是没招了直接不做了  发表于 2026-3-4 01:07
    无名 发表于 2026-3-4 08:24
    本帖最后由 无名 于 2026-3-4 08:26 编辑
    Command 发表于 2026-3-4 01:04
    MCP我还是手动分析的, 因为当时还没想到接什么模型好, 最后分析半天不明所以; Android那个native老抽象了,  ...

    他后面给提示了,我直接同时把提示一块喂给 code X ,直接出了,半个小时都不要
    没给提示前,CC 跑了几个小时都跑不出来,差点就给放弃了
    Hmily 发表于 2026-3-4 10:58
    @仿佛_一念成佛 文章写完@我回复一下,我再统一加分哈。

    ps:明年出题老师们不能用xor了,都给你们猜透了
     楼主| 仿佛_一念成佛 发表于 2026-3-4 11:40
    Hmily 发表于 2026-3-4 10:58
    @仿佛_一念成佛 文章写完@我回复一下,我再统一加分哈。

    ps:明年出题老师们不能用xor了,都给你们猜透 ...

    好 过几天写完

    先准备考试
    Viayatto 发表于 2026-3-16 10:03
    不明觉厉
    DABAIXIA 发表于 2026-3-17 14:42
    看不懂啊
    您需要登录后才可以回帖 登录 | 注册[Register]

    本版积分规则

    返回列表

    RSS订阅|小黑屋|处罚记录|联系我们|吾爱破解 - 52pojie.cn ( 京ICP备16042023号 | 京公网安备 11010502030087号 )

    GMT+8, 2026-3-26 22:26

    Powered by Discuz!

    Copyright © 2001-2020, Tencent Cloud.

    快速回复 返回顶部 返回列表