首页
社区
课程
招聘
从NCTF2026-NoMyBank!到Godot新特性下的游戏逆向分析破解
发表于: 6小时前 93

从NCTF2026-NoMyBank!到Godot新特性下的游戏逆向分析破解

6小时前
93

前言

起因是笔者去年CISCN遇到godot游戏逆向题后,在小黑盒刷到一篇关于godot独立游戏作品保护的文章,文章里提到了godot支持pck加密机制,于是就想着找机会看看是怎么个事。这道题的最初思路就这么诞生了。但是详细了解pck加密机制后发现,godot平台提供的这一层保护还是太脆弱了,不够有挑战性,于是发动AI之力了解到了godot4.x的新特性:GDExtension系统。GDExtension系统可以将游戏扩展到其他语言生态,就有点像安卓native层的感觉。加上godot本身就是一个开源游戏引擎,源码是可获取的,且平台支持修改引擎并导出游戏,可以利用这点来增加一些游戏保护措施。于是最终笔者利用godot平台实现了三个挑战点:pck加密保护、dll解密加载、gdextension存放核心逻辑。(实际上pck加密保护属于弱挑战点,因为从解题角度来说没什么必要去攻克这点)

由于引擎的开源属性,godot游戏反逆向还是具有挑战性的,毕竟源码公开,要破解游戏也只是时间问题。抛开这点不谈,godot游戏反逆向还存在另一个挑战点:引擎中使用了大量ERR_系列宏,导致报错信息大部分是嵌入在程序中的,相关信息如函数名也会被转成字符串嵌在反编译代码中,以此为切入可以很方便地在IDA中找到想要分析的函数。本题就是利用了这一特性来找pck加密使用的密钥,此外笔者在实现dll动态解密时也故意去除了原代码中的ERR_宏使用以对抗分析。

WriteUp

Kruse几个月前入坑了BlueArchive,在了解泳装蒙面团的事迹之后,他灵机一动用开源引擎godot制作了一个迷宫小游戏”NoMyBank!“。游戏制作完成后,Kruse把demo发给了SydzI,让他帮忙测试测试。SydzI认为Kruse的游戏不安全,给它加上了一些保护措施,并声称在迷宫深处的宝箱中放入了一个神秘礼物,你能帮Kruse找到这个礼物吗?注:迷宫出口在右侧。附件解压路径请勿包含中文。

题目链接:NoMyBank!.zip - Google 云端硬盘

godot逆向题,利用了godot 4.x支持的pck加密和cpp extension机制,同时改了godot引擎的dll加载机制,考察了hook重定向和smc。以下为预期解:

获取基础信息

附件解压出来是一个exe和一个dll。分析发现游戏本体和dll都被处理过,DIE提示游戏本体被打包,而dll会被识别为未知二进制文件,进一步用010editor打开dll会发现整个dll都被加密了(完全看不到PE文件格式特征)

意味着如果要分析dll,首先要找到游戏本体加载dll时对dll进行了什么处理。

尝试解包

godot游戏实质上是由引擎和资源文件组合成的(即DIE提示的“打包”),解包游戏可以获得游戏开发时使用的代码和素材等资源文件,所以第一步可以先尝试解包游戏。用GDRETools解包exe会发现解包失败,提示需要设置密钥:

查找资料会发现godot4.x版本推出了PCK加密编译的机制,可以使用256位AES密钥加密PCK文件(即godot的资源文件)。对于查找PCK加密密钥的方法,网上已有现成的资料,可以参考BV14NtozWEFN

原理是godot内部有一个处理报错的宏定义,该宏会将报错的相关明文信息嵌入在程序中

用IDA打开游戏本体,待加载完毕后,打开字符串窗口,搜索”can't open encrypted pack directory“,跟进到字符串出现的函数,如下图

在两个匹配字符串中间可以看到一个for循环,其中循环读取的byte_144D85CD0[i]即为密钥:

提取出这串数字D34BFF62613FDD2861F6D5942C5E99A53EF3E90ADBE9091B4686859D5B7DAB22,在GDRETools中选中菜单栏的“RETools”,选择“Set encryption key”输入密钥即可解包游戏

解包出来的文件夹结构如下:

在Scripts文件夹内可以找到存放游戏逻辑的.gd文件,在WinPopupTreasury.gd里可以找到一些提示:

此处有一个叫做get_checker_from_loaded_dll_node的函数,在_on_buttun_pressed中这个函数被调用并返回了一个flag_checker,显而易见libextension.dll实现的应该就是类似flag检验的功能了。所以接下来转向分析dll

寻找dll解密逻辑

试着修改dll的名字,运行游戏可以发现会触发报错:

此处的报错信息显然经过特殊处理,可以尝试使用这个报错信息来定位游戏本体中的dll加载函数(前提是这段信息被硬编码在程序中,而非被实时解密)

在IDA字符串窗口,搜索“D11 f1l3 n0t f0und”,可以发现报错信息确实是硬编码的,跟进就可以找到dll加载函数

PS:审WP的时候发现大部分师傅都是分析调用模块直接找到解密后的dll的,这个方法更好,预期解有点偏门了()

分析dll加载函数

通过上述方法找到的dll加载函数如下:

__int64 __fastcall sub_140013FA0(__int64 a1, __int64 a2, HMODULE *a3, __int64 a4)
{
  __int64 v4; // rax
  __int64 v5; // rax
  const char *v6; // rax
  __int64 v8; // rax
  __int64 v9; // rax
  __int64 v10; // rax
  __int64 v11; // rax
  __int64 v12; // rax
  __int64 v13; // rax
  __int64 v14; // rax
  const WCHAR *v15; // rax
  __int64 v16; // rax
  const WCHAR *v17; // rax
  __int64 v18; // rax
  int v19; // [rsp+20h] [rbp-478h]
  int v20; // [rsp+20h] [rbp-478h]
  CHAR v21; // [rsp+24h] [rbp-474h]
  CHAR v22; // [rsp+25h] [rbp-473h]
  char v23; // [rsp+27h] [rbp-471h]
  int j; // [rsp+28h] [rbp-470h]
  int v25; // [rsp+2Ch] [rbp-46Ch]
  _BYTE v26[8]; // [rsp+30h] [rbp-468h] BYREF
  int i; // [rsp+38h] [rbp-460h]
  int k; // [rsp+3Ch] [rbp-45Ch]
  int v29; // [rsp+40h] [rbp-458h]
  _BYTE v30[8]; // [rsp+48h] [rbp-450h] BYREF
  DWORD dwFlags; // [rsp+50h] [rbp-448h]
  __int64 v32; // [rsp+58h] [rbp-440h]
  unsigned int v33; // [rsp+60h] [rbp-438h]
  unsigned int v34; // [rsp+64h] [rbp-434h]
  int v35; // [rsp+68h] [rbp-430h]
  int v36; // [rsp+6Ch] [rbp-42Ch]
  unsigned int v37; // [rsp+70h] [rbp-428h]
  unsigned int v38; // [rsp+74h] [rbp-424h]
  LPVOID lpMem; // [rsp+78h] [rbp-420h]
  LPVOID v40; // [rsp+80h] [rbp-418h]
  DLL_DIRECTORY_COOKIE Cookie; // [rsp+88h] [rbp-410h]
  _BYTE v42[8]; // [rsp+90h] [rbp-408h] BYREF
  _BYTE v43[8]; // [rsp+98h] [rbp-400h] BYREF
  const char *v44; // [rsp+A0h] [rbp-3F8h]
  __int64 v45; // [rsp+A8h] [rbp-3F0h]
  _BYTE v46[8]; // [rsp+B0h] [rbp-3E8h] BYREF
  _BYTE v47[8]; // [rsp+B8h] [rbp-3E0h] BYREF
  __int64 (__fastcall *v48)(__int64, _BYTE *); // [rsp+C0h] [rbp-3D8h]
  __int64 v49; // [rsp+C8h] [rbp-3D0h]
  __int64 v50; // [rsp+D0h] [rbp-3C8h]
  __int64 v51; // [rsp+D8h] [rbp-3C0h]
  _BYTE v52[8]; // [rsp+E0h] [rbp-3B8h] BYREF
  _BYTE v53[8]; // [rsp+E8h] [rbp-3B0h] BYREF
  _BYTE v54[8]; // [rsp+F0h] [rbp-3A8h] BYREF
  _BYTE v55[8]; // [rsp+F8h] [rbp-3A0h] BYREF
  _BYTE v56[8]; // [rsp+100h] [rbp-398h] BYREF
  _BYTE v57[8]; // [rsp+108h] [rbp-390h] BYREF
  __int64 v58; // [rsp+110h] [rbp-388h]
  _BYTE v59[8]; // [rsp+118h] [rbp-380h] BYREF
  _BYTE v60[8]; // [rsp+120h] [rbp-378h] BYREF
  _BYTE v61[8]; // [rsp+128h] [rbp-370h] BYREF
  __int64 v62; // [rsp+130h] [rbp-368h]
  __int64 v63; // [rsp+138h] [rbp-360h]
  _BYTE v64[8]; // [rsp+140h] [rbp-358h] BYREF
  _BYTE v65[8]; // [rsp+148h] [rbp-350h] BYREF
  _BYTE v66[8]; // [rsp+150h] [rbp-348h] BYREF
  _BYTE v67[8]; // [rsp+158h] [rbp-340h] BYREF
  __int64 v68; // [rsp+160h] [rbp-338h]
  CHAR v69[256]; // [rsp+170h] [rbp-328h]
  CHAR Buffer[272]; // [rsp+270h] [rbp-228h] BYREF
  CHAR Text[256]; // [rsp+380h] [rbp-118h] BYREF

  sub_140020BB0(v26, a2);
  *(_BYTE *)(a4 + 16) = 1;
  if ( !(unsigned __int8)sub_142D29AF0(v26) )
  {
    v48 = *(__int64 (__fastcall **)(__int64, _BYTE *))(*(_QWORD *)a1 + 256LL);
    v49 = v48(a1, v55);
    v51 = sub_142CB2A10(v49, v54);
    v50 = sub_142CB2D10(a2, v53);
    v4 = sub_142CAEF80(v51, v52, v50);
    sub_140021720(v26, v4);
    sub_14000E310(v52);
    sub_14000E310(v53);
    sub_14000E310(v54);
    sub_14000E310(v55);
  }
  if ( (unsigned __int8)sub_142D29AF0(v26) )
  {
    sub_140020BB0(v30, v26);
    if ( (unsigned __int8)sub_142D29AF0(v26) )
    {
      v8 = sub_142CAF5C0(v26, v57, 0LL);
      v9 = sub_140027A40(v8);
      v32 = sub_143760A24(v9, "rb");
      sub_14000E310(v57);
      sub_143761080(v32, 0LL, 2LL);
      v29 = sub_143761748(v32);
      sub_143761080(v32, 0LL, 0LL);
      lpMem = (LPVOID)sub_1437604A0(v29);
      sub_143760C9C(lpMem, 1LL, v29, v32);
      sub_143760774(v32);
      v44 = "G00dLuck2U";
      v35 = sub_143799640("G00dLuck2U");
      for ( i = 0; i < 256; ++i )
        v69[i] = i;
      v19 = 0;
      for ( j = 0; j < 256; ++j )
      {
        v36 = (unsigned __int8)v69[j] + v19;
        v19 = (v44[j % v35] + v36) % 256;
        v21 = v69[j];
        v69[j] = v69[v19];
        v69[v19] = v21;
      }
      v40 = (LPVOID)sub_1437604A0(v29);
      v25 = 0;
      v20 = 0;
      for ( k = 0; k < v29; ++k )
      {
        v25 = (v25 + 1) % 256;
        v20 = ((unsigned __int8)v69[v25] + v20) % 256;
        v22 = v69[v25];
        v69[v25] = v69[v20];
        v69[v20] = v22;
        *((_BYTE *)v40 + k) = v69[((unsigned __int8)v69[v20] + (unsigned __int8)v69[v25]) % 256] ^ *((_BYTE *)lpMem + k);
      }
      sub_143760490(lpMem);
      GetTempPathA(0x104u, Buffer);
      sub_143799700(Buffer, "_");
      v58 = sub_142CB2D10(v26, v60);
      v10 = sub_142CAF5C0(v58, v59, 0LL);
      v11 = sub_140027A40(v10);
      sub_143799700(Buffer, v11);
      sub_14000E310(v59);
      sub_14000E310(v60);
      DeleteFileA(Buffer);
      v45 = sub_143760A24(Buffer, "wb");
      sub_143761B50(v40, 1LL, v29, v45);
      sub_143760774(v45);
      SetFileAttributesA(Buffer, 2u);
      sub_143760490(v40);
      v12 = sub_14000E2B0(v61, Buffer);
      sub_140021720(v30, v12);
      sub_14000E310(v61);
      Cookie = 0LL;
      sub_14001D560(v43, v30);
      v63 = sub_142D0D680();
      v62 = sub_142CB2A10(v30, v65);
      v13 = sub_142D0C8C0(v63, v64, v62);
      sub_14001D560(v42, v13);
      sub_14000E310(v64);
      sub_14000E310(v65);
      if ( a4 && *(_BYTE *)a4 )
      {
        v14 = sub_142CB0D30(v42, v66);
        v15 = (const WCHAR *)sub_140027A80(v14);
        Cookie = AddDllDirectory(v15);
        sub_14000E310(v66);
      }
      if ( a4 && *(_BYTE *)a4 )
        dwFlags = 4096;
      else
        dwFlags = 0;
      v16 = sub_142CB0D30(v43, v67);
      v17 = (const WCHAR *)sub_140027A80(v16);
      *a3 = LoadLibraryExW(v17, 0LL, dwFlags);
      sub_14000E310(v67);
      if ( !*a3 )
      {
        sub_14000E2B0(v46, Buffer);
        v23 = sub_142D29AF0(v46);
        sub_14000E310(v46);
        if ( v23 )
        {
          sub_14000E2B0(v47, Buffer);
          sub_142D42050(v47);
          sub_14000E310(v47);
        }
      }
      if ( *a3 )
      {
        if ( Cookie )
          RemoveDllDirectory(Cookie);
        if ( a4 && *(_QWORD *)(a4 + 8) )
          sub_1400214F0(*(_QWORD *)(a4 + 8), v26);
        if ( a4 )
        {
          if ( *(_BYTE *)(a4 + 16) )
          {
            v68 = a1 + 736;
            v18 = sub_140021910(a1 + 736, a3);
            sub_140021750(v18, Buffer);
          }
        }
        v38 = 0;
        sub_14000E310(v42);
        sub_14000E310(v43);
        sub_14000E310(v30);
        sub_14000E310(v26);
        return v38;
      }
      else
      {
        v37 = 19;
        sub_14000E310(v42);
        sub_14000E310(v43);
        sub_14000E310(v30);
        sub_14000E310(v26);
        return v37;
      }
    }
    else
    {
      v34 = 7;
      sub_14000E310(v30);
      sub_14000E310(v26);
      return v34;
    }
  }
  else
  {
    v5 = sub_142CAF5C0(v26, v56, 0LL);
    v6 = (const char *)sub_140027A40(v5);
    sub_1400298D0(Text, "D11 f1l3 n0t f0und: %s", v6);
    sub_14000E310(v56);
    MessageBoxA(0LL, Text, "D11 L0ad Err0r", 0x10u);
    v33 = 7;
    sub_14000E310(v26);
    return v33;
  }
}

可以看到该函数前半部分出现了“rb”、“wb”字样,且中间是类似RC4的算法,密钥为“G00dLuck2U“。此处出现“wb”,说明游戏在加载dll的时候会先将其解密到某个路径再加载,在此处下断点查看Buffer:

由此可知解密后的dll会被放置在系统的临时目录下。让游戏继续运行,就可以在系统临时目录下找到解密后的_libextension.dll。但是如果此时终止调试,会发现_libextension.dll从系统临时目录消失了,因此推测程序为了避免解密后的dll被发现,还做了用完即删的处理,但这不妨碍我们得到解密后的dll,复制一份即可。

用010editor打开解密后的dll,发现PE文件格式特征出现了,解密成功。

分析libextension.dll

IDA打开解密后的dll,在字符串窗口可以发现可疑字样

跟进可以找到如下函数

__int64 __fastcall sub_180001850(__int64 a1, char a2)
{
  __int64 v2; // rax
  __int64 v3; // rax
  __int64 v5; // [rsp+20h] [rbp-E8h]
  __int64 v6; // [rsp+28h] [rbp-E0h]
  __int64 v7; // [rsp+30h] [rbp-D8h]
  __int64 v8; // [rsp+38h] [rbp-D0h]
  __int64 v9; // [rsp+40h] [rbp-C8h]
  __int64 v10; // [rsp+48h] [rbp-C0h]
  __int64 v11; // [rsp+50h] [rbp-B8h]
  __int64 v12; // [rsp+58h] [rbp-B0h]
  __int64 v13; // [rsp+60h] [rbp-A8h]
  __int64 v14; // [rsp+68h] [rbp-A0h]
  __int64 v15; // [rsp+70h] [rbp-98h]
  __int64 v16; // [rsp+78h] [rbp-90h]
  __int64 v17; // [rsp+80h] [rbp-88h]
  _BYTE v18[8]; // [rsp+88h] [rbp-80h] BYREF
  _BYTE v19[8]; // [rsp+90h] [rbp-78h] BYREF
  _BYTE v20[8]; // [rsp+98h] [rbp-70h] BYREF
  _BYTE v21[8]; // [rsp+A0h] [rbp-68h] BYREF
  _BYTE v22[8]; // [rsp+A8h] [rbp-60h] BYREF
  _BYTE v23[8]; // [rsp+B0h] [rbp-58h] BYREF
  _BYTE v24[8]; // [rsp+B8h] [rbp-50h] BYREF
  _BYTE v25[8]; // [rsp+C0h] [rbp-48h] BYREF
  _BYTE v26[8]; // [rsp+C8h] [rbp-40h] BYREF
  _BYTE v27[8]; // [rsp+D0h] [rbp-38h] BYREF
  _BYTE v28[16]; // [rsp+D8h] [rbp-30h] BYREF
  _BYTE v29[16]; // [rsp+E8h] [rbp-20h] BYREF

  sub_18006AB40(*(_QWORD *)(a1 + 24));
  sub_1800030A0();
  v5 = sub_18000C8D0(24LL, &unk_1800EA585, &unk_1800EA584);
  if ( v5 )
  {
    v6 = sub_180003EA0(v5);
    v2 = sub_180003070(v6);
  }
  else
  {
    v2 = sub_180003070(0LL);
  }
  *(_QWORD *)(a1 + 32) = v2;
  if ( a2 )
  {
    v7 = *(_QWORD *)(a1 + 32);
    sub_180014E80(v19, "Right!");
    sub_180068540(v7, v19);
    sub_180010BE0(v19);
    v3 = sub_180006790(&unk_180150598);
    sub_180014E80(v20, v3);
    v9 = *(_QWORD *)(a1 + 32);
    v8 = sub_1800164A0(v27, "Good job! Here is your gift: ", v20);
    sub_180074F30(v9, v8);
    sub_180010BE0(v27);
    sub_180010BE0(v20);
  }
  else
  {
    v10 = *(_QWORD *)(a1 + 32);
    sub_180014E80(v21, "Wrong!");
    sub_180068540(v10, v21);
    sub_180010BE0(v21);
    v11 = *(_QWORD *)(a1 + 32);
    sub_180014E80(v22, "What a pity! Try again.");
    sub_180074F30(v11, v22);
    sub_180010BE0(v22);
  }
  sub_180040AA0(a1, *(_QWORD *)(a1 + 32), 0LL, 0LL);
  v13 = *(_QWORD *)(a1 + 32);
  v12 = sub_180004980(v18, 0LL, 0LL);
  sub_180072E40(v13, v12);
  if ( a2 )
  {
    v17 = *(_QWORD *)(a1 + 32);
    sub_180016810(v26, "quit_game", 0LL);
    v16 = sub_180020780(v29, a1, v26);
    sub_180016810(v25, "confirmed", 0LL);
    sub_18002B1E0(v17, v25, v16, 0LL);
    sub_18001CAC0(v25);
    sub_180020860(v29);
    return sub_18001CAC0(v26);
  }
  else
  {
    v15 = *(_QWORD *)(a1 + 32);
    sub_180016810(v24, "show_flag_dialog", 0LL);
    v14 = sub_180020780(v28, a1, v24);
    sub_180016810(v23, "confirmed", 0LL);
    sub_18002B1E0(v15, v23, v14, 0LL);
    sub_18001CAC0(v23);
    sub_180020860(v28);
    return sub_18001CAC0(v24);
  }
}

其中Right和Wrong的情况由变量a2决定,而a2是作为该函数的第二个参数传入的,因此回溯到上级函数:

可以看到该参数是前一个函数sub_180002A40的返回值,跟进sub_180002A40:

函数开头对参数a1进行了疑似长度检验的操作,推测a1即为要求输入的数据(即提示中说的“key”)。随后的sub_180006790是堆栈检查函数,然后输入的数据被复制到v4中,作为另一个函数sub_1800024A0的参数,跟进sub_1800024A0:

_DWORD *__fastcall sub_1800024A0(__int64 a1, __int64 a2, _DWORD *a3)
{
  _DWORD *result; // rax
  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 += 1131796;
      v6 += (dword_18014F498[1] + (v7 >> 5)) ^ (v9 + v7) ^ (dword_18014F498[0] + 16 * v7);
      v7 += (dword_18014F498[3] + (v6 >> 5)) ^ (v9 + v6) ^ (dword_18014F498[2] + 16 * v6);
    }
    v11[j] = v6;
    v11[j + 1] = v7;
  }
  for ( m = 0; m < 10; ++m )
    sub_180002410((unsigned int)v11[m], 4 * m + a2);
  result = a3;
  *a3 = 40;
  return result;
}

这里是一个魔改了delta的TEA加密。回到上级函数继续分析,函数的最后使用memcpy比较了Buf1和预设的数据值,若两个数据相同,随后的sub_180002950会对输入的数据进行一些字符变换:

__int64 __fastcall sub_180002950(__int64 a1)
{
  const void *v1; // rax
  __int64 v2; // rax
  int i; // [rsp+20h] [rbp-68h]
  _BYTE v5[32]; // [rsp+28h] [rbp-60h] BYREF
  _BYTE v6[48]; // [rsp+48h] [rbp-40h] BYREF

  v1 = (const void *)sub_180006790(a1);
  memcpy(v6, v1, 0x28uLL);
  for ( i = 0; i < 40; ++i )
  {
    if ( v6[i] == 111 )// 'o'
      v6[i] = 48;// '0'
    if ( v6[i] == 101 )// 'e'
      v6[i] = 51;// '3'
    if ( v6[i] == 105 )// 'i'
      v6[i] = 49;// '1'
  }
  v2 = sub_180003D90(v5, v6, 40LL);
  sub_180004F50(&unk_180150598, v2);
  return sub_180004CD0(v5);
}

这样分析下来,输入数据的检验逻辑应该只经过了一层TEA加密。至于最后的变换函数,仔细观察可以发现,最开始输出Right/Wrong的函数里,Right的情况也出现了unk_180150598

而这个数据最终流向了v20,作为“gift”。所以推测提示中的gift是由key变换而来的。但是,尝试使用TEA解密预设数据会发现根本得不到有意义的明文,因此,程序中可能有隐藏的逻辑。

寻找隐藏的逻辑

这里提供静态分析和动态分析两种方法:

静态分析

遇到这种莫名其妙的情况,先考虑有没有hook在背地里修改程序。

在IDA的Exports窗口可以看到有两个TlsCallback,Function name窗口也可以搜索到

跟进TlsCallback_1就可以看到如下内容

此处反编译可能有些问题,但是从汇编代码可以直观看出TlsCallback_1先获取了dll基址,随后加上0x24A0计算出TEA的实际地址(和函数名sub_1800024A0对上了),TEA的地址传给lpAddress作为后续memcpy的参数。也就是说这个回调函数hook了TEA,并将其修改成了Src,而这里的Src构成了一个mov rax,[目标函数地址] jump rax的重定向。跟进sub_180002700可以发现这是一个smc函数

综上,程序执行TlsCallback_1时会将TEA重定向到这个smc函数,随后smc函数从byte_18014F000获取数据进行解密,解密结果qword_1801505B8最终被当作一个函数执行。因此,TEA并非真正的加密函数,真正的加密函数要通过smc得到。写一个IDAPython脚本解密byte_18014F000处的数据:

import idc

start_addr = 0x18014F000
len = 0x491

for i in range(len):
    code = idc.get_wide_byte(start_addr + i)
    code = code ^ 0xba
    idc.patch_byte(start_addr+i,code)

解密后选中byte_180165000,将这段数据建立成函数,反编译得到如下:

_DWORD *__fastcall sub_18014F000(__int64 a1, __int64 a2, _DWORD *a3)
{
  _DWORD *result; // rax
  int j; // [rsp+0h] [rbp-108h]
  int k; // [rsp+0h] [rbp-108h]
  int m; // [rsp+0h] [rbp-108h]
  int v7; // [rsp+4h] [rbp-104h]
  int v8; // [rsp+4h] [rbp-104h]
  int v9; // [rsp+4h] [rbp-104h]
  int v10; // [rsp+4h] [rbp-104h]
  unsigned __int8 v11; // [rsp+Dh] [rbp-FBh]
  int i; // [rsp+10h] [rbp-F8h]
  unsigned int v13; // [rsp+18h] [rbp-F0h]
  unsigned __int8 v14; // [rsp+1Ch] [rbp-ECh]
  unsigned __int8 v15; // [rsp+20h] [rbp-E8h]
  _BYTE v16[216]; // [rsp+30h] [rbp-D8h]

  v16[0] = -91;
  v16[1] = -90;
  v16[2] = -89;
  v16[3] = -88;
  v16[4] = -87;
  v16[5] = -86;
  v16[6] = -85;
  v16[7] = -84;
  v16[8] = -83;
  v16[9] = -82;
  v16[10] = -81;
  v16[11] = -80;
  v16[12] = -79;
  v16[13] = -78;
  v16[14] = -77;
  v16[15] = -76;
  v16[16] = -75;
  v16[17] = -74;
  v16[18] = -73;
  v16[19] = -72;
  v16[20] = -71;
  v16[21] = -70;
  v16[22] = -69;
  v16[23] = -68;
  v16[24] = -67;
  v16[25] = -66;
  v16[26] = -123;
  v16[27] = -122;
  v16[28] = -121;
  v16[29] = -120;
  v16[30] = -119;
  v16[31] = -118;
  v16[32] = -117;
  v16[33] = -116;
  v16[34] = -115;
  v16[35] = -114;
  v16[36] = -113;
  v16[37] = -112;
  v16[38] = -111;
  v16[39] = -110;
  v16[40] = -109;
  v16[41] = -108;
  v16[42] = -107;
  v16[43] = -106;
  v16[44] = -105;
  v16[45] = -104;
  v16[46] = -103;
  v16[47] = -102;
  v16[48] = -101;
  v16[49] = -100;
  v16[50] = -99;
  v16[51] = -98;
  v16[52] = -58;
  v16[53] = -57;
  v16[54] = -56;
  v16[55] = -55;
  v16[56] = -54;
  v16[57] = -53;
  v16[58] = -52;
  v16[59] = -51;
  v16[60] = -50;
  v16[61] = -49;
  v16[62] = -44;
  v16[63] = -48;
  for ( i = 0; i < 64; ++i )
    v16[i + 128] = ~v16[i];
  v7 = 0;
  for ( j = 0; j < 40; j += 3 )
  {
    v11 = *(_BYTE *)(a1 + j);
    if ( j + 1 >= 40 )
      v14 = 0;
    else
      v14 = *(_BYTE *)(a1 + j + 1);
    if ( j + 2 >= 40 )
      v15 = 0;
    else
      v15 = *(_BYTE *)(a1 + j + 2);
    v16[v7 + 64] = v16[(((int)v14 >> 4) & 0xF | (unsigned __int8)(16 * (v11 & 3))) + 128];
    v8 = v7 + 1;
    v16[v8 + 64] = v16[(((int)v11 >> 2) & 0x3F) + 128];
    v9 = v8 + 1;
    if ( j + 1 >= 40 )
      v16[v9 + 64] = 61;
    else
      v16[v9 + 64] = v16[(v15 & 0x3F) + 128];
    v10 = v9 + 1;
    if ( j + 2 >= 40 )
      v16[v10 + 64] = 61;
    else
      v16[v10 + 64] = v16[(((int)v15 >> 6) & 3 | (unsigned __int8)(4 * (v14 & 0xF))) + 128];
    v7 = v10 + 1;
  }
  for ( k = 0; k < v7; ++k )
    *(_BYTE *)(a2 + k) = v16[k + 64];
  v13 = 1131796;
  for ( m = 0; m < v7; ++m )
  {
    *(_BYTE *)(a2 + m) ^= v13 >> (8 * m % 24);
    *(_BYTE *)(a2 + m) = (((int)*(unsigned __int8 *)(a2 + m) >> 6) | (4 * *(_BYTE *)(a2 + m))) ^ 0xBA;
    v13 = 16843155 * v13 + 305419896;
  }
  result = a3;
  *a3 = v7;
  return result;
}

动态分析

除了静态分析寻找可能的hook操作外,还可以写一个简单的加载器来加载dll并调用其中的函数。

根据上文分析可以知道,对输入数据的检验和处理主要是在函数sub_1800024A0中,根据.text段开头的注释信息可以计算出函数偏移为0x24A0

而对于参数a1的类型,前文提及函数开头进行了检验长度,而计算长度的函数unknown_libname_11如下:

// Microsoft VisualC v7/14 64bit runtime
__int64 __fastcall unknown_libname_11(__int64 a1)
{
  return *(_QWORD *)(a1 + 16);
}

而std::string在“短字符优化(SSO)”下的结构如下:

class string {	
    union Buffer{		
        char * _pointer;		
        char _local[16];	
    };	
        
    size_t _size;	
    size_t _capacity;    
    Buffer _buffer;
};

unknown_libname_11访问a1+16计算长度的行为和std::string的结构刚好对上,因此推测a1的类型为std::string。有了函数偏移和参数类型,就可以写出加载器:

#include&lt;windows.h&gt;
#include&lt;string&gt;
#include&lt;iostream&gt;
int main(){
    HMODULE hDll = LoadLibraryA("..//_libextension.dll");
    if(!hDll){
        std::cout << "failed to load dll" << std::endl;
        return 1;
    }
    BYTE* addr = (BYTE*)hDll + 0x2A40;
    int (*check)(std::string) = (int(*)(std::string))addr;
    std::string key;
    std::cout << "input \"key\" :" << std::endl;
    std::cin>>key;
    check(key);
    return 0;
}

编译出程序后,就可以在TEA处下断点,将dll附加到加载器进程进行调试。可以发现构造的参数成功通过了长度校验,接下来F7步入TEA函数得到:

将24A0处的数据重新定义为代码得到:

可以看到TEA函数开头被修改并重定向到了0x7FFD35282700h处,跟进得到:

LPVOID __fastcall sub_7FFD35282700(__int64 a1, __int64 a2, __int64 a3)
{
  LPVOID result; // rax
  unsigned __int64 i; // [rsp+20h] [rbp-28h]
  LPCVOID lpBaseAddress; // [rsp+28h] [rbp-20h]
  HANDLE hProcess; // [rsp+30h] [rbp-18h]

  if ( !qword_7FFD353D05B8 )
  {
    result = VirtualAlloc(0LL, 0x491uLL, 0x1000u, 0x40u);
    qword_7FFD353D05B8 = (__int64 (__fastcall *)(_QWORD, _QWORD, _QWORD))result;
    if ( !result )
      return result;
    for ( i = 0LL; i < 0x491; ++i )
      *((_BYTE *)qword_7FFD353D05B8 + i) = byte_7FFD353CF491 ^ byte_7FFD353CF000[i];
    lpBaseAddress = qword_7FFD353D05B8;
    hProcess = GetCurrentProcess();
    FlushInstructionCache(hProcess, lpBaseAddress, 0x491uLL);
    Sleep(0);
  }
  return (LPVOID)qword_7FFD353D05B8(a1, a2, a3);
}

分析可知,这是一个smc函数,函数解密了byte_7FFD353CF000处的数据并将其作为函数调用,运行至return处步入,建立函数即可看到真正的加密处理

解密flag

分析真正的加密函数,发现输入的数据先经过了一个魔改的base64编码,然后被逐字符加密,而base64的魔改点在于换表和字符换位(每段编码结果的第1和2、3和4位互换了),因此可以写出最终的exp:

import base64

# 解密
def decrypt(data):
    v3 = 1131796
    for i in range(len(data)):
        data[i] ^= 0xba
        data[i] = ((data[i] << 6) | (data[i] >> 2)) & 0xff
        data[i] ^= (v3 >> (8 * i % 24)) & 0xff
        v3 = (16843155 * v3 + 305419896) & 0xffffffff
    return data

# 解码
def b64_decode(data):
    # 字符换位还原
    for i in range(len(data)//4):
        data[i*4], data[i*4 + 1] = data[i*4 + 1], data[i*4]
        data[i*4 + 2], data[i*4 + 3] = data[i*4 + 3], data[i*4 + 2]

    encrypted_table = [
        -91, -90, -89, -88, -87, -86, -85, -84, -83, -82, -81, -80,
        -79, -78, -77, -76, -75, -74, -73, -72, -71, -70, -69, -68,
        -67, -66, -123, -122, -121, -120, -119, -118, -117, -116, -115,
        -114, -113, -112, -111, -110, -109, -108, -107, -106, -105, -104,
        -103, -102, -101, -100, -99, -98, -58, -57, -56, -55, -54, -53,
        -52, -51, -50, -49, -44, -48
    ]

    # 解密base表
    modified_table = ''.join(chr((~i) & 0xff) for i in encrypted_table)

    # 构建魔改表和标准表的映射
    std_table = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
    char_map = str.maketrans(modified_table,std_table)

    # 解码
    data_modifiedb64 = bytes(data).decode('ascii')
    data_stdb64 = data_modifiedb64.translate(char_map)
    data_decrypted = base64.b64decode(data_stdb64)

    return data_decrypted

# 字符变换
def change_str(data):
    chars = data.decode('ascii')
    chars = chars.replace('o','0').replace('e','3').replace('i','1')
    return chars
  
enc = [
        0x2B, 0xF7, 0x67, 0x5E, 0x7C, 0x98, 0xED, 0x6D,
        0xD1, 0x8C, 0xEF, 0x57, 0xBB, 0x33, 0x22, 0x7E,
        0xB2, 0x1F, 0x34, 0x5B, 0x36, 0x6C, 0x2B, 0xAF,
        0xBB, 0x5B, 0x12, 0xD6, 0x3C, 0x0A, 0x45, 0x27,
        0x84, 0x6C, 0x47, 0xAB, 0x2F, 0x75, 0x78, 0x3E,
        0x88, 0x89, 0x2D, 0x7A, 0xCD, 0x5C, 0xF6, 0xFA,
        0x36, 0x73, 0xFF, 0x6E, 0xD3, 0x4C, 0x1C, 0x75
    ]
temp = decrypt(enc)
key = b64_decode(temp)
gift = change_str(key)
print(gift)
#NCTF{Y0u_d3s3rv3_th1s_g1ft_b1bd7c719cfc}

游戏修改

未经pck加密以及无GDExtension扩展的godot游戏是可以直接使用GDRetools解包修改游戏逻辑并重新打包的,这种情况可以参考godot 引擎逆向初探 | in1t's blog。但是对于本题,笔者在写WP的时候曾尝试过使用GDRetools修改游戏逻辑并重新打包,结果发现这种方法貌似行不通,于是转向使用Frida hook游戏加载的gd文件。方法如下:

分析修改gd原文件

这里主要实现锁血和无敌。在解包出来的资源里,可以在Scripts/Player.gd找到玩家扣血逻辑:

这里把amount改为0即可实现锁血

而敌人的扣血逻辑在Scripts/Enemy.gd里:

这里把amount改为一个比较大的值(如10000)即可实现无敌

此外,笔者在Scripts/Bank.gd里留了一个靠近迷宫终点的坐标:

利用这个坐标可以直接跳过迷宫探索过程,把start_point赋值为$Point即可

完成上述修改后,把修改后的文件复制到%APPDATA%\Godot\app_userdata\NoMyBank下,以便后续将修改后的文件映射到user://路径下

寻找可行的hook点

本题利用了pck加密机制,可能受此影响,按照in1t师傅博客中的方法来hook会行不通,需要寻找新的hook点。借助AI分析引擎源码,最终定位到源码modules/gdscript/gdscript.cpp中的ResourceFormatLoaderGDScript::load函数。涉及到的特征报错信息是Failed to load script "%s" with error "%s",还是利用ERR_宏展开的特性在IDA中找到对应函数:

__int64 __fastcall sub_140550170(
        __int64 a1,
        __int64 a2,
        __int64 a3,
        __int64 a4,
        _DWORD *a5,
        __int64 a6,
        __int64 a7,
        int a8)
{
  const char *v8; // r8
  const char *v9; // r9
  __int64 v10; // rax
  int v12; // [rsp+20h] [rbp-68h]
  int v13; // [rsp+34h] [rbp-54h] BYREF
  BOOL v14; // [rsp+38h] [rbp-50h]
  int v15; // [rsp+3Ch] [rbp-4Ch]
  _BYTE v16[8]; // [rsp+40h] [rbp-48h] BYREF
  _BYTE v17[8]; // [rsp+48h] [rbp-40h] BYREF
  _BYTE v18[8]; // [rsp+50h] [rbp-38h] BYREF
  _BYTE *v19; // [rsp+58h] [rbp-30h]
  __int64 v20; // [rsp+60h] [rbp-28h]
  __int64 v21; // [rsp+68h] [rbp-20h]
  _BYTE v22[8]; // [rsp+70h] [rbp-18h] BYREF
  _BYTE v23[16]; // [rsp+78h] [rbp-10h] BYREF

  v14 = !a8 || a8 == 3;
  sub_14000E2B0(v17, (const char *)&unk_14394D9FE);
  sub_140563D60((__int64)v16, a4, &v13, (__int64)v17, v14);
  sub_14000E310(v17);
  if ( v13 && (unsigned __int8)sub_14005A170(v16) )
  {
    v20 = (__int64)*(&off_144D82490 + v13);
    v19 = v23;
    v21 = sub_140020BB0(v23, a4);
    sub_14000E2B0(v18, "Failed to load script \"%s\" with error \"%s\".", v8, v9);
    v10 = sub_140055210(v22, v18, v21, v20);
    LOBYTE(v12) = 1;
    sub_142CBF6B0("ResourceFormatLoaderGDScript::load", "modules\\gdscript\\gdscript.cpp", 3041LL, v10, v12, 0);
    sub_14000E310(v22);
    sub_14000E310(v18);
  }
  if ( a5 )
  {
    if ( (unsigned __int8)sub_14005A170(v16) )
      v15 = 0;
    else
      v15 = v13;
    *a5 = v15;
  }
  sub_1400A4A20(a2, v16);
  sub_140021210(v16);
  return a2;
}

在函数开头下断点运行游戏,可以发现成功触发断点

解析参数,编写脚本

先放上load函数的引擎源码:

Ref&lt;Resource&gt; ResourceFormatLoaderGDScript::load(const String &p_path, const String &p_original_path, Error *r_error, bool p_use_sub_threads, float *r_progress, CacheMode p_cache_mode) {
    Error err;
    bool ignoring = p_cache_mode == CACHE_MODE_IGNORE || p_cache_mode == CACHE_MODE_IGNORE_DEEP;
    Ref&lt;GDScript&gt; scr = GDScriptCache::get_full_script(p_original_path, err, "", ignoring);

    if (err && scr.is_valid()) {
        // If !scr.is_valid(), the error was likely from scr->load_source_code(), which already generates an error.
        ERR_PRINT_ED(vformat(R"(Failed to load script "%s" with error "%s".)", p_original_path, error_names[err]));
    }

    if (r_error) {
        // Don't fail loading because of parsing error.
        *r_error = scr.is_valid() ? OK : err;
    }

    return scr;
}

函数反汇编代码开头的rdx对应的是p_original_path,动调分析可知,rdx存放的是一个指针,该指针指向第二层指针,而第二层指针才最终指向加载的gd文件路径,gd文件路径的内存布局如下:

debug1031:00000266090E3E48 db  1Dh ; datalength
debug1031:00000266090E3E49 db    0
debug1031:00000266090E3E4A db    0
debug1031:00000266090E3E4B db    0
debug1031:00000266090E3E4C db    0
debug1031:00000266090E3E4D db    0
debug1031:00000266090E3E4E db    0
debug1031:00000266090E3E4F db    0
debug1174:000002577BD1CF50 db  72h ; r
debug1174:000002577BD1CF51 db    0
debug1174:000002577BD1CF52 db    0
debug1174:000002577BD1CF53 db    0
debug1174:000002577BD1CF54 db  65h ; e
debug1174:000002577BD1CF55 db    0
debug1174:000002577BD1CF56 db    0
debug1174:000002577BD1CF57 db    0
debug1174:000002577BD1CF58 db  73h ; s
debug1174:000002577BD1CF59 db    0
debug1174:000002577BD1CF5A db    0
debug1174:000002577BD1CF5B db    0
debug1174:000002577BD1CF5C db  3Ah ; :
debug1174:000002577BD1CF5D db    0
debug1174:000002577BD1CF5E db    0
debug1174:000002577BD1CF5F db    0
debug1174:000002577BD1CF60 db  2Fh ; /
debug1174:000002577BD1CF61 db    0
debug1174:000002577BD1CF62 db    0
debug1174:000002577BD1CF63 db    0
debug1174:000002577BD1CF64 db  2Fh ; /
debug1174:000002577BD1CF65 db    0
debug1174:000002577BD1CF66 db    0
debug1174:000002577BD1CF67 db    0
debug1174:000002577BD1CF68 db  53h ; S
debug1174:000002577BD1CF69 db    0
debug1174:000002577BD1CF6A db    0
debug1174:000002577BD1CF6B db    0
debug1174:000002577BD1CF6C db  63h ; c
debug1174:000002577BD1CF6D db    0
debug1174:000002577BD1CF6E db    0
debug1174:000002577BD1CF6F db    0
debug1174:000002577BD1CF70 db  72h ; r
debug1174:000002577BD1CF71 db    0
debug1174:000002577BD1CF72 db    0
debug1174:000002577BD1CF73 db    0
debug1174:000002577BD1CF74 db  69h ; i
debug1174:000002577BD1CF75 db    0
debug1174:000002577BD1CF76 db    0
debug1174:000002577BD1CF77 db    0
debug1174:000002577BD1CF78 db  70h ; p
debug1174:000002577BD1CF79 db    0
debug1174:000002577BD1CF7A db    0
debug1174:000002577BD1CF7B db    0
debug1174:000002577BD1CF7C db  74h ; t
debug1174:000002577BD1CF7D db    0
debug1174:000002577BD1CF7E db    0
debug1174:000002577BD1CF7F db    0
debug1174:000002577BD1CF80 db  73h ; s
debug1174:000002577BD1CF81 db    0
debug1174:000002577BD1CF82 db    0
debug1174:000002577BD1CF83 db    0
debug1174:000002577BD1CF84 db  2Fh ; /
debug1174:000002577BD1CF85 db    0
debug1174:000002577BD1CF86 db    0
debug1174:000002577BD1CF87 db    0
debug1174:000002577BD1CF88 db  47h ; G
debug1174:000002577BD1CF89 db    0
debug1174:000002577BD1CF8A db    0
debug1174:000002577BD1CF8B db    0
debug1174:000002577BD1CF8C db  61h ; a
debug1174:000002577BD1CF8D db    0
debug1174:000002577BD1CF8E db    0
debug1174:000002577BD1CF8F db    0
debug1174:000002577BD1CF90 db  6Dh ; m
debug1174:000002577BD1CF91 db    0
debug1174:000002577BD1CF92 db    0
debug1174:000002577BD1CF93 db    0
debug1174:000002577BD1CF94 db  65h ; e
debug1174:000002577BD1CF95 db    0
debug1174:000002577BD1CF96 db    0
debug1174:000002577BD1CF97 db    0
debug1174:000002577BD1CF98 db  4Dh ; M
debug1174:000002577BD1CF99 db    0
debug1174:000002577BD1CF9A db    0
debug1174:000002577BD1CF9B db    0
debug1174:000002577BD1CF9C db  61h ; a
debug1174:000002577BD1CF9D db    0
debug1174:000002577BD1CF9E db    0
debug1174:000002577BD1CF9F db    0
debug1174:000002577BD1CFA0 db  6Eh ; n
debug1174:000002577BD1CFA1 db    0
debug1174:000002577BD1CFA2 db    0
debug1174:000002577BD1CFA3 db    0
debug1174:000002577BD1CFA4 db  61h ; a
debug1174:000002577BD1CFA5 db    0
debug1174:000002577BD1CFA6 db    0
debug1174:000002577BD1CFA7 db    0
debug1174:000002577BD1CFA8 db  67h ; g
debug1174:000002577BD1CFA9 db    0
debug1174:000002577BD1CFAA db    0
debug1174:000002577BD1CFAB db    0
debug1174:000002577BD1CFAC db  65h ; e
debug1174:000002577BD1CFAD db    0
debug1174:000002577BD1CFAE db    0
debug1174:000002577BD1CFAF db    0
debug1174:000002577BD1CFB0 db  72h ; r
debug1174:000002577BD1CFB1 db    0
debug1174:000002577BD1CFB2 db    0
debug1174:000002577BD1CFB3 db    0
debug1174:000002577BD1CFB4 db  2Eh ; .
debug1174:000002577BD1CFB5 db    0
debug1174:000002577BD1CFB6 db    0
debug1174:000002577BD1CFB7 db    0
debug1174:000002577BD1CFB8 db  67h ; g
debug1174:000002577BD1CFB9 db    0
debug1174:000002577BD1CFBA db    0
debug1174:000002577BD1CFBB db    0
debug1174:000002577BD1CFBC db  64h ; d
debug1174:000002577BD1CFBD db    0
debug1174:000002577BD1CFBE db    0
debug1174:000002577BD1CFBF db    0
debug1174:000002577BD1CFC0 db    0
debug1174:000002577BD1CFC1 db    0
debug1174:000002577BD1CFC2 db    0
debug1174:000002577BD1CFC3 db    0
debug1174:000002577BD1CFC4 db    0
debug1174:000002577BD1CFC5 db    0
debug1174:000002577BD1CFC6 db    0
debug1174:000002577BD1CFC7 db    0
debug1174:000002577BD1CFC8 db    0
debug1174:000002577BD1CFC9 db    0
debug1174:000002577BD1CFCA db    0
debug1174:000002577BD1CFCB db    0
debug1174:000002577BD1CFCC db    0
debug1174:000002577BD1CFCD db    0
debug1174:000002577BD1CFCE db    0
debug1174:000002577BD1CFCF db    0
debug1174:000002577BD1CFD0 db    0
debug1174:000002577BD1CFD1 db    0
debug1174:000002577BD1CFD2 db    0
debug1174:000002577BD1CFD3 db    0
debug1174:000002577BD1CFD4 db    0
debug1174:000002577BD1CFD5 db    0
debug1174:000002577BD1CFD6 db    0

根据收集到的这些信息就可以写个打印脚本看看了

function readGodotString(addr) {  
    var dataPtr = addr.readPointer().readPointer();// 解析两层指针  
    if (dataPtr.isNull()) return { str: "", len: 0 };  
    var totalLen = dataPtr.sub(8).readU64();        // 从dataPtr上方内存读取长度信息(含 \0)  
    var result = '';  
    for (let i = 0; i < totalLen - 1; i++) {  
        var code = dataPtr.add(i * 4).readU32();    // 每4字节读取一个字符  
        if (code === 0) break;  // 读到0截止  
        result += String.fromCodePoint(code);  
    }  
    return { str: result, len: totalLen };  // 返回路径和路径字符串长度  
}

function main() {  
    var baseAddr = Process.getModuleByName("NoMyBank.exe").base;  
  
    // 函数相对偏移由未动调时的函数名中获得  
    Interceptor.attach(baseAddr.add(0x55017A), {  
    onEnter: function(args) {  
        var scriptInfo = readGodotString(this.context.rdx);  
        console.log("Loading:", scriptInfo.str, "scriptNameLength:", scriptInfo.len);     
    }  
});  
}
  
setImmediate(main);

得到如下路径输出:

Loading: res://Scripts/GameManager.gd scriptNameLength: 29
Loading: res://Scripts/Menu.gd scriptNameLength: 22
Loading: res://Scripts/Bank.gd scriptNameLength: 22
Loading: res://Scripts/OnEnterTreasury.gd scriptNameLength: 33
Loading: res://Scripts/WinPopupTreasury.gd scriptNameLength: 34
Loading: res://Scripts/OnNearTreasury.gd scriptNameLength: 32
Loading: res://Scripts/Enemy.gd scriptNameLength: 23
Loading: res://Scripts/HealthBar.gd scriptNameLength: 27
Loading: res://Scripts/Bullet.gd scriptNameLength: 24
Loading: res://Scripts/Player.gd scriptNameLength: 24

接下来补充patch路径的函数。直接hook修改rdx,效仿readGodotString写一个patchGodotString:

function readGodotString(addr) {  
    var dataPtr = addr.readPointer().readPointer();// 解析两层指针  
    if (dataPtr.isNull()) return { str: "", len: 0 };  
    var totalLen = dataPtr.sub(8).readU64();        // 从dataPtr上方内存读取长度信息(含 \0)  
    var result = '';  
    for (let i = 0; i < totalLen - 1; i++) {  
        var code = dataPtr.add(i * 4).readU32();    // 每4字节读取一个字符  
        if (code === 0) break;  // 读到0截止  
        result += String.fromCodePoint(code);  
    }  
    return { str: result, len: totalLen };  // 返回路径和路径字符串长度  
}  
  
function patchGodotString(addr, newStr) {  
    console.log("hook to ",newStr);  
    var dataPtr = addr.readPointer().readPointer();  
    if (dataPtr.isNull()) return;  
    var totalLen = dataPtr.sub(8).readU64();        // 原始缓冲区长度  
    var newTotalLen = newStr.length + 1;    // 目标路径字符串长度
    if (newTotalLen > totalLen) {  
        console.log("New string too long.");  
        return;  
    }  
    // 写入新字符串  
    for (let i = 0; i < newStr.length; i++) {  
        dataPtr.add(i * 4).writeU32(newStr.charCodeAt(i));  
    }  
    // 写终止符  
    dataPtr.add(newStr.length * 4).writeU32(0);  
    // 更新长度字段  
    dataPtr.sub(8).writeU64(newTotalLen);  
    // 清空剩余空间(防止残留)  
    for (let i = newTotalLen; i < totalLen; i++) {  
        dataPtr.add(i * 4).writeU32(0);  
    }  
    console.log("hook successfully");  
}  
  
function main() {  
    var baseAddr = Process.getModuleByName("NoMyBank.exe").base;  
  
    // 函数相对偏移由未动调时的函数名中获得  
    Interceptor.attach(baseAddr.add(0x55017A), {  
    onEnter: function(args) {  
        var scriptInfo = readGodotString(this.context.rdx);  
        console.log("Loading:", scriptInfo.str, "scriptNameLength:", scriptInfo.len);  
        if (scriptInfo.str === "res://Scripts/Bank.gd") {  
            patchGodotString(this.context.rdx, "user://Bank.gd");  
        }  
    }  
});  
}  
  
setImmediate(main);

运行这个脚本会发现游戏卡在启动界面同时报错:

回头分析load函数源码,发现p_original_path还传给了函数GDScriptCache::get_full_script,该函数源码如下:

Ref&lt;GDScript&gt; GDScriptCache::get_full_script(const String &p_path, Error &r_error, const String &p_owner, bool p_update_from_disk) {
    MutexLock lock(singleton->mutex);

    if (!p_owner.is_empty()) {
        singleton->dependencies[p_owner].insert(p_path);
    }

    Ref&lt;GDScript&gt; script;
    r_error = OK;
    if (singleton->full_gdscript_cache.has(p_path)) {
        script = singleton->full_gdscript_cache[p_path];
        if (!p_update_from_disk) {
            return script;
        }
    }

    if (script.is_null()) {
        script = get_shallow_script(p_path, r_error);
        // Only exit early if script failed to load, otherwise let reload report errors.
        if (script.is_null()) {
            return script;
        }
    }

    const String remapped_path = ResourceLoader::path_remap(p_path);

    if (p_update_from_disk) {
        if (remapped_path.get_extension().to_lower() == "gdc") {
            Vector<uint8_t> buffer = get_binary_tokens(remapped_path);
            if (buffer.is_empty()) {
                r_error = ERR_FILE_CANT_READ;
                return script;
            }
            script->set_binary_tokens_source(buffer);
        } else {
            r_error = script->load_source_code(remapped_path);
            if (r_error) {
                return script;
            }
        }
    }

    // Allowing lifting the lock might cause a script to be reloaded multiple times,
    // which, as a last resort deadlock prevention strategy, is a good tradeoff.
    uint32_t allowance_id = WorkerThreadPool::thread_enter_unlock_allowance_zone(singleton->mutex);
    r_error = script->reload(true);
    WorkerThreadPool::thread_exit_unlock_allowance_zone(allowance_id);
    if (r_error) {
        return script;
    }

    singleton->full_gdscript_cache[p_path] = script;
    singleton->shallow_gdscript_cache.erase(p_path);

    return script;
}

分析可知,该函数默认从缓存读取GDScript对象,如果直接修改上层函数的p_original_path,此处就无法根据参数p_path找到脚本缓存,引发报错。

但是该函数提供了另一个关键的机制:如果参数p_update_from_disk为真,就会使用ResourceLoader::path_remap重映射p_path,最终从重映射的路径加载GDScript对象。因此可以尝试修改ResourceLoader::path_remap的返回值来加载修改后的gd文件。

为了实现hook修改重映射的路径,首先要让p_update_from_disk为真,然后才能修改remapped_path为自定义路径。

在load函数调用get_full_script的地方找到p_update_from_disk:

.text:00000001405501D0                 mov     [rsp+88h+var_68], al ; p_update_from_disk,栈传参
.text:00000001405501D4                 lea     r9, [rsp+88h+var_40]
.text:00000001405501D9                 lea     r8, [rsp+88h+var_54]
.text:00000001405501DE                 mov     rdx, [rsp+88h+arg_18] ; p_path
.text:00000001405501E6                 lea     rcx, [rsp+88h+var_48]
.text:00000001405501EB                 call    sub_140563D60   ; get_full_script()

hook的地址为baseAddr.add(0x5501D0),hook修改rax的值为1

在IDA中通过load函数跳转到get_full_script,对比源码找到调用ResourceLoader::path_remap的地方:

.text:0000000140563F58                 mov     rdx, [rsp+0E8h+arg_8]
.text:0000000140563F60                 lea     rcx, [rsp+0E8h+var_C0] ; remapped_path
.text:0000000140563F65                 call    sub_142EC6A50   ; path_remap()
.text:0000000140563F6A                 nop

remapped_path是栈上的数据,因此需要先hook baseAddr.add(0x563F65)得到rcx的值即remapped_path的地址,然后再hook baseAddr.add(0x563F6A)解析出remapped_path的值并修改。这里需要注意的是,remapped_path存放的是直接指向文件路径字符串的指针,因此读取字符串或者修改字符串的时候只需要解析一层指针即可。写个脚本尝试打印remapped_path:

function readGodotString(addr) {  
    var dataPtr = addr.readPointer();   // 解析1层指针  
    if (dataPtr.isNull()) return { str: "", len: 0 };  
    var totalLen = dataPtr.sub(8).readU64();        // 从dataPtr上方内存读取长度信息(含 \0)  
    var result = '';  
    for (let i = 0; i < totalLen - 1; i++) {  
        var code = dataPtr.add(i * 4).readU32();    // 每4字节读取一个字符  
        if (code === 0) break;  // 读到0截止  
        result += String.fromCodePoint(code);  
    }  
    return { str: result, len: totalLen };  // 返回路径和路径字符串长度  
}  
  
function patchGodotString(addr, newStr) {  
    console.log("hook to",newStr);  
    var dataPtr = addr.readPointer();   // 只需要解析1层指针  
    if (dataPtr.isNull()) return;  
    var totalLen = dataPtr.sub(8).readU64();        // 原始缓冲区长度  
    var newTotalLen = newStr.length + 1;    // 目标路径字符串长度  
    if (newTotalLen > totalLen) {  
        console.log("New string too long.");  
        return;  
    }  
    // 写入新字符串  
    for (let i = 0; i < newStr.length; i++) {  
        dataPtr.add(i * 4).writeU32(newStr.charCodeAt(i));  
    }  
    // 写终止符  
    dataPtr.add(newStr.length * 4).writeU32(0);  
    // 更新长度字段  
    dataPtr.sub(8).writeU64(newTotalLen);  
    // 清空剩余空间(防止残留)  
    for (let i = newTotalLen; i < totalLen; i++) {  
        dataPtr.add(i * 4).writeU32(0);  
    }  
    console.log("hook successfully");  
}

function main() {  
    var baseAddr = Process.getModuleByName("NoMyBank.exe").base;  
    var tmpPtr = null;  
  
    // hook p_update_from_disk为true  
    Interceptor.attach(baseAddr.add(0x5501D0),{  
        onEnter(args) {  
            this.context.rax = 1;  
        }  
    });  
  
    // hook remapped_path的地址
    Interceptor.attach(baseAddr.add(0x563F65), {  
        onEnter: function(args) {  
        tmpPtr = this.context.rcx;  
    }  
});  
  
    // 解析ResourceLoader::path_remap函数执行后的remapped_path
    Interceptor.attach(baseAddr.add(0x563F6A), {  
        onEnter: function(args) {  
            var pathInfo = readGodotString(tmpPtr);  
            console.log("remapped_path",pathInfo.str);  
    });  
    
}  
  
setImmediate(main);

运行结果:

remapped_path res://Scripts/GameManager.gdc
remapped_path res://Scripts/Menu.gdc
remapped_path res://Scripts/Bank.gdc
remapped_path res://Scripts/OnEnterTreasury.gdc
remapped_path res://Scripts/WinPopupTreasury.gdc
remapped_path res://Scripts/OnNearTreasury.gdc
remapped_path res://Scripts/Enemy.gdc
remapped_path res://Scripts/HealthBar.gdc
remapped_path res://Scripts/Bullet.gdc
remapped_path res://Scripts/Player.gdc

可以看到gd文件都被重映射为了gdc文件。笔者尝试不遵循这个规则,将remapped_path直接修改为user://下的gd文件,结果发现gd文件能被加载但是不能生效,没能成功改变游戏逻辑。因此这里需要将user://下的gd文件进一步编译为gdc文件后再应用修改。

GDRETools提供了编译gd文件的功能,直接使用即可,不过需要注意设置destination folder为%APPDATA%\Godot\app_userdata\NoMyBank

最后,加上patch逻辑:

function readGodotString(addr) {  
    var dataPtr = addr.readPointer();   // 解析1层指针  
    if (dataPtr.isNull()) return { str: "", len: 0 };  
    var totalLen = dataPtr.sub(8).readU64();        // 从dataPtr上方内存读取长度信息(含 \0)  
    var result = '';  
    for (let i = 0; i < totalLen - 1; i++) {  
        var code = dataPtr.add(i * 4).readU32();    // 每4字节读取一个字符  
        if (code === 0) break;  // 读到0截止  
        result += String.fromCodePoint(code);  
    }  
    return { str: result, len: totalLen };  // 返回路径和路径字符串长度  
}  
  
function patchGodotString(addr, newStr) {  
    console.log("[+]hook to",newStr);  
    var dataPtr = addr.readPointer();   // 只需要解析1层指针  
    if (dataPtr.isNull()) return;  
    var totalLen = dataPtr.sub(8).readU64();        // 原始缓冲区长度  
    var newTotalLen = newStr.length + 1;    // 目标路径字符串长度  
    if (newTotalLen > totalLen) {  
        console.log("[!]New string too long.");  
        return;  
    }  
    // 写入新字符串  
    for (let i = 0; i < newStr.length; i++) {  
        dataPtr.add(i * 4).writeU32(newStr.charCodeAt(i));  
    }  
    // 写终止符  
    dataPtr.add(newStr.length * 4).writeU32(0);  
    // 更新长度字段  
    dataPtr.sub(8).writeU64(newTotalLen);  
    // 清空剩余空间(防止残留)  
    for (let i = newTotalLen; i < totalLen; i++) {  
        dataPtr.add(i * 4).writeU32(0);  
    }  
    console.log("[+]hook successfully");  
}  
  
function main() {  
    var baseAddr = Process.getModuleByName("NoMyBank.exe").base;  
    var tmpPtr = null;  
  
    // hook p_update_from_disk为true  
    Interceptor.attach(baseAddr.add(0x5501D0),{  
        onEnter(args) {  
            this.context.rax = 1;  
        }  
    });  
  
    // hook栈上的remapped_path  
    Interceptor.attach(baseAddr.add(0x563F65), {  
        onEnter: function(args) {  
        tmpPtr = this.context.rcx;  
    }  
});  
  
    // 解析ResourceLoader::path_remap函数执行后的remapped_path并patch
    Interceptor.attach(baseAddr.add(0x563F6A), {  
        onEnter: function(args) {  
            var pathInfo = readGodotString(tmpPtr);  
            var reloadResult;  
            console.log("[+]remapped_path",pathInfo.str);  
            if (pathInfo.str === "res://Scripts/Bank.gdc") {  
                patchGodotString(tmpPtr,"user://Bank.gdc");  
                reloadResult = readGodotString(tmpPtr);  
                console.log("[+]reload script ",reloadResult.str); // 检验是否patch成功  
            }  
            if(pathInfo.str === "res://Scripts/Player.gdc") {  
                patchGodotString(tmpPtr,"user://Player.gdc");  
                reloadResult = readGodotString(tmpPtr);  
                console.log("[+]reload script",reloadResult.str);  
            }  
            if(pathInfo.str === "res://Scripts/Enemy.gdc") {  
                patchGodotString(tmpPtr,"user://Enemy.gdc");  
                reloadResult = readGodotString(tmpPtr);  
                console.log("[+]reload script",reloadResult.str);  
            }  
        }  
    });  
  
}  
  
setImmediate(main);

运行脚本,成功修改游戏

[+]remapped_path res://Scripts/GameManager.gdc
[+]remapped_path res://Scripts/Menu.gdc
[+]remapped_path res://Scripts/Bank.gdc
[+]hook to user://Bank.gdc
[+]hook successfully
[+]reload script  user://Bank.gdc
[+]remapped_path res://Scripts/OnEnterTreasury.gdc
[+]remapped_path res://Scripts/WinPopupTreasury.gdc
[+]remapped_path res://Scripts/OnNearTreasury.gdc
[+]remapped_path res://Scripts/Enemy.gdc
[+]hook to user://Enemy.gdc
[+]hook successfully
[+]reload script user://Enemy.gdc
[+]remapped_path res://Scripts/HealthBar.gdc
[+]remapped_path res://Scripts/Bullet.gdc
[+]remapped_path res://Scripts/Player.gdc
[+]hook to user://Player.gdc
[+]hook successfully
[+]reload script user://Player.gdc

传播安全知识、拓宽行业人脉——看雪讲师团队等你加入!

最后于 5小时前 被SydzI编辑 ,原因: 修复格式问题
收藏
免费 1
支持
分享
最新回复 (0)
游客
登录 | 注册 方可回帖
返回