-
-
从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<windows.h>
#include<string>
#include<iostream>
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<Resource> 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<GDScript> 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<GDScript> 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<GDScript> 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