PKWCTF新生RE-“看看就好”

作者:玄网安全 发布:2026-09-27 18:03 收录:2026-09-28 10:35 5 次阅读 约 4932 字
摘要:RE1:糖衣炮弹拖到txt就有RE2:gogogo!
推荐理由:本文涵盖「PKWCTF」、「逆向工程」、「Go语言」等多个主题,重点关注 PKWCTF。

RE1:糖衣炮弹

拖到txt就有

RE2:gogogo!出发咯

分析 mian函数

if ( (unsigned __int64)v22 <= *(_QWORD *)(v2 + 16) )
    runtime_morestack_noctxt_abi0();
  v21 = &unk_140186C98;
  v22[0] = &off_1400DBB58;
  fmt_Println(v0, v1);
  v22[18] = &v21;
  v21 = &unk_140186C98;
  v22[0] = &off_1400DBB68;
  v22[19] = &v21;
  v22[20] = 1LL;
  v22[21] = 1LL;
  fmt_Print(v0, v1, (unsigned int)&off_1400DBB68, 1, v3, v4, v20);
  bufio_NewReader(v0, v1, v5, v6, v7, v8);
  v22[3] = bufio___Reader__ReadString(v0, v1, v9, v10, v11, v12);
  v22[4] = 10LL;
  v22[1] = v13;
  v22[2] = v0;
  strings_TrimSpace(v0, v0, v13, v13, v14, v15);
  if ( (unsigned __int8)main_xorCheckLookHere(v0, v0, v16, v17, v18, v19) )
  {
    v22[9] = &v21;
    v21 = &unk_140186C98;
    v22[0] = &off_1400DBB78;
    v22[10] = &v21;
    v22[11] = 1LL;
    v22[12] = 1LL;
  }
  else
  {
    v22[5] = &v21;
    v21 = &unk_140186C98;
    v22[0] = &off_1400DBB88;
    v22[6] = &v21;
    v22[7] = 1LL;
    v22[8] = 1LL;
  }
  fmt_Println(v0, v0);
}

1:main_xorCheckLookHere 校验函数

__int64 __fastcall main_xorCheckLookHere(__int64 a1)
{
  __int64 v1; // rax
  unsigned __int64 v2; // rbx
  unsigned __int64 i; // [rsp+2h] [rbp-18h]

  if ( v2 != qword_14019E438 )
    return 0LL;
  for ( i = 0LL; (__int64)i < (__int64)v2; ++i )
  {
    if ( i >= v2 )
      ((void (__noreturn *)(void))runtime_panicBounds)();
    if ( qword_14019E438 <= i )
      runtime_panicBounds(
        a1,
        main_encryptedFlag,
        i,
        (unsigned __int8)runtime_noptrdata ^ (unsigned int)*(unsigned __int8 *)(v1 + i));
    if ( *((_BYTE *)main_encryptedFlag + i) != ((unsigned __int8)runtime_noptrdata ^ *(_BYTE *)(v1 + i)) )
      return 0LL;
  }
  return 1LL;
}

2:main.xorKey 与 main.encryptedFlag 存放密钥和密文

.data:000000014019E430 main_encryptedFlag dq offset unk_1400D05FB
.data:000000014019E430                                         ; DATA XREF: main_xorCheckLookHere+8B↑r
.data:000000014019E438 qword_14019E438 dq 8                    ; DATA XREF: main_xorCheckLookHere+1C↑r

3:main.beginnerHint 是提示字符串

.data:000000014019E440 main_beginnerHint dq offset aNotSupportedFo+1621h
main 函数读入一行输入后,会走两条分支:

如果输入正好是 4 字节的 hint(代码里按小端比较 0x746e6968),程序会打印提示:hint: xor key = 0x12, cipher = BYEQFTio。
否则进入 xorCheckLookHere。这个函数要求输入长度必须为 8,然后把每个字节与 0x12 异或,再逐字节和密文 BYEQFTio 比较。全部通过则打印 Correct。

flag:PKWCTF{}

RE3:澳门新葡京

FLAG:PKWCTF{CQqQqQqQqQqQq_YOU}

RE4:糖衣炮弹2

16机制看到 不是upx,需要修改,在脱壳

xxd -s 0x180 -l 8 tang2.exe
xxd -s 0x1b0 -l 8 tang2.exe
xxd -s 0x1d8 -l 8 tang2.exe

脱壳:/mnt/d/CTF/CTF-RE/upx-5.0.1-win64/upx.exe -d tang2.exe

uint8_t __fastcall enc_char(uint8_t ch_0, size_t index)
{
  uint8_t v2; // al
  char v3; // r8

  v2 = rol8(ch_0 ^ (13 * index + 90), (index & 3) + 1);
  return 9 * v3 + v2 + 23;
}
真实逻辑只有这些:

长度不是 25,直接返回 0。
i 从 0 到 24:计算 enc_char(input[i], i),必须等于 target[i]。
有一个字节不相等就返回 0。
25 个字节全过,返回 1,main 里就会打印 Correct!。
x   = in[i] XOR ((0x0D * i + 0x5A) & 0xFF)
r   = ROL8(x, (i & 3) + 1)
out = (r + 9 * i + 0x17) & 0xFF

flag{easy_reverse_signin}

RE5:S级任务!追击!呆呆鸟!

外层是 Dodo Extraction 壳,内嵌 Unity 游戏。DecryptFlag 把 DODO-EXFIL-v2| 和 8 个密钥碎片拼起来做 SHA-256,再作为 AES-CBC 的 key 解密 flag。提示里的自杀只是游戏内 HP - 1,不用打通关。

解题过程

第 1 步:从壳里取出脚本

exe 是 32 位 .NET,入口 _CorExeMain。#US 里有 payload.zip、DodoExtraction.exe。zip 附在文件末尾,可直接当 zip 打开。游戏脚本在:

DodoExtraction_Data/Managed/Assembly-CSharp.dll

用户字符串里能看到 自杀协议执行:HP -1、DODO-EXFIL-v2|、两段 Base64,以及 8 个 4 字符碎片:K9Q2、7M4X、P8VA、2N6C、R5TJ、W3HL、F7ZD、B1YS。

第 2 步:按 DecryptFlag 解密

DecryptFlag 的 IL 是:

  1. String.Join("|", recoveredKeyFragments)
  2. 再接上前缀 DODO-EXFIL-v2|
  3. UTF-8 后 SHA256,结果作为 AES key
  4. IV = Base64(aW52LWRvZG8tMjAyNiEhIQ==) = inv-dodo-2026!!!
  5. 密文 = Base64(fEoFX9UwUuJyvBmccIHNVj6K8he+O47LrLHbJF4WYFQ=)
  6. AES-CBC 解密,去掉 PKCS7
import base64
import hashlib
import io
import re
import zipfile
from pathlib import Path

import dnfile
from Crypto.Cipher import AES

exe = next(
    p
    for p in Path(r"C:\Users\34645\Downloads\S级任务!追击!呆呆鸟!!").iterdir()
    if p.suffix.lower() == ".exe"
)

raw = exe.read_bytes()
dll = zipfile.ZipFile(io.BytesIO(raw)).read(
    "DodoExtraction_Data/Managed/Assembly-CSharp.dll"
)
dll_path = Path(r"C:\Users\34645\AppData\Local\Temp\opencode\dodo\Assembly-CSharp.dll")
dll_path.parent.mkdir(parents=True, exist_ok=True)
dll_path.write_bytes(dll)

pe = dnfile.dnPE(str(dll_path))
us = pe.net.user_strings
blob = dll[us.file_offset : us.file_offset + us.sizeof()]

strings = []
i = 1
while i < len(blob):
    b = blob[i]
    if b == 0:
        i += 1
        continue
    if b & 0x80 == 0:
        ln, i = b, i + 1
    elif b & 0xC0 == 0x80:
        ln, i = ((b & 0x3F) << 8) | blob[i + 1], i + 2
    else:
        ln = ((b & 0x1F) << 24) | (blob[i + 1] << 16) | (blob[i + 2] << 8) | blob[i + 3]
        i += 4
    raw_s = blob[i : i + ln]
    i += ln
    if raw_s:
        strings.append(raw_s[:-1].decode("utf-16le"))

prefix = next(s for s in strings if s.startswith("DODO-EXFIL-v2"))
blobs = []
for s in strings:
    try:
        dec = base64.b64decode(s, validate=True)
    except Exception:
        continue
    if dec and re.fullmatch(r"[A-Za-z0-9+/=]+", s):
        blobs.append(dec)
iv = next(b for b in blobs if len(b) == 16)
ct = next(b for b in blobs if len(b) > 16)
fragments = [s for s in strings if re.fullmatch(r"(?=.*\d)[A-Z0-9]{4}", s)]

material = prefix + "|".join(fragments)
key = hashlib.sha256(material.encode()).digest()
pt = AES.new(key, AES.MODE_CBC, iv).decrypt(ct)
print(pt[: -pt[-1]].decode())

运行输出:

PKWCTF{D0_y0U_LiK3_G@M3?}

RE6:空城计

1. 文件里有什么
目录里只有两样东西:

source.c:注释说「实际施工后剩下一个 main」,函数体就是 return 0。
空城计.exe:7680 字节,32 位 PE,镜像基址 0x400000。
节表:

节 RVA 文件偏移 内容
.text 0x1000 0x400 7 字节,就是 main
.rdata 0x2000 0x600 导入表、TLS 目录
.data 0x3000 0xA00 真正的代码和密文
.reloc 0x5000 0x1C00 重定位
main(0x401000)只有:

push ebp
mov  ebp, esp
xor  eax, eax
pop  ebp
ret
导入的 API 却是 CreateFileW、ReadFile、CloseHandle、ExitProcess、AddVectoredExceptionHandler、MessageBoxW。空的 main 用不到这些,说明逻辑在别处。

2. 入口不在 main,而在 TLS
PE 的 TLS 目录在 RVA 0x2048。IMAGE_TLS_DIRECTORY32.AddressOfCallBacks = 0x402024,回调表第一项是 0x403000,第二项是 0。

进程加载时,系统会在 main 之前调用 0x403000。这段代码在 .data 里,不是 .text。

3. TLS 回调 0x403000:只处理进程附加
cmp  dword ptr [ebp+0xC], 1     ; DLL_PROCESS_ATTACH
jne  0x403080                   ; 不是附加就直接返回
call $+5
pop  esi                        ; esi = 0x40300E
lea  edx, [esi+0xF2]            ; edx = 0x403100,VEH 处理函数
mov  eax, [esi+0x1006]          ; IAT:AddVectoredExceptionHandler
push edx
push 1                          ; First = TRUE,插到处理链最前
call dword ptr [eax]
Reason != 1 时直接 ret 0xC。注册失败则调用 ExitProcess(0)。

4. 故意除零,用 VEH 改 EIP
注册成功后,代码里夹了大量死指令,真正会走到的只有这几条:

xor  eax, eax
je   skip                 ; ZF=1,永远跳过下面那条 call
call 0x50DE302D           ; 死代码
skip:
mov  eax, 0xA5A55A5A
xor  eax, 0xA5A55A5A      ; eax = 0
test eax, eax
jne  junk                 ; 不跳
pop  eax                  ; 把前面 pushfd 弹掉
test edx, edx
je   go
jne  go                   ; 两条都到 go,中间的 call 0x44735259 走不到
go:
jmp  0x403084

0x403084:
call $+5
pop  eax                  ; eax = 0x403089
add  eax, 0xF             ; eax = 0x403098
push eax
ret                       ; 跳到 0x403098
0x403098 开始又是一串花指令(ljmp、ud2、假 call)。用 pushfd / popfd 保住标志位之后,真正执行的是:

mov  eax, 1
xor  ecx, ecx
cdq
idiv ecx                 ; 1 / 0,STATUS_INTEGER_DIVIDE_BY_ZERO = 0xC0000094
异常点就是这条 idiv,地址 0x4030C8。

VEH 0x403100 做三件事:

ExceptionRecord.ExceptionCode == 0xC0000094
Context.Eip == 0x4030C8(用 call/pop 拿到的基址加上 0xC8 算出来,避免写死绝对地址)
把 Context.Eip 改成 0x403200,返回 -1(EXCEPTION_CONTINUE_EXECUTION)
于是除零没有把进程打死,而是跳进 0x403200。

5. 0x403200:解开字符串
sub  esp, 0x80
call $+5
pop  esi
sub  esi, 0x20E          ; esi = 0x403000,整段数据的基址
lea  edi, [esi+0x848]    ; 密文字符串
mov  ecx, 0xCC           ; 204 字节
mov  eax, 0x6C8E9CF5     ; xorshift 种子
每一字节先更新一次 xorshift,再用 al 异或:

x ^= (x << 13)
x ^= (x >> 17)
x ^= (x << 5)
buf[i] ^= x & 0xFF
解开后是三段 UTF-16:

文件名:flag
标题:空城计 · 你咋进来了
正文:大门给你看了,源码也给你看了,正常人看到这里就该回去了。……别告诉其他人,他们还在 main 里等通知。
6. 读 flag 文件
CreateFileW(L"flag", GENERIC_READ, FILE_SHARE_READ,
            OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, ...)
ReadFile(h, buf, 0x32, &n, NULL)   ; 最多读 50 字节
CloseHandle(h)
失败、读失败、或者读到的长度不是 0x31(49)就 ExitProcess(0)。

读到 49 字节后,从下标 0x31 到 0x37 填 0x07,得到 56 字节(7 个 8 字节块)。

7. 按 8 字节做 XXTEA,再和密文比
每个 8 字节块先跳进 0x403343。这里同样有一堆 je/jne 死分支,活路径是 32 轮 TEA/XXTEA:

v0 = *(uint32*)block,v1 = *(uint32*)(block+4)
delta = 0x9E3779B9
sum 从 0 加到 32 * delta = 0xC6EF3720
key 在 esi+0x800,4 个 dword:
A17C9E43  6D20B8F5  C3E4719A  58BF026D
每一轮:

v0 += ((v1<<4) ^ (v1>>5)) + v1) ^ (key[sum & 3] + sum)
sum += 0x9E3779B9
v1 += ((v0<<4) ^ (v0>>5)) + v0) ^ (key[(sum>>11) & 3] + sum)
7 块都加密完后,和 esi+0x810 的 56 字节比较。全相同才:

MessageBoxW(NULL, 正文, 标题, MB_ICONINFORMATION)
ExitProcess(0)
8. 逆出 flag
比较的是密文,所以把 0x403810 的 56 字节按同一套 key 做 32 轮逆运算即可。逆运算从 sum = 0xC6EF3720 开始,每轮先减 v1 再减 v0,sum -= delta。

解密结果:

PKWCTF{the_city_is_empty_but_TLS_is_not_9e3779b9}\x07\x07\x07\x07\x07\x07\x07
前 49 字节就是 flag 文件的内容,后 7 个 0x07 是程序自己补的。再加密回去能对上原密文。

把下面内容存成程序同目录的 flag(不要多换行),再运行 exe,会弹出那个 MessageBox:

PKWCTF{the_city_is_empty_but_TLS_is_not_9e3779b9}

RE7:MioCrypt 写在 密码专栏中

RE8:sm.......................c??

chek_flag

__int64 __fastcall check_flag(const char *a1)
{
  __int64 v2; // [rsp+38h] [rbp-58h] BYREF
  char v3[32]; // [rsp+40h] [rbp-50h] BYREF
  char v4[39]; // [rsp+60h] [rbp-30h] BYREF
  unsigned __int8 v5; // [rsp+87h] [rbp-9h]
  size_t v6; // [rsp+88h] [rbp-8h]
  int Str; // [rsp+A0h] [rbp+10h]

  Str = (int)a1;
  v2 = 0i64;
  v6 = strlen(a1);
  if ( v6 != 31 )
    return 0i64;
  if ( (unsigned __int8)smc_get_key(v4, 32i64, &v2) != 1 )
    return 0i64;
  tiny243_xcrypt(Str, (unsigned int)v3, 31, (unsigned int)v4, v2);
  v5 = same_bytes(v3, &g_cipher, 31i64);
  burn(v4, 32i64);
  burn(v3, 31i64);
  return v5;
}
流程
strlen(a1) == 31,否则直接失败。
smc_get_key(v4, 32, &v2) 把 key 写进 v4,长度放进 v2。
tiny243_xcrypt(flag, v3, 31, key, keylen) 加密输入。
same_bytes(v3, &g_cipher, 31) 和 .rdata 里的 31 字节密文比较。
burn 清掉栈上的 key 和中间结果。
smc_get_key 里 key 不是静态数据:

VirtualProtect(&g_smc_keygen, 0x139, PAGE_EXECUTE_READWRITE)
smc_flip_stage() 把 .smc 里那 0x139 字节再 XOR 一次,还原成可执行的 keygen
FlushInstructionCache 之后调用 g_smc_keygen(buf, make_seed())
再 smc_flip_stage() 把代码 XOR 回去
make_seed() 是 0x13572468 ^ 0x7E0D0059 = 0x6D5A2431。keygen 用这个种子做 17 轮 imul/add,每轮只把低字节 XOR 一个立即数写出去,寄存器本身不变。得到的 17 字节 key 是:

BytePatchKey_243!
stage_mask(i) 是:

b = (i * 0x35 - 0x39) & 0xff
b ^= rotl8((i * 17 + 0x7b) & 0xff, i & 7)
b  = rotl8(b, (i % 7) + 1)
b ^= 0xA6
tiny243
模 243 的 RC4 变体。S 盒初值是 (17*i + 31) % 243,然后用 key 做 3 轮交换。加密循环对每个输入字节生成一个 rotl8 的 keystream 再 XOR。XOR 可逆,所以对 g_cipher 再跑一遍就是明文。

Flag
PKWCTF{wh4t_1s_smc_dynamic_key}

RE9:小栗子的保险箱

这是用 Godot Engine 4.7(官方导出模板)写的 Windows 程序,脚本是 GDScript。题目里那句「这是什么驱动写的呢?」指的就是 Godot。

解题过程

第 1 步:确认引擎

小栗子的保险箱.exe 是 PE32+ GUI 程序,约 109MB,末尾有 pck 段。段内魔数是 GDPC,包版本 4,引擎版本 4.7.0。二进制里还有 Godot Engine v4.7.stable.official,project.binary 的 config/features 为 4.7。

资源里有 scripts/Flag.gd.remap 和 scripts/Main.gd.remap,脚本语言是 GDScript。题目里的「这是什么驱动写的呢」指的就是 Godot。

第 2 步:解出 Flag.gdc

PCK 目录在包内偏移 0x519f0,共 38 个文件。scripts/Flag.gdc 记录偏移 0x4a860、长度 694。Godot 4 的 PCK 会把文件补齐到 16 字节,目录里的长度不含尾部填充,实际要再读 112 字节才能解完 zstd 帧。

文件前 112 字节是 Godot 二进制资源头(RSRC),后面才是编译脚本:

GDSC | version=101 | decompressed_size=2276 | zstd

GDSC 后第一个字节是 0x65('e'),所以字符串上看起来像 GDSCe,其实是 tokenizer 版本 101,不是加密标记。

解压后的 token 缓冲(Godot 4.7,头 16 字节)结构:

偏移
字段
Flag.gdc
0
标识符数量
15
4
常量数量
25
8
换行记录数
15
12
token 数量
158

标识符是 u32 长度加 UTF-32,每字节异或 0xB6。常量是 encode_variant。token 低 7 位是类型,最高位为 1 时占 8 字节(类型/索引 + 行号),否则占 5 字节。

反汇编得到的脚本等价于:

extends Node

func get_flag() -> String:
    var encrypted_flag: PackedByteArray = PackedByteArray([
        60, 34, 45, 42, 11, 53, 26,
        63, 42, 53, 38, 35, 38, 33,
        52, 0, 11, 12, 2, 14, 37,
        61, 110, 30, 4, 71, 68, 17,
    ])
    var key := "lizi_safe".to_utf8_buffer()
    return xor_decrypt(encrypted_flag, key)

func xor_decrypt(data: PackedByteArray, key: PackedByteArray) -> String:
    var result := PackedByteArray()
    result.resize(data.size())
    for i in range(data.size()):
        result[i] = data[i] ^ key[i % key.size()]
    return result.get_string_from_utf8()

Main.gdc 是实验室潜入玩法:和 NPC 对话、找密码纸条、在保险箱输入密码。纸条和对话只是剧情,真正的密钥和密文都在 Flag.gdc。

第 3 步:异或解密

下面的脚本从原始 exe 读 PCK、解出 Flag.gdc、按 tokenizer 抽出字节数组和字符串密钥,再做循环 XOR。

#!/usr/bin/env python3
"""从《小栗子的保险箱.exe》静态解出 flag。依赖: pip install zstandard"""
import struct
import zstandard as zstd

EXE = r"小栗子的保险箱.exe"
FLAG64 = 1 << 16

def decode_string(buf, off):
    ln = struct.unpack_from("<i", buf, off)[0]
    pad = (4 - ln % 4) % 4
    s = buf[off + 4 : off + 4 + ln].decode("utf-8")
    return s, off + 4 + ln + pad

def decode_variant(buf, off):
    header = struct.unpack_from("<I", buf, off)[0]
    typ = header & 0xFF
    off += 4
    wide = bool(header & FLAG64)
    if typ == 0:
        return None, off
    if typ == 1:
        return bool(struct.unpack_from("<I", buf, off)[0]), off + 4
    if typ == 2:
        if wide:
            return struct.unpack_from("<q", buf, off)[0], off + 8
        return struct.unpack_from("<i", buf, off)[0], off + 4
    if typ == 3:
        if wide:
            return struct.unpack_from("<d", buf, off)[0], off + 8
        return struct.unpack_from("<f", buf, off)[0], off + 4
    if typ in (4, 21):  # STRING / STRING_NAME
        return decode_string(buf, off)
    raise ValueError(f"unexpected constant type {typ:#x}")

def load_pck(exe):
    with open(exe, "rb") as f:
        data = f.read()
    pck = data.find(b"GDPC")
    # 跳过 PE 里偶然出现的 GDPC,真正的包头版本字段是 4.7.0
    while True:
        fmt, major, minor, patch = struct.unpack_from("<IIII", data, pck + 4)
        if fmt == 4 and major == 4 and minor == 7:
            break
        pck = data.find(b"GDPC", pck + 1)
    dir_off = pck + struct.unpack_from("<Q", data, pck + 0x20)[0]
    count = struct.unpack_from("<I", data, dir_off)[0]
    pos = dir_off + 4
    files = {}
    for _ in range(count):
        path_len = struct.unpack_from("<I", data, pos)[0]
        pos += 4
        name = data[pos : pos + path_len].split(b"\x00")[0].decode()
        pos += path_len + (4 - path_len % 4) % 4
        ofs, size = struct.unpack_from("<QQ", data, pos)
        pos += 16 + 16 + 4  # ofs, size, md5, flags
        files[name] = data[pck + ofs : pck + ofs + size + 256]
    return files

def decompress_gdc(blob):
    i = blob.find(b"GDSC")
    ver, decsz = struct.unpack_from("<II", blob, i + 4)
    assert ver == 101
    # 目录长度不含对齐填充。decompressobj 读完一帧就停,用 eof 确认帧完整。
    dec = zstd.ZstdDecompressor().decompressobj()
    out = dec.decompress(blob[i + 12 :])
    if not dec.eof or len(out) != decsz:
        raise RuntimeError("bad gdc frame")
    return out

TOKEN_LITERAL = 3  # GDScriptTokenizer::Token::LITERAL

def parse_tokenizer(buf):
    ident_count, const_count, line_count, token_count = struct.unpack_from("<IIII", buf, 0)
    off = 16
    idents = []
    for _ in range(ident_count):
        ln = struct.unpack_from("<I", buf, off)[0]
        off += 4
        chars = []
        for _j in range(ln):
            cp = struct.unpack_from("<I", buf, off)[0] ^ 0xB6B6B6B6
            off += 4
            chars.append(chr(cp))
        idents.append("".join(chars))
    consts = []
    for _ in range(const_count):
        value, off = decode_variant(buf, off)
        consts.append(value)
    off += line_count * 16  # token 行号表 + 列号表
    literals = []
    for _ in range(token_count):
        if buf[off] & 0x80:
            raw = struct.unpack_from("<I", buf, off)[0]
            off += 8
            if (raw & 0x7F) == TOKEN_LITERAL:
                literals.append(consts[raw >> 8])
        else:
            off += 5
    return idents, literals

def main():
    files = load_pck(EXE)
    idents, literals = parse_tokenizer(decompress_gdc(files["scripts/Flag.gdc"]))
    # 常量池按值去重,42 和 53 各出现两次,密文顺序以 LITERAL token 为准
    cipher = [c for c in literals if isinstance(c, int)]
    key = next(c for c in literals if isinstance(c, str)).encode()
    flag = bytes(b ^ key[n % len(key)] for n, b in enumerate(cipher))
    print(flag.decode())
    print("idents:", ", ".join(idents))

if __name__ == "__main__":
    main()

运行结果:

PKWCTF{YOYOYO~Gaming_T1me!!}
idents: Node, get_flag, String, encrypted_flag, PackedByteArray, key, to_utf8_buffer, xor_decrypt, data, result, resize, size, i, range, get_string_from_utf8

RE10:暗号断点

静态分析 challenge_main() 可以发现:程序先调用 build_snapshot() 生成 64 字节缓冲区,再由 verify_token() 取其中前 0x23(35)字节与用户输入进行校验。因此最直接的思路不是逆完整的混合算法,而是在 build_snapshot() 完成后直接观察 snapshot。

解题过程

第 1 步:定位动态恢复数据

首先确认 ELF:

chmod +x ./cipher_point
file ./cipher_point
checksec --file=./cipher_point

使用 IDA 打开后,搜索/进入:

challenge_main
build_snapshot
verify_token

challenge_main() 的核心逻辑为:

got = read_line(answer, 0x60);

build_snapshot(snapshot, 0x40);

pass = verify_token(snapshot, 0x23, answer, got);

clear_buffer(snapshot, 0x40);

其中:

  • 0x40 = 64,说明 snapshot 有 64 字节;
  • 0x23 = 35,说明实际校验长度为 35 字节;
  • answer 是用户输入;
  • snapshot 是程序运行时生成的目标数据。

因此程序逻辑可以简化为:

用户输入
   │
   ▼
read_line(answer)
   │
   │
   ├──────────────┐
   ▼              │
build_snapshot    │
   │              │
   ▼              │
snapshot[0..34]   │
   │              │
   └──────┬───────┘
          ▼
    verify_token
          │
          ▼
       accepted

第 2 步:IDA 远程动态调试

Linux 目标机上准备程序:

chmod +x ./cipher_point

启动 IDA 的 Linux Remote Debugger Server,然后在 IDA 中选择:

Debugger
  → Select debugger
  → Remote Linux debugger

配置目标机地址、端口以及:

Application: /path/to/cipher_point

进入 challenge_main() 后,在:

build_snapshot

函数入口下断点。

继续运行程序,在:

sync token>

处随便输入一段内容即可,例如:

AAAA

输入内容并不影响 build_snapshot() 的生成过程;真正需要观察的是 build_snapshot() 执行完成后的 snapshot。

在 verify_token() 入口断点是最方便的做法。Linux x86_64 System V ABI 下,函数参数对应:

RDI → snapshot
RSI → 0x23
RDX → answer
RCX → got

因此进入 verify_token() 后,RDI 就直接指向正确答案。

如果使用 GDB,可以直接:

x/s $rdi

或者:

x/35bx $rdi

IDA 中则可以将 Hex View 跳转到 RDI 指向的地址,查看恢复后的 35 字节。

build_snapshot() 内部还有一处很直观的写入:

mov cl, BYTE PTR [rbp-0x59]
mov rax, QWORD PTR [rbp-0x58]
mov BYTE PTR [rbp+rax*1-0x50], cl

这说明生成的数据正在逐字节写入内部缓冲区。

后续数据被复制到调用者提供的 snapshot,并以 0x23 字节作为有效长度。

第 3 步:读取并验证 Flag

动态调试得到的目标字符串为:

PKWCTF{ida_remote_debugging_is_fun}

长度:

35

与:

verify_token(snapshot, 0x23, answer, got);

中的 0x23 完全对应。

将该字符串作为输入:

printf '%s\n' 'PKWCTF{ida_remote_debugging_is_fun}' | ./cipher_point

程序应输出:

sync token> accepted

RE11:大鱼吃小鱼

发现加密混淆

页面把 18 位通行短语交给 tank-filter.wasm 的 check_key 做本地校验,通过后再 POST 到 /api/open-water 换 flag。对 WASM 里的逐字节变换求逆即可恢复短语。

解题过程

第 1 步:从混淆脚本里找出校验边界

/assets/reef-current.min.js 是 obfuscator.io 混淆。还原字符串表后,页面会加载 /assets/tank-filter.wasm,把输入写入导出的 memory,调用 check_key(ptr)。返回值非 0 才继续请求:

POST /api/open-water
Content-Type: application/json

{"phrase":"<18 chars>"}

输入框只接受字母和数字,长度固定 18。小游戏分数只影响画面,不参与校验。

第 2 步:还原 check_key 并反推短语

check_key 对下标 i 的每个字节做同一变换,再与 memory[0x100 + i] 比较。数据段实际落在偏移 0xF0,所以被比较的 18 字节是:

fd d2 09 cc 62 e8 c5 f3 c2 1c 3e e4 f2 7c ae a2 b7 d0

变换为:

x = input[i] XOR (0x37 + 11*i)
x = rotate_left_8(x, (i % 7) + 1)
x = (x + 0x13 + 3*i) & 0xFF

每个位置在可打印字符里只有一个原像,拼起来是 BmSSyu8ajGu5UBiDiZ。提交该短语得到 flag。

#!/usr/bin/env python3
import json
import pathlib
import urllib.request

BASE = "http://3000-e97f5d05-1964-4d51-95ab-b01940e00910.challenge.ctfplus.cn"
WASM = pathlib.Path(__file__).with_name("tank-filter.wasm")


def leb_u(buf, index):
    value = shift = 0
    while True:
        byte = buf[index]
        index += 1
        value |= (byte & 0x7F) << shift
        if byte < 0x80:
            return value, index
        shift += 7


def load_target(blob):
    index = 8
    while index < len(blob):
        section_id = blob[index]
        index += 1
        size, index = leb_u(blob, index)
        body, index = blob[index : index + size], index + size
        if section_id != 11:
            continue
        cursor = 0
        _, cursor = leb_u(body, cursor)
        offset, cursor = leb_u(body, cursor + 2)  # skip flag, i32.const
        cursor += 1  # end
        nbytes, cursor = leb_u(body, cursor)
        payload = body[cursor : cursor + nbytes]
        start = 0x100 - offset
        return payload[start : start + 18]
    raise SystemExit("data segment not found")


def rotl8(value, bits):
    value &= 0xFF
    return ((value << bits) | (value >> (8 - bits))) & 0xFF


def forward(char, index):
    mixed = char ^ ((0x37 + index * 11) & 0xFF)
    mixed = rotl8(mixed, (index % 7) + 1)
    return (mixed + 0x13 + index * 3) & 0xFF


def recover(target):
    chars = []
    for index, expected in enumerate(target):
        hits = [c for c in range(0x20, 0x7F) if forward(c, index) == expected]
        chars.append(chr(hits[0]))
    return "".join(chars)


phrase = recover(load_target(WASM.read_bytes()))
print(phrase)
request = urllib.request.Request(
    BASE + "/api/open-water",
    data=json.dumps({"phrase": phrase}).encode(),
    headers={"Content-Type": "application/json"},
    method="POST",
)
with urllib.request.urlopen(request, timeout=20) as response:
    print(json.loads(response.read().decode())["flag"])

本地还原结果:

BmSSyu8ajGu5UBiDiZ

接口返回:

{"ok":true,"flag":"PKWCTF{aac86789-140e-47bf-8a9d-05ad3a252ad3}"}

转载声明:本文转载自原发布平台 (作者:玄网安全), 原文标题《PKWCTF新生RE-“看看就好”》, 查看原文。 版权归原作者及原发布平台所有,本站仅作收录与展示,未对正文内容作实质性修改; 若涉及侵权请联系本站处理。