从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进行了什么处理。
尝试解包
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加载函数如下:
1 | |
可以看到该函数前半部分出现了“rb”、“wb”字样,且中间是类似RC4的算法,密钥为“G00dLuck2U“。此处出现“wb”,说明游戏在加载dll的时候会先将其解密到某个路径再加载,在此处下断点查看Buffer:
由此可知解密后的dll会被放置在系统的临时目录下。让游戏继续运行,就可以在系统临时目录下找到解密后的_libextension.dll。但是如果此时终止调试,会发现_libextension.dll从系统临时目录消失了,因此推测程序为了避免解密后的dll被发现,还做了用完即删的处理,但这不妨碍我们得到解密后的dll,复制一份即可。
用010editor打开解密后的dll,发现PE文件格式特征出现了,解密成功。
分析libextension.dll
IDA打开解密后的dll,在字符串窗口可以发现可疑字样
跟进可以找到如下函数
1 | |
其中Right和Wrong的情况由变量a2决定,而a2是作为该函数的第二个参数传入的,因此回溯到上级函数:
可以看到该参数是前一个函数sub_180002A40的返回值,跟进sub_180002A40:
函数开头对参数a1进行了疑似长度检验的操作,推测a1即为要求输入的数据(即提示中说的“key”)。随后的sub_180006790是堆栈检查函数,然后输入的数据被复制到v4中,作为另一个函数sub_1800024A0的参数,跟进sub_1800024A0:
1 | |
这里是一个魔改了delta的TEA加密。回到上级函数继续分析,函数的最后使用memcpy比较了Buf1和预设的数据值,若两个数据相同,随后的sub_180002950会对输入的数据进行一些字符变换:
1 | |
这样分析下来,输入数据的检验逻辑应该只经过了一层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处的数据:
1 | |
解密后选中byte_180165000,将这段数据建立成函数,反编译得到如下:
1 | |
动态分析
除了静态分析寻找可能的hook操作外,还可以写一个简单的加载器来加载dll并调用其中的函数。
根据上文分析可以知道,对输入数据的检验和处理主要是在函数sub_1800024A0中,根据.text段开头的注释信息可以计算出函数偏移为0x24A0
而对于参数a1的类型,前文提及函数开头进行了检验长度,而计算长度的函数unknown_libname_11如下:
1 | |
而std::string在“短字符优化(SSO)”下的结构如下:
1 | |
unknown_libname_11访问a1+16计算长度的行为和std::string的结构刚好对上,因此推测a1的类型为std::string。有了函数偏移和参数类型,就可以写出加载器:
1 | |
编译出程序后,就可以在TEA处下断点,将dll附加到加载器进程进行调试。可以发现构造的参数成功通过了长度校验,接下来F7步入TEA函数得到:
将24A0处的数据重新定义为代码得到:
可以看到TEA函数开头被修改并重定向到了0x7FFD35282700h处,跟进得到:
1 | |
分析可知,这是一个smc函数,函数解密了byte_7FFD353CF000处的数据并将其作为函数调用,运行至return处步入,建立函数即可看到真正的加密处理
解密flag
分析真正的加密函数,发现输入的数据先经过了一个魔改的base64编码,然后被逐字符加密,而base64的魔改点在于换表和字符换位(每段编码结果的第1和2、3和4位互换了),因此可以写出最终的exp:
1 | |
游戏修改
未经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 | |
在函数开头下断点运行游戏,可以发现成功触发断点
解析参数,编写脚本
先放上load函数的引擎源码:
1 | |
函数反汇编代码开头的rdx对应的是p_original_path,动调分析可知,rdx存放的是一个指针,该指针指向第二层指针,而第二层指针才最终指向加载的gd文件路径,gd文件路径的内存布局如下:
1 | |
根据收集到的这些信息就可以写个打印脚本看看了
1 | |
得到如下路径输出:
1 | |
接下来补充patch路径的函数。直接hook修改rdx,效仿readGodotString写一个patchGodotString:
1 | |
运行这个脚本会发现游戏卡在启动界面同时报错:
遇到和in1t师傅一样的问题了-O-
回头分析load函数源码,发现p_original_path还传给了函数GDScriptCache::get_full_script,该函数源码如下:
1 | |
分析可知,该函数默认从缓存读取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 | |
hook的地址为baseAddr.add(0x5501D0),hook修改rax的值为1
在IDA中通过load函数跳转到get_full_script,对比源码找到调用ResourceLoader::path_remap的地方:
1 | |
remapped_path是栈上的数据,因此需要先hook baseAddr.add(0x563F65)得到rcx的值即remapped_path的地址,然后再hook baseAddr.add(0x563F6A)解析出remapped_path的值并修改。这里需要注意的是,remapped_path存放的是直接指向文件路径字符串的指针,因此读取字符串或者修改字符串的时候只需要解析一层指针即可。写个脚本尝试打印remapped_path:
1 | |
运行结果:
1 | |
可以看到gd文件都被重映射为了gdc文件。笔者尝试不遵循这个规则,将remapped_path直接修改为user://下的gd文件,结果发现gd文件能被加载但是不能生效,没能成功改变游戏逻辑。因此这里需要将user://下的gd文件进一步编译为gdc文件后再应用修改。
GDRETools提供了编译gd文件的功能,直接使用即可,不过需要注意设置destination folder为%APPDATA%\Godot\app_userdata\NoMyBank
最后,加上patch逻辑:
1 | |
运行脚本,成功修改游戏
1 | |
从NCTF2026-NoMyBank!到Godot新特性下的游戏逆向分析破解
https://sydzi.github.io/2026/04/05/从NCTF2026-NoMyBank!到Godot新特性下的游戏逆向分析破解/