NCTF 2026 Reverse Write Up
Reverse
Hook My Secret
题目一共有3个Stage
Stage1:

1
九格图案锁,随便滑动一下,出现错误,提示Pattern incorrect

2
在Jadx中找到关键字符串,发现sha-256后的4a6bc34076c8eef0f9eac59ad30d99bb4f56ecea4b0bfab92540fb655ac680f3
所以可以直接爆破出手势
爆破exp:
import hashlib
TARGET = "4a6bc34076c8eef0f9eac59ad30d99bb4f56ecea4b0bfab92540fb655ac680f3"
# Android pattern 跳点规则
SKIP = {
(0, 2): 1, (2, 0): 1,
(0, 6): 3, (6, 0): 3,
(2, 8): 5, (8, 2): 5,
(6, 8): 7, (8, 6): 7,
(0, 8): 4, (8, 0): 4,
(2, 6): 4, (6, 2): 4,
(1, 7): 4, (7, 1): 4,
(3, 5): 4, (5, 3): 4,
}
def valid_next(path, nxt):
if nxt in path:
return False
if not path:
return True
a = path[-1]
if (a, nxt) in SKIP:
return SKIP[(a, nxt)] in path
return True
def dfs(path, max_len=9):
if 4 <= len(path) <= max_len:
s = ",".join(map(str, path))
if hashlib.sha256(s.encode()).hexdigest() == TARGET:
return path[:]
if len(path) == max_len:
return None
for nxt in range(9):
if valid_next(path, nxt):
path.append(nxt)
r = dfs(path, max_len)
if r:
return r
path.pop()
return None
def main():
for start in range(9):
ans = dfs([start])
if ans:
print("[+] Found pattern:", ans)
print("[+] String form:", ",".join(map(str, ans)))
print("[+] 1-based view:", [x + 1 for x in ans])
return
print("[-] Not found")
if __name__ == "__main__":
main()
img
也就是

img
成功来到
Stage2*:*

img
看到Native Check 直接把so扔进IDA 得到一段很长的代码:
int __cdecl Java_com_nctf_hookmysecret_nativebridge_NativeBridge_encryptStage2(int a1, int a2, int a3)
{
int v3; // eax
int v4; // edi
const char *s; // esi
size_t v6; // eax
size_t n; // ecx
char *v8; // edi
int v9; // ebp
size_t v10; // edi
char *v11; // eax
size_t v12; // esi
int v13; // ecx
int v14; // edi
char *v15; // esi
int v16; // ebp
int v18; // esi
size_t v19; // eax
bool v20; // sf
size_t v21; // esi
_BYTE *v22; // ebp
char v23; // cl
size_t v24; // edi
size_t v25; // eax
char *v26; // eax
char v27; // cl
int v28; // esi
unsigned int v29; // ecx
signed int v30; // ebp
int v31; // edx
unsigned int v32; // edi
void *v33; // ebp
int v34; // eax
unsigned int v35; // edx
char *v36; // ecx
void *v37; // eax
_BYTE *v38; // esi
char *v39; // eax
unsigned int v40; // ebx
unsigned int v41; // eax
int v42; // eax
int v43; // edx
__m128i v44; // xmm0
int v45; // ebx
int v46; // ebx
char v47; // al
_BYTE *v48; // edx
char v49; // al
char *v50; // edi
unsigned int v51; // esi
char *v52; // ebp
char *v53; // ecx
unsigned int v54; // eax
int v55; // edx
int v56; // ebx
__m128i v57; // xmm2
unsigned int v58; // edx
int v59; // esi
const char *src; // [esp+4h] [ebp-88h]
char *v62; // [esp+18h] [ebp-74h]
void *v63; // [esp+18h] [ebp-74h]
char *v64; // [esp+1Ch] [ebp-70h]
bool v65; // [esp+1Ch] [ebp-70h]
char v66; // [esp+1Ch] [ebp-70h]
int v67; // [esp+1Ch] [ebp-70h]
unsigned int v68; // [esp+20h] [ebp-6Ch]
unsigned int v69; // [esp+24h] [ebp-68h]
int v70; // [esp+28h] [ebp-64h]
char v71; // [esp+28h] [ebp-64h]
int v72; // [esp+28h] [ebp-64h]
unsigned int v73; // [esp+2Ch] [ebp-60h]
size_t v74; // [esp+34h] [ebp-58h]
char v75; // [esp+38h] [ebp-54h]
int v76; // [esp+3Ch] [ebp-50h]
unsigned int v77; // [esp+40h] [ebp-4Ch]
_BYTE *v78; // [esp+44h] [ebp-48h]
_DWORD v79[4]; // [esp+48h] [ebp-44h] BYREF
int dest; // [esp+58h] [ebp-34h] BYREF
_BYTE *v81; // [esp+5Ch] [ebp-30h]
void *v82; // [esp+60h] [ebp-2Ch]
__int16 v83; // [esp+68h] [ebp-24h] BYREF
size_t v84; // [esp+6Ch] [ebp-20h]
void *v85; // [esp+70h] [ebp-1Ch]
unsigned int v86; // [esp+78h] [ebp-14h]
v86 = __readgsdword(0x14u);
v3 = (*(*a1 + 676))(a1, a3, 0);
v4 = v3;
s = &unk_C5F3;
if ( v3 )
s = v3;
v6 = strlen(s);
if ( v6 >= 0xFFFFFFF0 )
sub_1E270(&dest);
n = v6;
v70 = v4;
if ( v6 > 0xA )
{
v9 = v6 | 0xF;
v10 = v6;
v11 = operator new((v6 | 0xF) + 1);
n = v10;
v8 = v11;
v82 = v11;
dest = v9 + 2;
v81 = n;
}
else
{
LOBYTE(dest) = 2 * v6;
v8 = &dest + 1;
}
src = s;
v12 = n;
memmove(v8, src, n);
v8[v12] = 0;
if ( (dest & 1) != 0 )
{
v64 = v82;
v13 = v81;
if ( !v81 )
goto LABEL_19;
}
else
{
v13 = dest >> 1;
v64 = &dest + 1;
if ( !v13 )
goto LABEL_19;
}
v14 = 0;
v15 = &off_476CC - 238561;
while ( 1 )
{
v16 = v13;
if ( !memchr(v15, v64[v14], 4u) )
break;
++v14;
v13 = v16;
if ( v16 == v14 )
goto LABEL_19;
}
if ( v14 == -1 )
{
LABEL_19:
v83 = 0;
v18 = v70;
if ( (dest & 1) == 0 )
goto LABEL_21;
goto LABEL_20;
}
while ( v16 )
{
if ( !memchr(v15, v64[--v16], 4u) )
goto LABEL_97;
}
v16 = -1;
LABEL_97:
std::string::basic_string(&v83, &dest, v14, v16 - v14 + 1);
v18 = v70;
if ( (dest & 1) != 0 )
LABEL_20:
operator delete(v82);
LABEL_21:
if ( v18 )
(*(*a1 + 680))(a1, a3, v18);
dest = 0;
v81 = nullptr;
v82 = nullptr;
v65 = (v83 & 1) == 0;
if ( (v83 & 1) != 0 )
{
v19 = v84;
v20 = (v84 & 0x80000000) != 0;
if ( v84 )
{
LABEL_25:
if ( v20 )
sub_1E330(&dest);
v21 = v19;
v22 = operator new(v19);
dest = v22;
v81 = v22;
v82 = &v22[v21];
v23 = 81;
v24 = 0;
while ( 1 )
{
v26 = &v83 + 1;
if ( !v65 )
v26 = v85;
v66 = v26[v24];
v71 = v23;
v27 = __ROL1__(v66 ^ v23 ^ (13 * v24 + 66), 3) + v24 + 7 * v23;
if ( v22 == v82 )
break;
*v22++ = v27;
v81 = v22;
LABEL_68:
v23 = v71 + v66 + (v24++ ^ v27);
v65 = (v83 & 1) == 0;
if ( (v83 & 1) != 0 )
v25 = v84;
else
v25 = v83 >> 1;
if ( v24 >= v25 )
{
v50 = dest;
goto LABEL_74;
}
}
v75 = v27;
v28 = dest;
v62 = v22;
v29 = &v22[-dest];
v30 = &v22[-dest + 1];
if ( v30 < 0 )
sub_1E330(&dest);
if ( 2 * v29 > v30 )
v30 = 2 * v29;
v73 = v29;
if ( v29 >= 0x3FFFFFFF )
v30 = 0x7FFFFFFF;
v74 = v24;
if ( v30 )
v31 = operator new(v30);
else
v31 = 0;
v32 = v31 + v73;
v33 = (v31 + v30);
*(v31 + v73) = v75;
if ( v62 == v28 )
{
dest = v31 + v73;
v81 = (v32 + 1);
v82 = v33;
v37 = v62;
v38 = (v32 + 1);
v24 = v74;
goto LABEL_66;
}
v69 = v31 + v73;
v78 = (v32 + 1);
v34 = ~v28;
v76 = v31;
if ( v73 >= 8 )
{
v39 = &v62[v34];
v24 = v74;
if ( v31 + v73 - 1 - v39 > v31 + v73 - 1 || v62 - 1 - v39 > (v62 - 1) )
{
v35 = v31 + v73;
}
else
{
v35 = v31 + v73;
if ( (v28 - v76) >= 0x20 )
{
v40 = 0;
v41 = v73;
if ( v73 >= 0x20 )
{
v40 = v73 & 0xFFFFFFE0;
v42 = v76 + v73 - 16;
v43 = 0;
do
{
v44 = _mm_loadu_si128(&v62[v43 - 32]);
*(v42 + v43) = _mm_loadu_si128(&v62[v43 - 16]);
*(v42 + v43 - 16) = v44;
v43 -= 32;
}
while ( -(v73 & 0xFFFFFFE0) != v43 );
v34 = ~v28;
if ( v73 == v40 )
goto LABEL_65;
if ( (v73 & 0x18) == 0 )
{
v36 = &v62[-v40];
v35 = v69 - v40;
goto LABEL_60;
}
v41 = v73;
v35 = v69;
}
v35 -= v41 & 0xFFFFFFF8;
v77 = v41 & 0xFFFFFFF8;
v36 = &v62[-(v41 & 0xFFFFFFF8)];
v45 = -v40;
do
{
*(v69 + v45 - 8) = *&v62[v45 - 8];
v45 -= 8;
}
while ( -(v41 & 0xFFFFFFF8) != v45 );
v34 = ~v28;
if ( v73 == v77 )
goto LABEL_65;
goto LABEL_60;
}
}
v36 = v62;
v34 = ~v28;
}
else
{
v35 = v31 + v73;
v36 = v62;
v24 = v74;
}
LABEL_60:
v68 = &v36[v34];
v46 = (v36 - v28) & 7;
if ( ((v36 - v28) & 7) != 0 )
{
do
{
v47 = *--v36;
*--v35 = v47;
--v46;
}
while ( v46 );
}
if ( v68 >= 7 )
{
v48 = (v35 - 1);
do
{
*v48 = *(v36 - 1);
*(v48 - 1) = *(v36 - 2);
*(v48 - 2) = *(v36 - 3);
*(v48 - 3) = *(v36 - 4);
*(v48 - 4) = *(v36 - 5);
*(v48 - 5) = *(v36 - 6);
*(v48 - 6) = *(v36 - 7);
v49 = *(v36 - 8);
v36 -= 8;
*(v48 - 7) = v49;
v48 -= 8;
}
while ( v36 != v28 );
}
LABEL_65:
v37 = dest;
dest = v76;
v38 = v78;
v81 = v78;
v82 = v33;
if ( !v37 )
{
LABEL_67:
v22 = v38;
v27 = v75;
goto LABEL_68;
}
LABEL_66:
operator delete(v37);
goto LABEL_67;
}
}
else
{
v19 = v83 >> 1;
v20 = 0;
if ( v19 )
goto LABEL_25;
}
v50 = nullptr;
v22 = nullptr;
LABEL_74:
v51 = v22 - v50;
v67 = (*(*a1 + 716))(a1, v22 - v50);
if ( v67 )
{
memset(v79, 0, 12);
if ( v22 == v50 )
{
v52 = nullptr;
v53 = nullptr;
}
else
{
v72 = v22 - v50;
v63 = v22;
if ( v51 >= 0x40000000 )
sub_1E350(v79);
v52 = operator new(4 * v51);
v53 = v52;
v54 = v50;
if ( v51 < 0x14 )
goto LABEL_83;
if ( v52 < &v50[v51] )
{
v53 = v52;
v54 = v50;
if ( v50 < &v52[4 * v72] )
goto LABEL_83;
}
v55 = v51 & 0x3FFFFFF8;
v53 = &v52[4 * (v51 & 0xFFFFFFF8)];
v54 = &v50[v51 & 0xFFFFFFF8];
v56 = 0;
do
{
v57 = _mm_unpacklo_epi16(_mm_unpacklo_epi8(_mm_cvtsi32_si128(*&v50[v56 + 4]), 0LL), 0LL);
*&v52[4 * v56] = _mm_unpacklo_epi16(_mm_unpacklo_epi8(_mm_cvtsi32_si128(*&v50[v56]), 0LL), 0LL);
*&v52[4 * v56 + 16] = v57;
v56 += 8;
}
while ( v55 != v56 );
if ( v51 != v55 )
{
LABEL_83:
v58 = v63 + ~v54;
v59 = (v63 - v54) & 7;
if ( ((v63 - v54) & 7) != 0 )
{
do
{
*v53 = *v54++;
v53 += 4;
--v59;
}
while ( v59 );
}
if ( v58 >= 7 )
{
do
{
*v53 = *v54;
*(v53 + 1) = *(v54 + 1);
*(v53 + 2) = *(v54 + 2);
*(v53 + 3) = *(v54 + 3);
*(v53 + 4) = *(v54 + 4);
*(v53 + 5) = *(v54 + 5);
*(v53 + 6) = *(v54 + 6);
*(v53 + 7) = *(v54 + 7);
v54 += 8;
v53 += 32;
}
while ( v54 != v63 );
}
}
}
(*(*a1 + 844))(a1, v67, 0, (v53 - v52) >> 2, v52);
if ( v52 )
operator delete(v52);
}
if ( v50 )
{
v81 = v50;
operator delete(v50);
}
if ( (v83 & 1) != 0 )
operator delete(v85);
return v67;
}def encrypt_stage2(s: str):
s = s.strip()
seed = 0x51
out = []
for i, ch in enumerate(s.encode()):
x = ((13 * i + 0x42) ^ seed ^ ch) & 0xff
x = ((x << 3) & 0xff) | (x >> 5) # rol 3
y = (7 * seed + i + x) & 0xff
out.append(y)
seed = (ch + seed + (y ^ i)) & 0xff
return outStage2 exp:
TARGET = [250, 113, 87, 185, 6, 125, 167, 156, 4, 0, 229, 239, 119, 155, 187, 95]
def encrypt_stage2(s: str):
s = s.strip()
seed = 0x51
out = []
for i, ch in enumerate(s.encode()):
x = ((13 * i + 0x42) ^ seed ^ ch) & 0xff
x = ((x << 3) & 0xff) | (x >> 5) # rol 3
y = (7 * seed + i + x) & 0xff
out.append(y)
seed = (ch + seed + (y ^ i)) & 0xff
return out
def recover_key(target):
seed = 0x51
res = []
for i, want in enumerate(target):
found = None
for ch in range(256):
x = ((13 * i + 0x42) ^ seed ^ ch) & 0xff
x = ((x << 3) & 0xff) | (x >> 5)
y = (7 * seed + i + x) & 0xff
if y == want:
next_seed = (ch + seed + (y ^ i)) & 0xff
found = (ch, next_seed)
break
if found is None:
raise ValueError(f"char {i} not found")
ch, seed = found
res.append(ch)
return bytes(res).decode()
def main():
key = recover_key(TARGET)
print("[+] Recovered key:", key)
print("[+] Verify:", encrypt_stage2(key))
print("[+] Match:", encrypt_stage2(key) == TARGET)
if __name__ == "__main__":
main()
img
所以Key为k7Xm2Pq9Wv4N8bRt
Stage3*:*

img

img
发现stage3使用了AES加密,密文是jSaMnziall55Tdr+IZc7EKUNm/N4uwrZw1QFPw6DuirfYFJZg88j6GKLhWfNljAB
密钥是stage2的key所以还差个IV

img
通过对其方法进行追踪可以看到:

img
IV已有,解密即可

img

img
No My Bank!
一开始 GDRE Tools 直接恢复不完整,看起来像是资源和脚本被加密了。实际情况分成两层:
NoMyBank.exe内嵌了一个加密的 Godot PCK。根目录下还有一个额外加密的
libextension.dll,这是 GDExtension,真正的校验逻辑在里面。先解第一层:
使用 KeyDot 从 NoMyBank.exe 中提取到 PCK 解密 key:
D34BFF62613FDD2861F6D5942C5E99A53EF3E90ADBE9091B4686859D5B7DAB22
再用GDRE进行解密

img

img
核心逻辑:
func _on_button_pressed():
var flag_checker = get_checker_from_loaded_dll_node()
if flag_checker:
flag_checker.show_flag_dialog()
queue_free()说明点击金库弹窗按钮后,会去调用 GDExtension 节点 dllchecker 的方法 show_flag_dialog()。
这说明真正的校验和 flag 展示都在原生扩展里,不在 GDScript 里。
这时题目打包的目录下还有一个dll没有用上
运行程序后,扩展会被自动解密并落到临时目录:
C:\Users\hao\AppData\Local\Temp_libextension.dll
这是正常的dll

img

img
往上找到:

img
检查长度是否为40
_DWORD *__fastcall sub_1800024A0(__int64 a1, __int64 a2, _DWORD *a3)
{
int i; // [rsp+20h] [rbp-58h]
int j; // [rsp+24h] [rbp-54h]
unsigned int v6; // [rsp+28h] [rbp-50h]
unsigned int v7; // [rsp+2Ch] [rbp-4Ch]
int m; // [rsp+30h] [rbp-48h]
int v9; // [rsp+34h] [rbp-44h]
int k; // [rsp+38h] [rbp-40h]
_DWORD v11[10]; // [rsp+40h] [rbp-38h] BYREF
memset(v11, 0, sizeof(v11));
for ( i = 0; i < 5; ++i )
{
v11[2 * i] = sub_1800023A0((8 * i + a1));
v11[2 * i + 1] = sub_1800023A0((8 * i + 4 + a1));
}
for ( j = 0; j < 10; j += 2 )
{
v9 = 0;
v6 = v11[j];
v7 = v11[j + 1];
for ( k = 0; k < 32; ++k )
{
v9 += 0x114514;
v6 += (unk_18014F49C + (v7 >> 5)) ^ (v9 + v7) ^ (unk_18014F498 + 16 * v7);
v7 += (unk_18014F4A4 + (v6 >> 5)) ^ (v9 + v6) ^ (unk_18014F4A0 + 16 * v6);
}
v11[j] = v6;
v11[j + 1] = v7;
}
for ( m = 0; m < 10; ++m )
sub_180002410(v11[m], 4 * m + a2);
*a3 = 40;
return a3;
}题目采用三层处理:
明文经自定义 TEA-like 算法加密,参数为:
delta = 0x114514
key = [0x12345678, 0x9ABCDEF0, 0x13579BDF, 0x2468ACE0]
TEA 输出再经自定义 Base64 编码,字符表为
ZYXWVUTSRQPONMLKJIHGFEDCBAzyxwvutsrqponmlkjihgfedcba9876543210+/
且 4 个 6-bit 输出顺序为 [b,a,d,c]
最后再做逐字节异或 + rol2 混淆,seed 初始值为 0x114514
exp:
CIPH_HEX = "2bf7675e7c98ed6dd18cef57bb33227eb21f345b366c2bafbb5b12d63c0a4527846c47ab2f75783e88892d7acd5cf6fa3673ff6ed34c1c75"
def ror2(x: int) -> int:
return ((x >> 2) | ((x << 6) & 0xff)) & 0xff
def dec_stage1(cipher_hex: str) -> bytes:
data = bytes.fromhex(cipher_hex)
seed = 0x114514
out = bytearray()
for i, c in enumerate(data):
k = (seed >> ((i * 8) % 24)) & 0xff
b = ror2(c ^ 0xBA) ^ k
out.append(b)
seed = seed * 0x1010193 + 0x12345678
return bytes(out)
CUSTOM_B64_TABLE = "ZYXWVUTSRQPONMLKJIHGFEDCBAzyxwvutsrqponmlkjihgfedcba9876543210+/"
def custom_b64_decode(s: str) -> bytes:
mp = {ch: i for i, ch in enumerate(CUSTOM_B64_TABLE)}
out = bytearray()
for i in range(0, len(s), 4):
quad = s[i:i+4]
pad = quad.count('=')
q = []
for ch in quad:
if ch == '=':
q.append(None)
else:
q.append(mp[ch])
# 输出顺序是 [b, a, d, c]
# 还原为标准顺序 [a, b, c, d]
a = q[1] if q[1] is not None else 0
b = q[0] if q[0] is not None else 0
d = q[2] if q[2] is not None else 0
c = q[3] if q[3] is not None else 0
n = (a << 18) | (b << 12) | (c << 6) | d
if pad == 0:
out.extend([(n >> 16) & 0xff, (n >> 8) & 0xff, n & 0xff])
elif pad == 1:
out.extend([(n >> 16) & 0xff, (n >> 8) & 0xff])
elif pad == 2:
out.append((n >> 16) & 0xff)
else:
raise ValueError("invalid padding")
return bytes(out)
def main():
s1 = dec_stage1(CIPH_HEX)
print("[+] stage1 result:", s1.decode())
s2 = custom_b64_decode(s1.decode())
print("[+] stage2 bytes hex:", s2.hex())
print("[+] flag:", s2.decode())
if __name__ == "__main__":
main()
img

img
最后还有一个小变换
最终flag:NCTF{Y0u_d3s3rv3_th1s_g1ft_b1bd7c719cfc}
Pay For 2048
题目是从一个基于 Electron 的 2048 游戏。直接拿asar解包,拿到源文件

class GameController {
constructor({ gameManager, uiManager, licenseService }) {
this.gameManager = gameManager;
this.uiManager = uiManager;
this.licenseService = licenseService;
this.keyMap = {
ArrowLeft: 0,
ArrowDown: 1,
ArrowRight: 2,
ArrowUp: 3
};
this.state = this.#createInitialState();
}
async bootstrap() {
this.uiManager.setBanner("Loading WASM core...");
await this.licenseService.init();
this.#bindEvents();
this.#setPhase("PLAYING_FREE", "WASM core ready. Reach 256 to request the license.");
this.#syncView();
}
restart() {
this.gameManager.reset();
this.state = this.#createInitialState();
this.uiManager.hideLicenseModal();
this.uiManager.hideResult();
this.#setPhase("PLAYING_FREE", "Fresh board. Reach 256 to trigger the license gate.");
this.#syncView();
}
async submitLicense() {
const key = this.uiManager.getLicenseKey();
const context = this.#collectContext();
this.state.busy = true;
this.uiManager.setLicenseBusy(true);
this.uiManager.setBanner("Verifying license...");
try {
const result = await this.licenseService.verifyKey(key, context);
if (!result.ok) {
this.state.busy = false;
this.uiManager.setLicenseBusy(false);
this.uiManager.showLicenseModal(result.message);
return;
}
this.state.activated = true;
this.state.paused = false;
this.state.busy = false;
this.state.licenseKey = key;
this.state.sessionToken = result.data.sessionToken;
this.uiManager.setLicenseBusy(false);
this.uiManager.hideLicenseModal();
this.#setPhase("PLAYING_PRO", "License accepted. 2048 final verification is now unlocked.");
this.#syncView();
} catch (error) {
this.state.busy = false;
this.uiManager.setLicenseBusy(false);
this.uiManager.showLicenseModal(error.message || "License verification failed.");
}
}
async #unlockFlag() {
try {
const result = await this.licenseService.unlockFlag(this.state.licenseKey, this.#collectContext());
this.state.busy = false;
if (!result.ok) {
this.state.paused = false;
this.#setPhase("FAIL", result.message);
this.#syncView();
return;
}
this.state.gameOver = true;
this.#setPhase("SUCCESS", "Final verification passed.");
this.uiManager.showResult({
title: "Flag Released",
message: "WASM validation passed and the encrypted payload was released.",
flag: result.data.flag
});
this.#syncView();
} catch (error) {
this.state.busy = false;
this.state.paused = false;
this.#setPhase("FAIL", error.message || "Final verification failed.");
this.#syncView();
}
}
#bindEvents() {
window.addEventListener("keydown", (event) => {
void this.#onKeydown(event);
});
this.uiManager.authButton.addEventListener("click", () => {
void this.submitLicense();
});
this.uiManager.licenseInput.addEventListener("keydown", (event) => {
if (event.key === "Enter") {
event.preventDefault();
void this.submitLicense();
}
});
this.uiManager.restartButton.addEventListener("click", () => this.restart());
this.uiManager.resultCloseButton.addEventListener("click", () => {
this.uiManager.hideResult();
if (!this.state.gameOver) {
this.state.paused = false;
this.#syncView();
}
});
}
async #onKeydown(event) {
const direction = this.keyMap[event.key];
if (direction === undefined || this.state.paused || this.state.busy || this.state.gameOver) {
return;
}
const moveResult = this.gameManager.move(direction);
if (!moveResult.moved) {
return;
}
this.gameManager.addRandomTile();
this.#syncView();
if (!this.state.reached256 && moveResult.maxTile >= 256 && !this.state.activated) {
this.state.reached256 = true;
this.state.paused = true;
this.#setPhase("WAIT_LICENSE", "Reached 256. Enter the key to continue.");
this.uiManager.clearLicenseInput();
this.uiManager.showLicenseModal();
this.#syncView();
return;
}
if (!this.state.reached2048 && this.state.activated && moveResult.maxTile >= 2048) {
this.state.reached2048 = true;
this.state.paused = true;
this.state.busy = true;
this.#setPhase("VERIFYING_FINAL", "2048 reached. Verifying final state inside WASM...");
this.#syncView();
await this.#unlockFlag();
return;
}
if (!this.gameManager.hasMovesAvailable()) {
this.state.gameOver = true;
this.state.paused = true;
this.#setPhase("FAIL", "No moves left. Restart to try another run.");
this.uiManager.showResult({
title: "Game Over",
message: "棋盘已无可用移动,请重新开始。"
});
}
}
#collectContext() {
const snapshot = this.gameManager.getStateSnapshot();
return {
score: snapshot.score,
steps: snapshot.steps,
maxTile: snapshot.maxTile,
board: snapshot.board,
sessionToken: this.state.sessionToken
};
}
#syncView() {
const snapshot = this.gameManager.getStateSnapshot();
this.uiManager.render({
...snapshot,
phase: this.state.phase
});
}
#setPhase(phase, message) {
this.state.phase = phase;
this.uiManager.setBanner(message);
}
#createInitialState() {
return {
activated: false,
paused: false,
reached256: false,
reached2048: false,
gameOver: false,
busy: false,
phase: "INIT",
licenseKey: "",
sessionToken: ""
};
}
}
module.exports = {
GameController
};在game-controller.js 里可以看到:合成到 256 时会弹出授权框 , 输入 key 后调用 verifyLicense(), 成功后继续游戏 , 合成到 2048 时调用 unlockFlag()。
将 wasm_core.wasm 转成 wat 后,可以看到这些关键符号:
normalize_key
verify_license_impl
unlock_flag_impl
build_session_token
还能直接看到一些关键字符串:
license format must match NCTF-XXXX-XXXX-XXXX
reach 256 before requesting activation
reach 2048 before requesting the final unlock
arcade::unlock-seed
NCTF{decrypt_failed}
从这些我可以知道: key 格式固定为 NCTF-XXXX-XXXX-XXXX , 真 flag 不是明文存放,而是经过解密.
解密会用到 arcade::unlock-seed
我分析 verify_license,verify_license_impl 的逻辑可以还原为:
检查 maxTile >= 256
校验 key 格式
调用 normalize_key
对规范化后的 12 字符串做哈希
判断哈希结果是否等于常量 -57161169
normalize_key` 的作用是把:`NCTF-ABCD-EFGH-IJKL`变成:`ABCDEFGHIJKL也就是去掉前缀和横杠,只保留后面的 12 个字符。
WASM 中的哈希大致形式如下:
h = 4951
for i, ch in enumerate(norm):
c = 17881 if i % 2 == 0 else 40503
h = (((ch + c) * (i + 11)) ^ rol32(h, 3)) + ((ch << (i % 5)) ^ CONST)由于字符集被限制在大写字母和数字,可以直接把这 12 个字符,交给 Z3 去解。
先能解到一个碰撞 key: NCTF-HPOB-XXGC-5PBA
它确实可以通过 verify_license,但 unlock_flag 返回的不是正常 flag.
分析 unlock_flag , unlock_flag_impl 的关键逻辑如下:
检查 maxTile >= 2048
根据当前上下文重建 sessionToken
比较传入的 sessionToken 是否一致
再次 normalize_key
构造 key material:
normalized_key + "|arcade::unlock-seed"
用这个 key material 循环异或解密一段 42 字节密文
可以还原出解密公式:
km = (normalized_key + "|arcade::unlock-seed").encode()
flag = bytes(
enc[i] ^ ((29 * i + 17) & 0xff) ^ km[i % len(km)]
for i in range(42)
)这里最关键的是:
后 20 字节 |arcade::unlock-seed 是已知常量 , 所以 flag 的第 12..31 个字符可以直接解出来
这一段解出来后得到:
6-c4f2-4dbb-b270-f5d
联立约束求真正 key
此时我们已经有约束:
必须满足 verify_license 的哈希校验
把这些条件一起丢给 Z3,最终可以得到唯一有效的 normalized key:
RU57W45M2048
所以完整 key 为:
NCTF-RU57-W45M-2048
exp:
from z3 import BitVec, BitVecVal, Or, RotateLeft, Solver
TARGET = (-57161169) & 0xFFFFFFFF
ALNUM = [ord(c) for c in "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"]
HEXLOW = [ord(c) for c in "0123456789abcdef"]
ENC = [
13, 56, 42, 25, 169, 244, 236, 247, 250, 16, 53, 94,
39, 198, 182, 147, 230, 168, 83, 54, 11, 101, 131, 237,
196, 183, 95, 61, 99, 89, 39, 148, 134, 255, 231, 15,
66, 20, 95, 5, 200, 251,
]
def solve_normalized_key() -> str:
solver = Solver()
xs = [BitVec(f"x{i}", 32) for i in range(12)]
for x in xs:
solver.add(Or(*[x == v for v in ALNUM]))
# From the known plaintext prefix/suffix of NCTF{...}
known = {
0: ord("R"),
1: ord("U"),
2: ord("5"),
3: ord("7"),
4: ord("W"),
9: ord("0"),
}
for idx, value in known.items():
solver.add(xs[idx] == value)
# verify_license hash logic
h = BitVecVal(4951, 32)
for i, x in enumerate(xs):
c = 40503 if (i & 1) else 17881
term1 = BitVecVal(c, 32) + x
term2 = term1 * BitVecVal(i + 11, 32)
term3 = RotateLeft(h, 3)
term4 = (x << (i % 5)) ^ BitVecVal((-1515890086) & 0xFFFFFFFF, 32)
h = (term2 ^ term3) + term4
solver.add(h == BitVecVal(TARGET, 32))
# Force the unknown plaintext positions to stay lowercase hex
for i in range(5, 12):
plain = BitVecVal(ENC[i] ^ ((i * 29 + 17) & 0xFF), 32) ^ xs[i]
solver.add(Or(*[plain == v for v in HEXLOW]))
for pos in range(32, 41):
key_idx = pos - 32
plain = BitVecVal(ENC[pos] ^ ((pos * 29 + 17) & 0xFF), 32) ^ xs[key_idx]
solver.add(Or(*[plain == v for v in HEXLOW]))
assert solver.check().r == 1
model = solver.model()
return "".join(chr(model[x].as_long()) for x in xs)
def decrypt_flag(normalized_key: str) -> str:
km = (normalized_key + "|arcade::unlock-seed").encode()
out = []
for i, value in enumerate(ENC):
out.append(value ^ ((i * 29 + 17) & 0xFF) ^ km[i % len(km)])
return bytes(out).decode()
if __name__ == "__main__":
normalized = solve_normalized_key()
full_key = f"NCTF-{normalized[:4]}-{normalized[4:8]}-{normalized[8:12]}"
flag = decrypt_flag(normalized)
print("Normalized Key:", normalized)
print("License Key:", full_key)
print("Flag:", flag)
img
VM Encryptor
题目给了两个文件
vm-encryptor.exe
code.bin
- 题目本质是一个自定义虚拟机(VM)解释执行题
进一步分析可知,输入校验流程共分为:
按 3 字节分组,进行 24-bit 旋转/XOR 变换
对结果做 Base64 编码
对编码结果逐字节异或 0x63
与字节码中的固定 56 字节常量比较
在code.bin处 0x109d 有 密文:
const = bytes.fromhex(
"275a0b080a3a090d30314c37023a120e542a4c302c3211270631563712264c37"
"28325b375500481a0201112716004c372407113401365b27"
)在 0x10e3 能看到标准 Base64 表:
ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/
VM指令回复
把 VM 跳表恢复出来后,这题实际用到的指令并不多。最关键的几条如下:
| opcode | 语义 |
|---|---|
| 0x00 | jmp imm32 |
| 0x01 | jz imm32 |
| 0x02 | jnz imm32 |
| 0x03 | push8 imm8 |
| 0x04 | push32 imm32 |
| 0x05 | load8 [addr] |
| 0x06 | load32 [addr] |
| 0x07 | pop |
| 0x08 | store8 [addr] = val |
| 0x09 | store32 [addr] = val |
| 0x0a | add |
| 0x0b | sub |
| 0x0c | mul |
| 0x0d | div |
| 0x0e | mod |
| 0x0f | and |
| 0x10 | or |
| 0x11 | not |
| 0x12 | xor |
| 0x13 | shl |
| 0x14 | shr |
| 0x15 / 0x16 | eq |
| 0x17 | ne |
| 0x18 | lt |
| 0x19 | gt |
| 0x1a | le |
| 0x1b | ge |
| 0x1c | dup |
| 0x1d | swap |
| 0x1e | call imm32 |
| 0x1f | ret |
| 0x20 | puts(addr) |
| 0xff | halt |
还原*:*
先把最终常量逆掉:
stage1 = bytes(x ^ 0x63 for x in const)
print(stage1.decode())得到:
D9hkiYjnSR/TaYqm7I/SOQrDeR5TqE/TKQ8T6c+yabrDuc/TGdrWbU8D
对这串做 Base64 解码:
import base64
raw = base64.b64decode(stage1)
print(raw.hex())得到 42 字节:
0fd8648988e7491fd3698aa6ec8fd2390ac3791e53a84fd3290f13e9cfb269bac3b9cfd319dad66d4f03
继续结合 VM 字节码,可以看到它反复出现:
0xffffff
0x55757d
多次位移/组合
最后查 Base64 表
把这段逻辑还原后,每 3 字节块的正向变换是:
x = block24
x = rol24(x, 5)
x ^= 0x55757d
x = rol24(x, 11)
x ^= 0x55757d
x = rol24(x, 20)
x ^= 0x55757d然后把这个 24-bit 结果做 Base64 编码。
因此逆过程就是:
x = encoded_block
x = ror24(x ^ 0x55757d, 20)
x = ror24(x ^ 0x55757d, 11)
x = ror24(x ^ 0x55757d, 5)恢复出原始 3 字节。
exp:
import base64
const = bytes.fromhex(
"275a0b080a3a090d30314c37023a120e542a4c302c3211270631563712264c37"
"28325b375500481a0201112716004c372407113401365b27"
)
def ror24(x, n):
n %= 24
return ((x >> n) | (x << (24 - n))) & 0xffffff
key = 0x55757d
# Step 1: 逆掉最后一层 xor 0x63
stage1 = bytes(b ^ 0x63 for b in const)
print("[+] stage1 =", stage1.decode())
# Step 2: Base64 解码
raw = base64.b64decode(stage1)
print("[+] raw =", raw.hex())
# Step 3: 逐 3 字节逆 24-bit 变换
flag = bytearray()
for i in range(0, len(raw), 3):
x = int.from_bytes(raw[i:i+3], "big")
x = ror24(x ^ key, 20)
x = ror24(x ^ key, 11)
x = ror24(x ^ key, 5)
flag += x.to_bytes(3, "big")
print("[+] flag =", flag.decode())
[+] stage1 = D9hkiYjnSR/TaYqm7I/SOQrDeR5TqE/TKQ8T6c+yabrDuc/TGdrWbU8D
[+] raw = 0fd8648988e7491fd3698aa6ec8fd2390ac3791e53a84fd3290f13e9cfb269bac3b9cfd319dad66d4f03
[+] flag = NCTF{1578be15-ad09-4859-9193-5d52585eb485}鸡爪流高手
这题表面上看像一个五子棋小游戏,在服务端业务逻辑里:
服务维护一个玩家排行榜,玩家通过和机器人下五子棋获得或失去分数。
分数结算时,玩家和对手走了两套不同的更新函数。
玩家使用的那套更新函数存在 bug,可以把分数打成负数。
负分写入 SQLite 时又触发了有符号/无符号处理错误,最终把 -1 写成了 4294967295*。*
一旦自己的分数变成 4294967295*,就会直接冲到排行榜第一,随后即可调用取 flag 接口。*
所以这题的本质利用链是:
错误的 Elo 结算
-> 得到负分
-> 负分被错误地零扩展写库
-> 分数变成 4294967295
-> rank = 1
-> 拿 flag我分析协议:
逆向 protocol_read_packet 和 protocol_write_response 后可以得到通信格式:
4 bytes magic = "GAME"
4 bytes len = big-endian,总长度
1 byte cmd = 命令号
N bytes payload = 命令参数服务端返回包格式相同。
命令号
根据 handler_handle_packet 的分发表,可以恢复出命令映射:
取 flag
查询当前分数和排名
查询附近排行榜
reset 当前挑战状态
随机匹配一个对手并开始对局
查看当前棋盘
落子,payload 形如 x,y
在空棋盘上相当于取消对局,在非空棋盘上相当于认输结算
分析*数据库与排行榜逻辑:*
服务端内置 SQLite,核心表结构非常简单:
CREATE TABLE IF NOT EXISTS players (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
score INTEGER NOT NULL
);reset 之后,玩家初始分数固定为 50。服务里还会维护若干机器人。
db_pick_default_opponent 对应的 SQL 是:
SELECT id, name, score
FROM players
WHERE name != ? AND score <= 50
ORDER BY RANDOM()
LIMIT 1;也就是说,cmd=5 只能随机匹配 score <= 50 的对手。
reset 的隐藏重点*:*
db_reset_challenge_state 不是简单地清空当前状态,它会把若干固定机器人分数重置为一组常量:
[50, 40, 30, 20, 20, 10, 2000000]
这意味着:
服务里天然存在一个 2000000 分的榜一机器人
只要能把自己分数打成 4294967295,就会直接无敌
分析*五子棋逻辑与可用赢法:*
棋盘大小为 15 x 15,玩家执黑,AI 执白。
实战中,第 3 步需要对 10 分机器人稳定拿下一盘。脚本中使用的固定赢法如下:
(7,7)
(6,7)
(8,7)
(9,7)
(10,7)这条线是可行的。一次本地复现时 AI 的应对为:
7,7 -> AI=6,6
6,7 -> AI=5,5
8,7 -> AI=4,4
9,7 -> AI=5,7
10,7 -> Win最后形成横向五连,成功获胜。
开始定位*漏洞点:*
结算流程*:*
settle_game 是整道题最关键的函数。
我分析它做了两件事:
读取玩家当前分数、对手当前分数
分别调用两个不同的分数更新函数
那么伪代码可以概括为:
player_new = score_apply_buggy_update(player_score, opponent_score, result);
update_db(player_name, player_new);
opponent_new = score_apply_update(opponent_score, player_score, 1.0 - result);
update_db(opponent_name, opponent_new);这里最关键的一点是:
玩家:score_apply_buggy_update
对手:score_apply_update
也就是只有玩家这边走了带 bug 的更新逻辑。
正常版本 score_apply_update
正常版本大致等价于:
delta = lround(32 * (actual - expected));
new_score = current + delta;
if (new_score < 0) new_score = 0;
return new_score;它会把分数下界截断到 0,因此不会出现负数。
Buggy 版本 score_apply_buggy_update
玩家侧的 buggy 版本在尾部多了一个特殊分支,逻辑近似为:
delta = lround(32 * (actual - expected));
if (current_score <= 9 && delta < 0) {
return current_score;
}
return current_score + delta;这段逻辑分析出来两个严重问题:
没有像正常版本一样对结果做 max(new_score, 0) 的保护。
只在 current_score <= 9 && delta < 0 时才“保底不掉分”。
换句话说,只要玩家当前分数 大于 9,并且这次输棋扣分幅度足够大,就能直接得到负数。
写库时的整数符号错误*:*
db_update_player_score 里最终通过 sqlite3_bind_int64 把分数写回数据库。
问题在于调用时只把新分数放进了 edx,在 x86-64 SysV ABI 下这会发生 零扩展:
edx = -1
rdx = 0x00000000ffffffff于是原本应该是 -1 的分数,被当成了:4294967295
SQLite 里看到的就是一个超大的正整数。
如何构造 -1
题目的核心不是任意改分,而是把当前分数打成 -1。
远端 reset 后,脚本实测拿到的一组关键对手如下:
残阳余温(50)
余晖静候(30)
指尖依旧(10)
安然重演(20)
风月空守(20)
利用顺序如下*:*
Step 1. 对 50 分机器人认输*:*
50 -> 40
返回类似:
Resign Delta=-10 Score=40 Rank=1003 OppDelta=10 OppScore=60
Step 2. 对 30 分机器人认输*:*
40 -> 28
返回类似:
Resign Delta=-12 Score=28 Rank=1005 OppDelta=12 OppScore=42
Step 3. 打赢 10 分机器人*:*
使用前面那条固定赢法:
(7,7) (6,7) (8,7) (9,7) (10,7)
结果:
28 -> 34
同时这个 10 分机器人会掉到:
10 -> 4
Step 4. 对一个 20 分机器人认输*:*
34 -> 21
Step 5. 对另一个 20 分机器人认输*:*
21 -> 11
Step 6. 再去匹配刚才已经掉到 4 分的机器人,并认输*:*
此时玩家分数是 11,对手分数是 4。
根据 buggy Elo 计算,这一步会得到:
delta = -12
11 + (-12) = -1Resign Delta=-12 Score=4294967295 Rank=1 OppDelta=12 OppScore=16
注意这里响应里已经直接显示了 Score=4294967295,说明零扩展写库已经成功发生。
Step 7. 调用取 flag 接口*:*
这时候再发 cmd=1 即可:
NCTF{258454e8-045f-4d6c-819b-e289aa8c2607}