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

第一次出题就遇上agent战争了😭😭😭

前言

起因是笔者去年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时对dll进行了什么处理。

尝试解包

godot游戏实质上是由引擎和资源文件组合成的(即DIE提示的“打包”),解包游戏可以获得游戏开发时使用的代码和素材等资源文件,所以第一步可以先尝试解包游戏。用GDRETools解包exe会发现解包失败,提示需要设置密钥:
GDRETools提示
查找资料会发现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”输入密钥即可解包游戏
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加载函数如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
__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:
Buffer
由此可知解密后的dll会被放置在系统的临时目录下。让游戏继续运行,就可以在系统临时目录下找到解密后的_libextension.dll。但是如果此时终止调试,会发现_libextension.dll从系统临时目录消失了,因此推测程序为了避免解密后的dll被发现,还做了用完即删的处理,但这不妨碍我们得到解密后的dll,复制一份即可。
用010editor打开解密后的dll,发现PE文件格式特征出现了,解密成功。
PE文件格式恢复,解密成功

分析libextension.dll

IDA打开解密后的dll,在字符串窗口可以发现可疑字样
可疑字样
跟进可以找到如下函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
__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:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
_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会对输入的数据进行一些字符变换:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
__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
unk_180150598
而这个数据最终流向了v20,作为“gift”。所以推测提示中的gift是由key变换而来的。
但是,尝试使用TEA解密预设数据会发现根本得不到有意义的明文,因此,程序中可能有隐藏的逻辑。

寻找隐藏的逻辑

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

静态分析

遇到这种莫名其妙的情况,先考虑有没有hook在背地里修改程序。
在IDA的Exports窗口可以看到有两个TlsCallback,Function name窗口也可以搜索到
TlsCallback
跟进TlsCallback_1就可以看到如下内容
TlsCallback_1
此处反编译可能有些问题,但是从汇编代码可以直观看出TlsCallback_1先获取了dll基址,随后加上0x24A0计算出TEA的实际地址(和函数名sub_1800024A0对上了),TEA的地址传给lpAddress作为后续memcpy的参数。也就是说这个回调函数hook了TEA,并将其修改成了Src,而这里的Src构成了一个mov rax,[目标函数地址] jump rax的重定向。跟进sub_180002700可以发现这是一个smc函数
sub_180002700
综上,程序执行TlsCallback_1时会将TEA重定向到这个smc函数,随后smc函数从byte_18014F000获取数据进行解密,解密结果qword_1801505B8最终被当作一个函数执行。因此,TEA并非真正的加密函数,真正的加密函数要通过smc得到。写一个IDAPython脚本解密byte_18014F000处的数据:

1
2
3
4
5
6
7
8
9
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,将这段数据建立成函数,反编译得到如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
_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如下:

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

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

1
2
3
4
5
6
7
8
9
10
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。有了函数偏移和参数类型,就可以写出加载器:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#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函数得到:
步入TEA函数
将24A0处的数据重新定义为代码得到:
重新定义数据
可以看到TEA函数开头被修改并重定向到了0x7FFD35282700h处,跟进得到:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
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:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
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中找到对应函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
__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函数的引擎源码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
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文件路径的内存布局如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
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);

得到如下路径输出:

1
2
3
4
5
6
7
8
9
10
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:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
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);

运行这个脚本会发现游戏卡在启动界面同时报错:
报错
遇到和in1t师傅一样的问题了-O-
回头分析load函数源码,发现p_original_path还传给了函数GDScriptCache::get_full_script,该函数源码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
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:

1
2
3
4
5
6
.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的地方:

1
2
3
4
.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:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
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);

运行结果:

1
2
3
4
5
6
7
8
9
10
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
GDRETools编译gd文件的功能
最后,加上patch逻辑:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
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);

运行脚本,成功修改游戏
游戏启动截图

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
[+]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
作者

SydzI

发布于

2026-04-05

更新于

2026-04-14

许可协议

评论