首页
课程
问答
CTF
社区
招聘
峰会
发现
排行榜
知识库
工具下载
看雪20年
看雪商城
证书查询
登录
注册
首页
社区
课程
招聘
发现
问答
CTF
排行榜
知识库
工具下载
峰会
看雪商城
证书查询
社区
CTF对抗
发新帖
1
2
从NCTF2026-NoMyBank!到Godot新特性下的游戏逆向分析破解
发表于: 2026-4-13 16:57
3608
从NCTF2026-NoMyBank!到Godot新特性下的游戏逆向分析破解
SydzI
1
2026-4-13 16:57
3608
# 前言 起因是笔者去年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找到这个礼物吗? >注:迷宫出口在右侧。附件解压路径请勿包含中文。 题目链接:<a href="elink@274K9s2c8@1M7s2y4Q4x3@1q4Q4x3V1k6Q4x3V1k6V1M7X3W2$3k6g2)9J5k6h3N6G2L8$3N6D9k6g2)9J5k6h3y4G2L8g2)9J5c8X3k6A6L8r3g2Q4x3V1k6V1i4K6u0r3x3g2S2D9i4K6g2X3L8h3E0v1k6V1u0a6y4U0c8F1M7W2S2C8g2$3c8w2h3s2y4B7L8e0W2K9P5s2m8z5L8r3!0a6c8s2N6Q4x3V1k6$3K9h3g2%4">NoMyBank!.zip - Google 云端硬盘</a> 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加载函数如下: ```cpp __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,在字符串窗口可以发现可疑字样  跟进可以找到如下函数 ```cpp __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: ```cpp _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会对输入的数据进行一些字符变换: ```cpp __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处的数据: ```python 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,将这段数据建立成函数,反编译得到如下: ```cpp _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如下: ```cpp // Microsoft VisualC v7/14 64bit runtime __int64 __fastcall unknown_libname_11(__int64 a1) { return *(_QWORD *)(a1 + 16); } ``` 而std::string在“短字符优化(SSO)”下的结构如下: ```cpp 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。有了函数偏移和参数类型,就可以写出加载器: ```cpp #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处,跟进得到: ```cpp 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: ```python 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解包修改游戏逻辑并重新打包的,这种情况可以参考<a href="elink@568K9s2c8@1M7s2y4Q4x3@1q4Q4x3V1k6Q4x3V1k6A6L8U0q4@1i4K6u0W2N6r3!0H3i4K6u0r3x3U0l9J5y4q4)9J5c8U0l9I4i4K6u0r3x3U0y4Q4x3V1k6Y4L8$3c8G2N6q4)9J5k6q4)9J5y4f1f1#2i4K6t1#2b7V1y4Q4x3U0f1&6y4g2)9J5y4f1f1$3i4K6t1#2z5e0y4Q4x3U0f1^5c8g2)9J5y4f1f1&6i4K6t1#2z5o6m8Q4x3U0f1^5y4W2)9J5y4f1f1#2i4K6t1#2z5e0m8Q4x3U0f1&6x3g2)9J5y4f1f1#2i4K6t1#2z5o6S2Q4x3U0f1&6c8q4)9J5y4f1f1$3i4K6t1#2z5p5g2Q4x3U0g2m8x3W2)9J5c8R3`.`.">godot 引擎逆向初探 | in1t's blog</a>。但是对于本题,笔者在写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中找到对应函数: ```cpp __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函数的引擎源码: ```cpp 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 ``` 根据收集到的这些信息就可以写个打印脚本看看了 ```javascript 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: ```javascript 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,该函数源码如下: ```cpp 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: ```javascript 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逻辑: ```javascript 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 ```
冰与火的战歌:Windows内核攻防实战高级班!从零到实战,融合AI与Windows内核攻防全技术栈,打造具备自动化能力的内核开发高手。
最后于
2026-4-13 17:02 被SydzI编辑 ,原因: 修复格式问题
#Reverse
收藏
・
1
点赞
・
2
打赏
分享
分享到微信
分享到QQ
分享到微博
赞赏记录
参与人
雪币
留言
时间
hanaraiN
期待更多优质内容的分享,论坛有你更精彩!
2026-4-14 08:24
xxrinkoss
这个讨论对我很有帮助,谢谢!
2026-4-13 20:09
查看更多
赞赏
×
1 雪花
5 雪花
10 雪花
20 雪花
50 雪花
80 雪花
100 雪花
150 雪花
200 雪花
支付方式:
微信支付
赞赏留言:
快捷留言
感谢分享~
精品文章~
原创内容~
精彩转帖~
助人为乐~
感谢分享~
最新回复
(
2
)
Imxz
雪 币:
112
活跃值:
(9435)
能力值:
( LV2,RANK:10 )
在线值:
发帖
6
回帖
754
粉丝
9
关注
私信
Imxz
2
楼
tql
2026-4-15 11:53
0
mb_czvsfrqr
雪 币:
3
活跃值:
(235)
能力值:
( LV2,RANK:10 )
在线值:
发帖
0
回帖
24
粉丝
0
关注
私信
mb_czvsfrqr
3
楼
大佬可以认识一下吗 在下有很多资源 希望可以有机会跟你合作共赢探讨一下项目
2026-8-20 11:08
0
游客
登录
|
注册
方可回帖
回帖
表情
雪币赚取及消费
高级回复
返回
SydzI
1
3
发帖
0
回帖
60
RANK
关注
私信
他的文章
[原创]KCTF2026 第二题:巳时·绿光幽语
661
从NCTF2026-NoMyBank!到Godot新特性下的游戏逆向分析破解
3608
[原创][新手向]Moectf2025 Reverse方向全解
12105
关于我们
联系我们
企业服务
看雪公众号
专注于PC、移动、智能设备安全研究及逆向工程的开发者社区
看原图
赞赏
×
雪币:
+
留言:
快捷留言
为你点赞!
返回
顶部