文章总结: 本文为PKWCTF新生逆向赛题解,涵盖6道RE题目。RE1为简单异或解密;RE2分析Go程序main函数,通过异或0x12解密8字节密文;RE3直接给出flag;RE4涉及UPX脱壳及自定义加密算法;RE5为Unity游戏逆向,通过Dodo壳提取脚本,拼接密钥碎片进行AES-CBC解密;RE6利用TLS回调执行隐藏逻辑。文章提供了详细解题步骤和flag,适合CTF逆向入门学习。
综合评分: 75
文章分类: CTF,实战经验,逆向分析
PKWCTF新生RE-“看看就好”
赛查查
2026年9月28日 19:05
北京
在小说阅读器读本章
去阅读
在公众号小说中沉浸阅读
以下文章来源于玄网安全
,作者opis
玄网安全
.
ctf竞赛交流,网络大臭虫一个
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 是:
String.Join("|", recoveredKeyFragments)- 再接上前缀
DODO-EXFIL-v2| - UTF-8 后
SHA256,结果作为 AES key - IV = Base64(
aW52LWRvZG8tMjAyNiEhIQ==) =inv-dodo-2026!!! - 密文 = Base64(
fEoFX9UwUuJyvBmccIHNVj6K8he+O47LrLHbJF4WYFQ=) - 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-“看看就好”》