Cris.Q

Back

WHUCTF-2025 RE方向全题解Blur image

WHUCTF-2025 RE方向全题解#

签到#

打开发现是弗吉尼亚密码,直接解密即可。

WHUCTF{welcome_to_reverse-kep}.exe

ezbase#

发现密文 d2h1Y3RmezZac2U2NF8xc192ZXJ5XzNac3ktZWtlfQ==

猜测base64,发现表都没换

坏了,想简单了,换表了,难度还是太高了(

aBCDEFGHIJKLMNOPQRSTUVWXYZAbcdefghijklmnoqprstuvwxyz0123456789+/

得:

whuctf{6@se64_1s_very_3@sy-eke}.exe

太空逃亡#

主函数是一个巨简单的验证函数,关键的加密逻辑在sub_4015F8。检验得其把用户输入当作一串方向键 W/A/S/D(分别对应上、左、下、右),在一个 16×16 的网格(线性数组 byte_4A3020)上沿直线滑动:每次沿某方向一直前进直到遇到一个非 0的格(障碍/节点);不能越界、不能连续输入相同方向,目标是最终抵达数组中值为 ’!’(33)的格子。

迷宫地图 (0=路径, !=终点, 其他=障碍):
. . . . # . . . . . . . # # # # 
. . . # # . . . # # . . . . . . 
. . . . . . . . # # . # # . . . 
. . . # . # # . # # . # # . . . 
. . . # . # # . # # . # . . . . 
. . # # . # # . . . . # . . . . 
. . . . . # # . . . . # . . . . 
# . . . . . . . . . . . . . . . 
. . . . . . . . . . . . # . . . 
. . . . . . # # # . . . # . . . 
. . . . . . . # # # . . . . # # 
. . . # . # . # . # . . . . # . 
. . . . . . . . . . . . . . # # 
# # # # . # . # . # . . . . # . 
. # . # . # . # . # . . . . # . 
. # . # . # . # . # ! . . . # # 
plaintext

值得注意的是!这是一个冰面迷宫,左右移会直接移到可移动区域的边缘。

写出解密脚本即可:

import hashlib
from collections import deque


def parse_maze_data():
    """解析IDA风格的迷宫数据"""
    # 解析重复计数语法
    def parse_dup(part):
        part = part.strip()
        if 'dup(' in part:
            count_str, value_str = part.split('dup(')
            count_str = count_str.strip()
            value_str = value_str.strip(')')

            # 处理十六进制计数 (如0Eh, 1Bh等)
            if count_str.endswith('h'):
                count = int(count_str[:-1], 16)
            else:
                count = int(count_str)

            # 处理十六进制值
            if value_str.endswith('h'):
                value = int(value_str[:-1], 16)
            else:
                value = int(value_str)

            return [value] * count
        else:
            # 处理普通值
            if part.endswith('h'):
                return [int(part[:-1], 16)]
            else:
                return [int(part)]

    # 原始数据
    raw_data = [
        "4 dup(0), 23h, 7 dup(0), 4 dup(23h), 3 dup(0), 2 dup(23h)",
        "3 dup(0), 2 dup(4Fh), 0Eh dup(0), 2 dup(4Fh), 0, 2 dup(50h)",
        "6 dup(0), 4Ch, 0, 2 dup(4Fh), 0, 2 dup(4Fh), 0, 2 dup(50h)",
        "6 dup(0), 4Ch, 0, 2 dup(4Fh), 0, 2 dup(4Fh), 0, 50h",
        "6 dup(0), 2 dup(4Ch), 0, 2 dup(4Fh), 4 dup(0), 50h",
        "9 dup(0), 2 dup(4Fh), 4 dup(0), 50h, 4 dup(0), 23h",
        "1Bh dup(0), 23h, 9 dup(0), 3 dup(4Dh), 3 dup(0), 23h",
        "0Ah dup(0), 3 dup(4Dh), 4 dup(0), 2 dup(45h), 3 dup(0)",
        "30h, 0, 4Dh, 0, 4Dh, 0, 4Dh, 4 dup(0), 45h, 0Fh dup(0)",
        "2 dup(45h), 3 dup(54h), 49h, 0, 4Dh, 0, 4Dh, 0, 4Dh",
        "4 dup(0), 45h, 2 dup(0), 54h, 0, 49h, 0, 4Dh, 0, 4Dh",
        "0, 4Dh, 4 dup(0), 45h, 2 dup(0), 54h, 0, 49h, 0, 4Dh",
        "0, 4Dh, 0, 4Dh, 21h, 3 dup(0), 2 dup(45h)"
    ]

    maze = []
    for line in raw_data:
        parts = line.split(',')
        for part in parts:
            part = part.strip()
            if part:
                maze.extend(parse_dup(part))

    return maze


def solve_maze(maze, start, end):
    """使用BFS算法求解迷宫最短路径"""
    rows, cols = 16, 16
    directions = [(0, 1, 'D'), (1, 0, 'S'), (0, -1, 'A'), (-1, 0, 'W')]

    # 将一维数组转换为二维网格
    grid = [[maze[i * cols + j] for j in range(cols)] for i in range(rows)]

    # BFS初始化
    queue = deque([(start, [])])
    visited = set([start])

    while queue:
        (x, y), path = queue.popleft()

        # 到达终点
        if (x, y) == end:
            return path

        # 探索四个方向
        for dx, dy, direction in directions:
            nx, ny = x + dx, y + dy

            # 检查边界和可通行性
            if 0 <= nx < rows and 0 <= ny < cols:
                if (nx, ny) not in visited and (grid[nx][ny] == 0 or grid[nx][ny] == 0x21):
                    visited.add((nx, ny))
                    queue.append(((nx, ny), path + [direction]))

    return None  # 无解


def visualize_maze(maze):
    """可视化迷宫"""
    rows, cols = 16, 16
    grid = [[maze[i * cols + j] for j in range(cols)] for i in range(rows)]

    print("迷宫地图 (0=路径, !=终点, 其他=障碍):")
    for i in range(rows):
        for j in range(cols):
            if grid[i][j] == 0:
                print('.', end=' ')
            elif grid[i][j] == 0x21:
                print('!', end=' ')
            else:
                print('#', end=' ')
        print()


def main():
    # 解析迷宫数据
    print("正在解析迷宫数据...")
    maze = parse_maze_data()

    # 检查迷宫大小
    print(f"迷宫大小: {len(maze)} 字节")

    # 可视化迷宫
    visualize_maze(maze)

    # 设置起点和终点
    start = (0, 0)
    end = (15, 15)  # 终点在右下角

    print(f"\n起点: {start}")
    print(f"终点: {end}")

    # 求解迷宫
    print("\n正在求解迷宫...")
    path = solve_maze(maze, start, end)

    if path:
        path_str = ''.join(path)
        print(f"找到路径! 长度: {len(path)}")
        print(f"WASD序列: {path_str}")

        # 计算MD5
        md5_hash = hashlib.md5(path_str.encode()).hexdigest()
        print(f"MD5哈希: {md5_hash}")

        # 构造flag
        flag = f"WHUCTF{{{md5_hash}}}"
        print(f"\nFLAG: {flag}")
    else:
        print("未找到路径!")


if __name__ == "__main__":
    main()
python

Try to find me#

打开IDA一片茫然,啥都找不到。于是Shift + F12查字符串发现了:

strcpy((char *)(v0 + 40), "Software\\flag");
strcpy((char *)(v0 + 84), "flag");
c

找到了程序逻辑的尾巴: 这里会把解密后的目标字符串写入注册表的HKEY_CURRENT_USER\Software\flag,然后残忍地删掉。 不行,不能让它删掉(悲) 于是我们patch掉这两个RegDeleteKey的调用,再次运行程序。 注:本题的加密是曾经书记出的困难题,所以别想着静调了~

What can you find#

这题可以当作一道misc去做:

> binwalk -e ./what_can_you_find.3dsx
bash

得一张png,打开有:

这其实是非预期,下面介绍一种比较简洁的预期解法。

首先,我们了解到3DSX是ELF的变体,因此有映射区段并反编译的可能。通过查找Github已有的开源库我们发现了 这个项目,这里作者其实已经对IDA 7以上的版本做了适配,我进行了少量修改使它符合IDA 9的标准,但不修改也没大问题,可以直接用。放入~/.idapro/loaders即可:

现在,我们就可以像处理一个正常的ELF可执行文件那样去处理它了。先查找字符串可以发现如上的逆天言论,跳转找到主函数:

这里是一个输入密码并显示图片的简单程序,因此我们要从中提取出图片文件。此处像上面非预期一样binwalk即可。

好多文件#

好多文件!!!

但是没关系,观察Plugins发现,83和84单独分离出了自己的dll,猜测83和84是节目效果。

打开后发现是盘子(小)小故事,笑死了。

打开exe文件,太乱了,拖一下列表发现主要函数:

// Hidden C++ exception states: #wind=2
HBRUSH __fastcall WndProc(HWND hWnd, UINT msg, HDC wParam, LPARAM lParam)
{

    ...something_else...
    
      if ( (unsigned __int16)wParam == 2 )
      {
        GetWindowTextW(hEdit, buf, 32);
        score = j__wtoi(buf);
        if ( (unsigned int)score <= 0x64 )
        {
          std::wstring::wstring(&dllName);
          if ( score > 60 )
          {
            if ( score > 82 )
            {
              if ( score == 83 )
              {
                std::wstring::operator=(&dllName, L"Plugin_83.dll");
              }
              else if ( score == 84 )
              {
                std::wstring::operator=(&dllName, L"Plugin_84.dll");
              }
              else
              {
                std::wstring::operator=(&dllName, L"Plugin_85_100.dll");
              }
            }
            else
            {
              std::wstring::operator=(&dllName, L"Plugin_61_82.dll");
            }
          }
          else
          {
            std::wstring::operator=(&dllName, L"Plugin_0_60.dll");
          }
          v8 = std::wstring::c_str(&dllName);
          hMod = LoadLibraryW(v8);
          if ( hMod )
          {
            RunPlugin = (void (__fastcall *)(HWND__ *, int))GetProcAddress(hMod, "RunPlugin");
            if ( RunPlugin )
            {
              RunPlugin_1 = (std::wstring *)RunPlugin;
              RunPlugin(hWnd, score);
            }
            else
            {
              MessageBoxW(hWnd, &lpText_, &lpCaption_, 0x10u);
            }
            FreeLibrary(hMod);
            std::wstring::~wstring(&dllName);
          }
          else
          {
            RunPlugin_1 = std::operator+<wchar_t>(&result, &Left, &dllName);
            _Left = RunPlugin_1;
            std::operator+<wchar_t>(&v19, RunPlugin_1, Right);
            std::wstring::~wstring(&result);
  ...something_else...
  return 0;
}
c

我们发现了调用dll的关键函数:RunPlugin

分别打开几个Plugin的该函数,发现不同的惊喜:

直接猜XOR,最后结果肯定是whuctf{

不用说了,基础的换表base64,Cyberchef直接梭。

你这是典型的RC4逻辑,RC4思维,RC4素质。

分别解密后拼出完整flag:

whuctf{I_w1ll_give_y0u_an_extr@_po1nt}

hajimi_player#

使用FindCrypt发现了一些AES相关的东西:

询问AI有:

函数 1:sub_140012E50

作用:实现 AES-128 的密钥扩展 (Key Expansion)。

概要说明:

  • 输入:16 字节主密钥(a1)。

  • 输出:176 字节扩展密钥(a2),即 11 轮 × 16 字节的轮密钥。

  • 内部逻辑:

    • 拷贝前 16 字节为初始轮密钥。

    • 每 16 字节为一轮,执行:

      • RotWord(循环左移 1 字节);
      • SubWord(用 Rijndael S-box 替换每个字节);
      • 首字节 XOR 上 Rcon(轮常数),并更新 Rcon;
      • 按 AES 规则生成新的 16 字节轮密钥。
  • 使用的表:RijnDael_AES_LONG_14001DBE0(AES S-box)。

👉 简单说:它把一个 16 字节的 AES 密钥展开成所有轮用的 176 字节密钥数据。

函数 2:sub_140013D70

作用:执行 AES 的 SubBytes 步骤(S-box 替换)。

概要说明:

  • 输入:指向 16 字节数据块的指针(a1)。
  • 对每个字节执行:byte = SBOX[byte]
  • 直接在原缓冲区中替换(就地操作),返回 16。
  • 同样使用表 RijnDael_AES_LONG_14001DBE0(Rijndael S-box)。

👉 简单说:它把 16 字节“状态矩阵”中的每个字节都通过 AES S-box 映射,属于 AES 加密的一个核心变换步骤。

查找引用,发现了函数sub_140012CA0

分析得出这是单块AES的加密实现,用GPT的话说就是:

这是单块 AES-128 加密的驱动函数——把 16 字节明文用 16 字节密钥加密成 16 字节密文(相当于 ECB 的单块操作)。流程就是标准 AES:初始轮密钥异或 → 9 个主轮(SubBytes→ShiftRows→MixColumns→AddRoundKey)→ 最后一轮(SubBytes→ShiftRows→AddRoundKey,不含 MixColumns)。

顺着该函数的线索,观察行移位和列混淆和轮密钥加的相关函数:

  • sub_140013A30:ShiftRows
  • sub_140013390:MixColumns
  • sub_140012C00:AddRoundKey(state XOR round key)

发现全是标准实现。(想到了书记骂盘子的“都多少年了还看不出来AES”)

全部更名后,想起了被PolyEncrypt支配的恐惧。

根据该函数的引用,跳转到了sub_140011990,这是一个登录对话框,输入用户名和密码。

然而,我们发现了奇怪的函数!

根据函数签名,它长得很像加密函数,但又不是我们之前分析的AES_EncryptBlock。导航进去并尝试分析之。发现是一个类TEA实现。这里Trunk和跳转比较多就不贴出完整流程了,给一个重命名表,可自行检验:

sub_140013E00 = TEA_EncryptBlock128_ECB
sub_140013EA0 = TEA_Encrypt64
sub_140011447 = TEA_Trunk_Wrapper
c

至此登录部分分析完毕,给出用户名和密码的解密脚本(解密需要的静态数据就不贴出了,很容易找到)。

WHUCTF2025_新生赛/RE/hajimi_player via 🐍 
 cat dec.py 
# Requires: pip install pycryptodome
from Crypto.Cipher import AES
import struct

# Constants from .rdata
C1 = bytes.fromhex(
    "F2 C1 8F 6E 89 18 46 70 DE E6 76 95 E7 BC 17 CB".replace(" ", ""))
C2 = bytes.fromhex(
    "4D D5 DF FB 1A CA 76 E6 1E 22 5F 3E 57 58 F3 EB".replace(" ", ""))

# TEA key words (little-endian 32-bit words as in the binary)
k0 = 0x00000001
k1 = 0x00000002
k2 = 0x00000003
k3 = 0x00000004
KEY_WORDS = (k0, k1, k2, k3)

# TEA parameters observed in the binary
DELTA = 0x0D33B470  # custom delta from disassembly
ROUNDS = 32


def le_bytes_to_u32_pair(b8):
    """Little-endian: first 4 bytes -> v0 (low dword), next 4 -> v1"""
    v0 = struct.unpack("<I", b8[0:4])[0]
    v1 = struct.unpack("<I", b8[4:8])[0]
    return v0, v1


def u32_pair_to_le_bytes(v0, v1):
    return struct.pack("<I", v0) + struct.pack("<I", v1)


def tea_decrypt_block64(v0, v1, k):
    """Decrypt single 64-bit block that was encrypted with the variant seen in binary."""
    k0, k1, k2, k3 = k
    mask32 = 0xFFFFFFFF
    # initial sum = delta * rounds mod 2^32
    sum_ = (DELTA * ROUNDS) & mask32
    # run rounds in reverse
    for _ in range(ROUNDS):
        # reverse of:
        # v5 += (k3 + (v4>>5)) ^ (sum + v4) ^ (k2 + (v4<<4));
        # v4 += (k1 + (v5>>5)) ^ (sum + v5) ^ (k0 + (v5<<4));
        v1 = (v1 - (((k3 + ((v0 >> 5) & mask32)) ^ ((sum_ + v0) & mask32)
              ^ ((k2 + ((v0 << 4) & mask32)) & mask32)) & mask32)) & mask32
        v0 = (v0 - (((k1 + ((v1 >> 5) & mask32)) ^ ((sum_ + v1) & mask32)
              ^ ((k0 + ((v1 << 4) & mask32)) & mask32)) & mask32)) & mask32
        sum_ = (sum_ - DELTA) & mask32
    return v0, v1


def tea_decrypt_128_ecb(cipher16, k):
    """Decrypt 16-byte block that was encrypted as two TEA-64 encryptions (ECB on two halves)."""
    assert len(cipher16) == 16
    out = bytearray(16)
    for i in range(2):
        blk = cipher16[i*8:(i+1)*8]
        v0, v1 = le_bytes_to_u32_pair(blk)
        pv0, pv1 = tea_decrypt_block64(v0, v1, k)
        out[i*8:(i+1)*8] = u32_pair_to_le_bytes(pv0, pv1)
    return bytes(out)


def aes128_decrypt_block(cipher16, key16):
    assert len(cipher16) == 16 and len(key16) == 16
    cipher = AES.new(key16, AES.MODE_ECB)
    return cipher.decrypt(cipher16)


# Step 1: decrypt username block (TEA variant)
plain_user_block = tea_decrypt_128_ecb(C1, KEY_WORDS)
print("Username block (hex):", plain_user_block.hex())

# remove trailing zero padding to get ASCII username (zero-padded)
username = plain_user_block.rstrip(b"\x00").decode('latin1', errors='replace')
print("Username:", repr(username))

# Step 2: decrypt password block using AES-128 with username-block as key
# The code uses the username block (16 bytes) as AES key directly.
plain_pass_block = aes128_decrypt_block(C2, plain_user_block)
print("Password block (hex):", plain_pass_block.hex())
password = plain_pass_block.rstrip(b"\x00").decode('latin1', errors='replace')
print("Password:", repr(password))

WHUCTF2025_新生赛/RE/hajimi_player via 🐍 
 python dec.py
Username block (hex): 68616a696d696e616d656c75646f6f6f
Username: 'hajiminameludooo'
Password block (hex): 6d616e626f2331623265266562686a34
Password: 'manbo#1b2e&ebhj4'
bash

获得用户名和密码。

接下来根据窗口逻辑,顺着指针dwNewLong_捋到函数sub_140012480,发现是一个文件解密器的实现。GPT大人如是说:

快速结论(一句话) sub_140012480 是 Hajimi Player 的窗口过程:用户点击 “select your hajimi file” 会把路径放到全局 FileName;点击 “play” 会读取 GetWindowLongPtr(hWnd, GWLP_USERDATA)(也就是登录时存的 Destination —— username+password 拼接字符串),把它作为“密钥/密钥材料”传给 sub_14001110E(初始化/派生解密上下文),然后调用 sub_140011451 把读到的文件数据用该上下文进行解密/处理,最后把解密后数据指向全局 ::Block/::Size 以供播放。也就是说 Player 在播放前会对选中文件进行解密,解密密钥由窗口的 user-data(登录时的 username+password)来派生/生成。

啊伟大的GPT大人!

解密文件部分主要涉及的是sub_14001110Esub_140011451,分析得知:

…能得知什么,就一破RC4罢了。

解密之:

# RC4 decrypt for Hajimi Player files
key = (b"hajiminameludooo" + b"manbo#1b2e&ebhj4")  # username + password

def rc4_ksa(key: bytes):
    S = list(range(256))
    j = 0
    for i in range(256):
        j = (j + S[i] + key[i % len(key)]) & 0xFF
        S[i], S[j] = S[j], S[i]
    return S, 0, 0  # S, i, j

def rc4_prga(S, i, j):
    i = (i + 1) & 0xFF
    j = (j + S[i]) & 0xFF
    S[i], S[j] = S[j], S[i]
    K = S[(S[i] + S[j]) & 0xFF]
    return K, S, i, j

def rc4_crypt(data: bytes, key: bytes) -> bytes:
    S, i, j = rc4_ksa(key)
    out = bytearray(len(data))
    for n, b in enumerate(data):
        k, S, i, j = rc4_prga(S, i, j)
        out[n] = b ^ k
    return bytes(out)

inp = "video.hajimi"   # 改成你的文件名
outp = "output.bin"

with open(inp, "rb") as f:
    enc = f.read()

dec = rc4_crypt(enc, key)

with open(outp, "wb") as f:
    f.write(dec)

# 粗略看看是否像明文
head = dec[:64]
print("Decrypted head (hex):", head.hex())
try:
    print("ASCII head:", head.decode("latin1"))
except:
    pass
python

通过file命令查阅生成的基密文件后发现是个MP4,遂直接mpv播放之。

WHUCTF{h@jimin@meLud0_axiga@xiii}

byd气笑了。

Baby_HeavenGate#

众所周知,Heaven’s Gate是一种在32位程序环境下执行64位代码的混淆技术。 于是,当我们看到这个JUMPOUT时,我们就知道要切到反汇编界面进行查看了。 这里出现了牢门经典的call $+5retf等汇编指令。

进门发现一个简单的异或: 继续往下分析,又发现了经典牢门: 分析可得,这里会把用户输入送入sub_402000函数中。 当然,如果你实现没有执行某一步骤,你看到的可能不是正经的sub_402000,而是一些奇怪的东西。 其实这里出题人已经通过区段名给了我们提示:这里是Heaven’s Gate跳转到的64位代码处。我们通过IDA的Edit-> Segments -> Edit Segment将x64区段改为64位: 核心的异或解密逻辑就搞定了。

因此,写出解密脚本如下:

# 加密数据
encrypted = [
    0x09, 0x2e, 0x27, 0x36, 0x39, 0x01, 0x65, 0x35, 0x71, 0x05,
    0x28, 0x20, 0x1d, 0x28, 0x23, 0x32, 0x34, 0x1d, 0x24, 0x61,
    0x27, 0x73, 0x05, 0x34, 0x36, 0x26, 0x12
]

# "Heaven's Gate" 密钥 (13个字符)
key = b"Heaven's Gate"

# 第一步:逆向第二步XOR(使用"Heaven's Gate"作为轮转密钥)


def get_key_char(index):
    return key[index % len(key)]

# 第二步:逆向XOR_Encrypt


def decrypt_xor_encrypt(encrypted_byte):
    for original in range(256):
        if encrypted_byte == (original + 39 - 2 * (original & 0x27)) & 0xFF:
            return original
    return None


# 完整解密
decrypted_step1 = [encrypted[i] ^ get_key_char(i) for i in range(27)]
decrypted_step2 = [decrypt_xor_encrypt(b) for b in decrypted_step1]

flag = ''.join(chr(b) for b in decrypted_step2)
print("解密结果:", flag)
python

解密结果: flag{Heavens_Gate_mastered}

天才侦探#

打开发现奇怪的setjmp函数:

带着对它的疑惑,先看看别处的逻辑🤔

很轻松地,我们可以看出XOR和XXTEA_Encrypt加密的线索。

然而,XXTEA_Encrypt中部出现了疑似花指令的诡异跳转。切到汇编: 经典的跳转到+1位置,在中间藏一个恶心人的幽灵call。NOP掉即可。

__int64 __fastcall XXTEA_Encrypt(char *Buf1, int n10, char *Can_y0u_catch_me)
{
    ...something...
  if ( n10 <= 1 )
  {
    if ( n10 < -1 )
    {
      v20 = -n10;
      v10 = 52 / -n10 + 6;
      v14 = 1131796 * v10;
      v19 = *(_DWORD *)Buf1;
      do
      {
        v8 = (v14 >> 2) & 3;
        for ( i = v20 - 1; i; --i )
        {
          v16 = *(_DWORD *)&Buf1[4 * (i - 1)];
          v5 = &Buf1[4 * i];
          *(_DWORD *)v5 -= ((v19 ^ v14) + (v16 ^ *(_DWORD *)&Can_y0u_catch_me[4 * (v8 ^ i & 3)]))
                         ^ (((4 * v19) ^ (v16 >> 5)) + ((v19 >> 3) ^ (16 * v16)));
          v19 = *(_DWORD *)v5;
        }
        v17 = *(_DWORD *)&Buf1[4 * v20 - 4];
        *(_DWORD *)Buf1 -= (((4 * v19) ^ (v17 >> 5)) + ((v19 >> 3) ^ (16 * v17)))
                         ^ ((v19 ^ v14) + (v17 ^ *(_DWORD *)&Can_y0u_catch_me[4 * v8]));
        v19 = *(_DWORD *)Buf1;
        v14 -= 1131796;
        --v10;
      }
      while ( v10 );
    }
  }
  else
  {
    v9 = 52 / n10 + 6;
    v13 = 0;
    v15 = *(_DWORD *)&Buf1[4 * n10 - 4];
    do
    {
      v13 += 0x114514;
      v7 = (v13 >> 2) & 3;
      for ( j = 0; j < n10 - 1; ++j )
      {
        v18 = *(_DWORD *)&Buf1[4 * j + 4];
        v3 = &Buf1[4 * j];
        *(_DWORD *)v3 += ((v18 ^ v13) + (v15 ^ *(_DWORD *)&Can_y0u_catch_me[4 * (v7 ^ j & 3)]))
                       ^ (((4 * v18) ^ (v15 >> 5)) + ((v18 >> 3) ^ (16 * v15)));
        v15 = *(_DWORD *)v3;
      }
      v4 = &Buf1[4 * n10 - 4];
      *(_DWORD *)v4 += ((*(_DWORD *)Buf1 ^ v13) + (v15 ^ *(_DWORD *)&Can_y0u_catch_me[4 * (v7 ^ j & 3)]))
                     ^ (((4 * *(_DWORD *)Buf1) ^ (v15 >> 5)) + ((*(_DWORD *)Buf1 >> 3) ^ (16 * v15)));
      v15 = *(_DWORD *)v4;
      --v9;
    }
    while ( v9 );
  }
  return 0xFFFFFFFF / 0;                        // 这里会触发除0异常,抛出浮点数异常然后触发longjmp
}
c

然而,在XXTEA加密的末尾,我们发现了奇怪的除0行为!

这里非常不对劲,除0会引发报错。猜测这里的报错是有意为之,我们找到错误处理函数Function

一个永真跳转,先执行sub_4015F0,再执行longjmp_w。到这里我们明白了上面的setjmp究竟起何作用:一开始Buf是0,执行XXTEA加密,然后XXTEA加密末尾通过除零触发Function,修改Buf为1并跳到setjmp处,使程序进入XOR_Encrypt的分支。

于是,问题就在于:sub_4015F0()做了什么。

分析各种跳转发现,这里又是一个异或。

于是理清了所有加密步骤,写出解密脚本:

import struct

# 密文数据 (从 Cipher)
cipher_bytes = bytes([
    0x8A, 0xFF, 0x4B, 0x28, 0xF8, 0x79, 0x53, 0xA8,
    0xFA, 0xE0, 0x9B, 0x21, 0x9D, 0xAB, 0x79, 0x01,
    0xCD, 0xD7, 0x49, 0xA5, 0x7A, 0x05, 0x0B, 0x94,
    0xF5, 0xD8, 0xBA, 0xD9, 0x4B, 0x26, 0x3E, 0x0C,
    0x8F, 0x91, 0x70, 0x41, 0x05, 0xE9, 0x40, 0xD7
])

# sub_4015F0 中使用的 cipher 数组 (从 unk_49F020 提取,按 DWORD 存储)
unk_49F020 = [
    0x0000003E, 0x00000034, 0x000000BE, 0x000000D6,
    0x00000000, 0x0000008F, 0x000000C8, 0x00000006,
    0x00000008, 0x000000E2, 0x000000F9, 0x00000005,
    0x000000FD, 0x000000E0, 0x0000003A, 0x000000D0,
    0x0000000A, 0x000000C2, 0x000000D7, 0x000000D4,
    0x0000000C, 0x0000001E, 0x00000047, 0x0000004D,
    0x0000009D, 0x00000020, 0x000000F9, 0x00000095,
    0x0000009F, 0x0000000F, 0x000000E8, 0x00000075,
    0x000000CA, 0x000000F6, 0x00000096, 0x00000096,
    0x0000007B, 0x00000028, 0x0000001B, 0x000000D1
]

# XXTEA 密钥
xxtea_key = b"Can_y0u_catch_me"

# XOR 密钥
xor_key = b"Siesta"


def xxtea_decrypt(v, k):
    """XXTEA 解密算法"""
    def MX(sum, y, z, p, e, k):
        return ((z >> 5 ^ y << 2) + (y >> 3 ^ z << 4)) ^ ((sum ^ y) + (k[p & 3 ^ e] ^ z))

    n = len(v)
    rounds = 6 + 52 // n
    sum = (rounds * 0x114514) & 0xFFFFFFFF
    y = v[0]

    while rounds > 0:
        e = (sum >> 2) & 3
        for p in range(n - 1, 0, -1):
            z = v[p - 1]
            v[p] = (v[p] - MX(sum, y, z, p, e, k)) & 0xFFFFFFFF
            y = v[p]
        z = v[n - 1]
        v[0] = (v[0] - MX(sum, y, z, 0, e, k)) & 0xFFFFFFFF
        y = v[0]
        sum = (sum - 0x114514) & 0xFFFFFFFF
        rounds -= 1

    return v


def decrypt():
    # 步骤1: 逆 XOR_Encrypt (与 "Siesta" 异或)
    data = bytearray(cipher_bytes)
    for i in range(40):
        data[i] ^= xor_key[i % len(xor_key)]

    print("Step 1 - 逆 XOR_Encrypt:")
    print(' '.join(f'{b:02x}' for b in data))

    # 步骤2: 逆 sub_4015F0 ((data ^ cipher[i]) - 1)
    for i in range(40):
        # 原加密: Input[i] = (Input[i] + 1) ^ cipher[i]
        # 逆运算: Input[i] = (data[i] ^ cipher[i]) - 1
        data[i] = ((data[i] ^ (unk_49F020[i] & 0xFF)) - 1) & 0xFF

    print("\nStep 2 - 逆 sub_4015F0:")
    print(' '.join(f'{b:02x}' for b in data))

    # 步骤3: XXTEA 解密
    # 将字节数组转换为 32 位整数数组(小端序)
    v = list(struct.unpack('<10I', bytes(data)))

    # 将密钥转换为 32 位整数数组
    k = list(struct.unpack('<4I', xxtea_key[:16]))

    # 执行 XXTEA 解密
    decrypted = xxtea_decrypt(v, k)

    # 转换回字节
    result = struct.pack('<10I', *decrypted)

    print("\nStep 3 - XXTEA 解密:")
    print(' '.join(f'{b:02x}' for b in result))

    # 尝试解析为字符串
    try:
        flag = result.decode('ascii', errors='ignore')
        print(f"\n解密结果: {flag}")
        return flag
    except:
        print("\n解密结果 (hex):", result.hex())
        return result


if __name__ == "__main__":
    print("=" * 60)
    print("开始解密...")
    print("=" * 60)
    print()

    result = decrypt()
python

解密结果: WHUCTF{U_ar3_as_pr0f3ssional_as_siesta!}

感想#

本次迎新赛拿到了个总榜第二。当然还是感谢学长们不杀之恩。

第一天下午去了Deepin的WHLUG,没写题,第二天运动会+百团大战+游园会,算是玩爽了,也没怎么写题,我忏悔喵。

最后,逆向工程这个方向,从迎新赛打到moe,从NewStar打到ILoveCTF,甚至在强网杯还干出来一题。再回头看看迎新赛,虽不能说轻舟已过万重山,至少也翻过了一两重吧。

WHUCTF-2025 RE方向全题解
https://crisq.top/blog/whuctf_2025_newbies_wp
Author Cris.Q
Published at 2025年10月26日
Comment seems to stuck. Try to refresh?✨