NCTF 2026 Reverse Write Up

2026 年 4 月 22 日 星期三(已编辑)
48
1
这篇文章上次修改于 2026 年 4 月 23 日 星期四,可能部分内容已经不适用,如有疑问可询问作者。

NCTF 2026 Reverse Write Up

Reverse

Hook My Secret

题目一共有3个Stage

Stage1:

1

1

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

2

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

也就是

img

img

成功来到

Stage2*:*

img

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;
}
丢给AI分析,可以得到这样的逻辑
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
再找到目标数组[250, 113, 87, 185, 6, 125, 167, 156, 4, 0, 229, 239, 119, 155, 187, 95]

Stage2 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

img

所以Key为k7Xm2Pq9Wv4N8bRt

Stage3*:*

img

img
img

img

发现stage3使用了AES加密,密文是jSaMnziall55Tdr+IZc7EKUNm/N4uwrZw1QFPw6DuirfYFJZg88j6GKLhWfNljAB

密钥是stage2的key所以还差个IV

img

img

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

img

img

IV已有,解密即可

img

img
img

img

No My Bank!

一开始 GDRE Tools 直接恢复不完整,看起来像是资源和脚本被加密了。实际情况分成两层:

  1. NoMyBank.exe 内嵌了一个加密的 Godot PCK。
  2. 根目录下还有一个额外加密的 libextension.dll,这是 GDExtension,真正的校验逻辑在里面。

    先解第一层:

使用 KeyDotNoMyBank.exe 中提取到 PCK 解密 key:

D34BFF62613FDD2861F6D5942C5E99A53EF3E90ADBE9091B4686859D5B7DAB22

再用GDRE进行解密

img

img
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

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;
}

题目采用三层处理:

  1. 明文经自定义 TEA-like 算法加密,参数为:

    delta = 0x114514

    key = [0x12345678, 0x9ABCDEF0, 0x13579BDF, 0x2468ACE0]

  2. TEA 输出再经自定义 Base64 编码,字符表为

    ZYXWVUTSRQPONMLKJIHGFEDCBAzyxwvutsrqponmlkjihgfedcba9876543210+/

    且 4 个 6-bit 输出顺序为 [b,a,d,c]

  3. 最后再做逐字节异或 + 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
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

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语义
0x00jmp imm32
0x01jz imm32
0x02jnz imm32
0x03push8 imm8
0x04push32 imm32
0x05load8 [addr]
0x06load32 [addr]
0x07pop
0x08store8 [addr] = val
0x09store32 [addr] = val
0x0aadd
0x0bsub
0x0cmul
0x0ddiv
0x0emod
0x0fand
0x10or
0x11not
0x12xor
0x13shl
0x14shr
0x15 / 0x16eq
0x17ne
0x18lt
0x19gt
0x1ale
0x1bge
0x1cdup
0x1dswap
0x1ecall imm32
0x1fret
0x20puts(addr)
0xffhalt

还原*:*

先把最终常量逆掉:

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_packetprotocol_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) = -1
服务端返回:

Resign Delta=-12 Score=4294967295 Rank=1 OppDelta=12 OppScore=16

注意这里响应里已经直接显示了 Score=4294967295,说明零扩展写库已经成功发生。

Step 7. 调用取 flag 接口*:*

这时候再发 cmd=1 即可:

NCTF{258454e8-045f-4d6c-819b-e289aa8c2607}

使用社交账号登录

  • Loading...
  • Loading...
  • Loading...
  • Loading...
  • Loading...