转自长亭知乎专栏,实习时小姐姐的约稿,已经不在那边了所以版权不归我哈
笔者一直自认玩过不少游戏,无奈水平太菜,日常送人头。痛定思痛,决定冲(xie)冠(xiu)一(gai)怒(qi),经过几次失败的尝试之后,终于搞定了几款时下热门的Unity游戏。出于各种原因,本文以一款不具名的国外游戏作为实例,分享笔者研究过程中的一些心得,与各位分享。
0X00 打包党的鶸改法
首先采用最简单的打包党策略,演示一下如何快速修改一款单机游戏的金币/宝石等资源。这部分快速带过,主要负责熟悉 Unity 游戏结构,时至今日已经不算一种技术了。
首先将游戏安装 APK 解包,这里使用 apktool 或者直接看作 zip 解包是没有区别的,因为游戏严重依赖框架,Java 层和 Manifest 等文件价值不大。作为 Unity 游戏的一个特征点,可以很明显的发现这样一个文件夹。
Assembly-CSharp.dll 等几个文件是 Unity 游戏最鲜明的特点。通过 file 或者 binwalk 查看可以发现它们是 C# 字节码格式( IL ),这种格式如果不进行加密,可以轻松的还原 C#指令。
接下来在手机中安装一次游戏,看一下大致的游戏逻辑,确定需要修改什么。
看起来右上角的宝石不错。。。
一般来说在游戏里边宝石都是稀缺资源,这里以其作为目标。为了快速定位,尝试使用 diamond ,gem 等字符串在 cs源码中进行全局搜索,很快就能定位到关键位置。
private void SetupFirebaseDefault()
{
this.Defaults.Add("EnergyStart", 20);
this.Defaults.Add("InitialEnergy", 75);
this.Defaults.Add("EnergyFillerSecs", 420);
this.Defaults.Add("InitialGold", 200);
this.Defaults.Add("InitialGems", 50);
this.Defaults.Add("XpRequirement", 75);
this.Defaults.Add("XpReqIncremental", 75);
this.Defaults.Add("XpAttackBonus", 5);
this.Defaults.Add("BubblesRequired", 6);
this.Defaults.Add("StoreBubbleCostsAddOn", 2);
this.Defaults.Add("ReviveCost", 100);
this.Defaults.Add("ShopTokenCost", 100);
this.Defaults.Add("ShopTokenCostIncremental", 75);
this.Defaults.Add("ShopTokenMax", 10);
this.Defaults.Add("ShopFigMax", 3);
this.Defaults.Add("ShopKeyMax", 5);
this.Defaults.Add("EnergyPricesInGemsBig", 200);
this.Defaults.Add("EnergyPricesInGemsSmall", 50);
this.Defaults.Add("EnergyPackageBig", 60);
this.Defaults.Add("EnergyPackageSmallMin", 5);
this.Defaults.Add("RepeatedFigurineTokenConversion", 7);
this.Defaults.Add("RefreshLootCost", 20);
...
}
InitialGems 字段明显是初始宝石数的意思,OK就它了。C# 字节码的修改有很多种方式,比较方便的工具是 Reflector ,这里因为没有这个工具,使用 ILDASM 反编译,随后修改, ILASM 重编译回去的方法。这两个工具都是微软官方提供的,当然可以百度搜到。 ILDASM 具有图形化界面,直接从其中 dump 出来即可,随后修改 dump 出的 IL 文件如下:
接下来使用 ILASM 命令 ilasm.exe name.il /DLL 可以将 IL 文件回编译成 DLL ,将其替换 APK 包中的对应 DLL ,签名,安装之后可以发现修改生效了。
这就是 Unity 游戏打包党快速修改的过程,看起来很简单,但是却存在不少问题:
- 这个游戏没有做任何保护,一旦存在保护,比如IL字节码加密,就需要去跟IL字节码加载的逻辑,伺机恢复明文IL字节码。
- 加壳问题,好在加壳是针对Java层的,考查了国内几款主流游戏之后发现基本没有加壳,因为壳并不能保证ELF文件的安全,ELF文件很可能使用其他安全策略。
- 重打包问题,国内游戏是不可能让你修改数据重打包的,特别是联网游戏,会有多处完整性校验,因此修改工作必须在运行过程中进行。
综合以上,我们虽然完成了对一款毫无安全保护的 Unity 游戏的修改,但是为了进一步研究适用于更复杂条件下的修改策略,还需要进一步研究心得方案。
0X01 注入与hook
考虑到国内主流游戏的安全机制,必须使用运行时修改的方式。比较理想的方式是先注入 zygote 进程。zygote 进程是 Dalvik 虚拟机的孵化器进程。众所周知,常规的 Android APP 是运行在 Dalvik 虚拟机(或者其继承者 ART )中的,虚拟机需要加载很多运行所需的库(如 libdvm.so),并且初始化虚拟机对象。这个过程费时费力,为了保证应用的启动速度,zygote被设计为虚拟机进程的父进程。当应用启动时,直接从 zygote 上 fork() 出来,继承其虚拟内存空间。因此,注入到 zygote 进程的好处是先于应用代码执行,可以有效避免注入过程被应用的 anti-ptrace 机制检测到。
Android 平台上的注入已经是相对成熟的一套代码,最初是由看雪版主古河大大发布,随后出现了很多的更新、优化版本。其基本思路是利用 Linux 平台上的跨进程控制机制 ptrace ,通过对 ptrace 的封装实现目标进程的读、写,寄存器获取、保存、恢复,页状态变更、写入一段施工程序、远程调用施工程序,负责将待注入模块加载到目标内存中。这些内容前人之述备矣,这里贴几个相关链接,不在做具体展开。
[原创]发个Android平台上的注入代码-『Android安全』-看雪安全论坛 libinject
android hook 框架 libinject2 简介、编译、运行 libinject2
Android进程的so注入--Poison(稳定注入版) - 水汐。2014 的专栏 - CSDN博客 Poison注入框架
在完成注入之后,我们的功能代码即可在目标进程中执行,接下来需要在目标进程中执行 hook 过程。通过 hook 技术,可以截断一个函数的执行流程并插入自定义的代码。针对 zygote 注入,这个问题稍微复杂。因为在我们注入 zygote 的时机,游戏进程还没有启动,因此无法直接 hook 到目标函数。后面将会介绍到,我们的目标函数是 native 层的 c 函数。因为 zygote 进程最后会 fork 成游戏进程,为了感知游戏进程中目标函数的加载,可以监控该函数所在的库的加载,那么就需要用到 linker 中的 dlopen 函数。
void *dlopen(const char *filename, int flags);
通过 hook dlopen 函数并检查 filename 参数确定目标库的加载,随后再一次进行实际的功能性 hook ,hook 目标函数达到修改目的。也就是说,通过 zygote hook 的方式 hook 一个目标函数,需要进行两次 hook ,第一次是 hook linker 中的 dlopen 以确定目标模块的基址,第二次是在该模块中 hook 目标函数。这里有一个小问题是由于 dlopen 函数在每一个 zygote 的子进程中都会被 hook ,导致系统性能下降,一个解决方案是定期查看** /proc/pid/cmdline** 如果自身不是目标进程那么就解除 hook 。
针对 Unity 游戏的 hook 思路大体如上,本节的最后再讲讲关于使用的 hook 框架。Hook 操作的原理可以理解为强行修改程序的代码段,通过修改目标地址上的字节码为 B ,JMP 等指令将指令流跳转到 hook 者控制的位置执行另一段指令。当然实际实现中复杂性远远大于这句描述,因为指令执行完毕之后通常需要返回到 hook 前的位置,如何保证 hook 点处指令、寄存器值等各种信息完好,是需要很大工作量的。Java 层的 hook 框架可以使用 XScript 、frida 、cydia substrate 等等,native 层笔者尝试过的有效工具有 cydia substrate和android-inline-hook ( ele7enxxh/Android-Inline-Hook )。ARM 平台上的 hook 工具开发有几个坑点,一个原因是由于 ARM 有大量位置相关代码,如果 hook 点在这种指令上,那么想要在异地恢复这条指令相当困难;另一个原因是 ARM 上存在 Thumb 指令集,需要考虑判断当前指令集并执行不同的操作。
有了注入和 hook 两种工具,就可以完成对目标函数的运行时修改。下一节探讨针对 Unity 游戏,具体修改哪些函数可以完成对游戏逻辑的控制。
0X02 Mono加载C#字节码过程分析
可能很多人都像我一样好奇过,Android 是一个类 Java 虚拟机部署在 Linux 平台上,怎么就跑起来了微软的 C# ?其实 C# 已经被 ECMA 组织标准化(虽然这组织和微软渊源颇深),并且标准基础上出现了一套运行时( Common Language Runtime , CLR )。这套运行时的具体实现是一个叫做 mono 的开源项目。
本节介绍 mono 执行 C# 字节码的过程。Android 上的 Unity 正是通过 mono 的 Just-in-time Compile 机制完成了从 C# 语言世界到 ARM 机器码世界的转化。接下来对 Mono 项目的源码中对 DLL 处理的逻辑做一个分析。
首先,mono 加载 DLL 文件之后,会进行预编译,首先调用 /mono/mini/mini.c 中的 mono_precompile_assemblies 函数,该函数对所有需要加载的 assembly 文件逐个调用 mono_precompile_assembly 。
void mono_precompile_assemblies ()
{
GHashTable *assemblies = g_hash_table_new (NULL, NULL);
mono_assembly_foreach ((GFunc)mono_precompile_assembly, assemblies);
g_hash_table_destroy (assemblies);
}
static void
mono_precompile_assembly (MonoAssembly *ass, void *user_data)
{
...
for (i = 0; i < mono_image_get_table_rows (image, MONO_TABLE_METHOD); ++i) {
method = mono_get_method (image, MONO_TOKEN_METHOD_DEF | (i + 1), NULL);
mono_compile_method (method);
if (strcmp (method->name, "Finalize") == 0) {
invoke = mono_marshal_get_runtime_invoke (method, FALSE);
mono_compile_method (invoke);
}
...
}
这里摘取了 mono_precompile_assembly 函数的关键步骤。该函数中针对当前需要处理的的 assembly ,对其中每一个函数调用 mono_compile_method 进行编译,同时编译 invoke 。这个 invoke 是对应函数的一个包装器,当 mono最终调用函数时,会通过包装器调用而不是直接调用。因此在函数 compile 完成之后,会生成并编译 invoke 函数。
接下来分析的关键是 mono_compile_method 函数,真正的编译过程发生在这个函数中。该函数不是唯一的,因为 mono 同时支持 AOT( ahead of time )编译,未来也可能添加其他功能。因此这个函数这里为一个函数指针,在 JIT 编译环境下执行的是 mono_jit_compile_method 函数。
gpointer
mono_jit_compile_method (MonoMethod *method)
{
MonoException *ex = NULL;
gpointer code;
code = mono_jit_compile_method_with_opt (method, mono_get_optimizations_for_method (method, default_opt), &ex);
if (!code) {
g_assert (ex);
mono_raise_exception (ex);
}
return code;
}
这个函数调用了 mono_jit_compile_method_with_opt 函数做具体操作,注意这里返回的是 gpointer 指针,其实这个指针指向的就是 DLL 脚本最终编译成汇编所在的地址,后续如果我们需要修改生成的汇编代码,修改这个指针即可。接下来我们稍微深入跟进一些。
static gpointer
mono_jit_compile_method_with_opt (MonoMethod *method, guint32 opt, MonoException **ex)
{
...
target_domain = mono_get_root_domain ();
info = lookup_method (target_domain, method); //先查表判断是否已经编译
if (info) {
/* We can't use a domain specific method in another domain */
if (! ((domain != target_domain) && !info->domain_neutral)) {
MonoVTable *vtable;
MonoException *tmpEx;
mono_jit_stats.methods_lookups++;
vtable = mono_class_vtable (domain, method->klass);
g_assert (vtable);
tmpEx = mono_runtime_class_init_full (vtable, ex == NULL);
if (tmpEx) {
*ex = tmpEx;
return NULL;
}
return mono_create_ftnptr (target_domain, info->code_start);
}
}
code = mono_jit_compile_method_inner (method, target_domain, opt, ex);//实际编译点
···
p = mono_create_ftnptr (target_domain, code);
···
return p;
}
这里隐去了编译 invoke 函数的代码和一些细枝末节的 check 。可以看到, mono_jit_compile_method_with_opt 函数的主要流程是首先查表看当前要编译的函数是否已经编译,如果已经编译,则直接返回编译好的结果;否则,调用 mono_jit_compile_method_inner 函数实际编译并注册到 target_domain 中,随后通过 mono_create_ftnptr 函数获取函数指针。因为这部分代码是复用的,除了首次加载 DLL 之外的一些情景也会调用该函数,其中存在一些函数已经编译的情况。 mono_jit_compile_method_inner 函数以下是一些与机器相关的具体机器码生成过程,对虚拟机感兴趣的朋友可以进一步学习,这里就不继续深究了,简单把整个调用过程整理一下:
graph TD;
start-->mono_precompile_assemblies
mono_precompile_assemblies-->|foreach|mono_precompile_assembly
mono_precompile_assembly-->|函数体|mono_jit_compile_method
mono_precompile_assembly-->|invoke|mono_marshal_get_runtime_invoke
mono_marshal_get_runtime_invoke-->mono_jit_compile_method
mono_jit_compile_method-->mono_jit_compile_method_with_opt
mono_jit_compile_method_with_opt-->|已经编译过|mono_create_ftnptr
mono_jit_compile_method_with_opt-->|没有编译过|mono_jit_compile_method_inner
mono_jit_compile_method_inner-->mini_method_compile
mini_method_compile-->mono_codegen
mono_codegen-->mono_create_ftnptr
mono_create_ftnptr-->finish
至此我们的分析完成了,尽管虚拟机可以使用花样繁多的语言开发,但是最终在执行前一需要恢复成本地机器码去执行。这就给了我们下 hook 的机会,下一节介绍通过修改 mono 编译出来的汇编函数逻辑,完成对游戏流程的动态修改。
0X03 通过修改虚拟机生成的汇编指令修改游戏逻辑
接下来我们尝试利用前面两节介绍的知识,修改游戏的执行逻辑.
private void MainButtonClicked()
{
...
case UI_ConfirmationPopup.ScreenType.BuyCoins:
{
int num = Tuning.ShopCoinPackagesPrices[this.coinIndex];
int num2 = Tuning.ShopCoinPackages[this.coinIndex];
if (UserProfile.Gems >= num)
{
UserProfile.Gold += num2;
UserProfile.Gems -= num;
this.purchasedAmount = num2;
this.screenType = UI_ConfirmationPopup.ScreenType.CoinsPurchased;
Events.Instance.UI_MARKET_PURCHASED();
GeneralManager.Analytics.ReportGoldPurchased(this.coinIndex, num);
this.CallItQuits();
}
else
{
this.DisableAssets(true);
this.LaunchOutOfGems();
}
break;
}
...
}
我们选择 MainButtonClicked 这个函数作为目标,其中的购买金币分支会检测当前钻石数量,如果数量够则进行购买,否则不进行购买。在 mono_jit_compile_method_with_opt 函数上下钩子,检查第一个参数 method 的 name 字段是否包含“ MainButtonClicked ”,在包含这个字段时,将 gpointer 指向的函数 dump 出来。
if(!strstr(name, "MainButtonClicked")) return target(arg1, arg2, arg3);
LOGE("find MainButtonClicked");
void* funcptr = target(arg1, arg2, arg3);
LOGE("function MainButtonClicked base is: %0lx", funcptr);
int fd = open("/data/local/tmp/dump", O_WRONLY | O_CREAT);
if(fd == -1){
LOGE("open error: %s", strerror(errno));
exit(-1);
}
if(write(fd, funcptr, 0x1000 * 0x1000) == -1){
LOGE("write error: %s", strerror(errno));
exit(-1);
}
如上述代码所示,这次 hook 在 mono_jit_compile_method_with_opt 函数每次编译 C# 函数点进行判断,当被编译的函数是我们的目标 MainButtonClicked 时,对内存进行 dump ,将编译成机器码的 MainButtonClicked 输出出来,接下来,使用 IDA 对该函数进行分析。
在加载该函数时需要注意,由于 dump 出来的是部分内存,不像标准的 elf 文件一样有各种配置能够加载,识别为 binary file ,需要手动指定处理器架构,这里是 ARM 。另外需要指定硬盘文件偏移和程序在内存中偏移的映射关系,注意上边代码中第四行输出了程序在内存中的地址,IDA 能够利用 file_offset+memory_base 计算出相当一部分的跳转指令的跳转地址(当然,由于我们只 dump 了很小一部分内存,仍然有很多依赖相对偏移寻址的跳转目标无法恢复,但对程序结构的分析无太大影响)。
通过 IDA 加载后,可以看出函数明显是一个 switch-case 结构:
这个结构与 MainButtonClicked 函数原始形式一致,通过分析二者关系可以定位到金币购买时点击确定按键对应到的 case :
case UI_ConfirmationPopup.ScreenType.BuyCoins:
{
int num = Tuning.ShopCoinPackagesPrices[this.coinIndex];
int num2 = Tuning.ShopCoinPackages[this.coinIndex];
if (UserProfile.Gems >= num)
{
UserProfile.Gold += num2;
UserProfile.Gems -= num;
this.purchasedAmount = num2;
this.screenType = UI_ConfirmationPopup.ScreenType.CoinsPurchased;
Events.Instance.UI_MARKET_PURCHASED();
GeneralManager.Analytics.ReportGoldPurchased(this.coinIndex, num);
this.CallItQuits();
}
else
{
this.DisableAssets(true);
this.LaunchOutOfGems();
}
break;
}
途中的两个分支就是 C# 中的 if-else 。由于高级语言数据结构比较复杂,反应在机器码层面取数据涉及问题较多,因此修改取数据比较困难。但是可以看到,上面的 block 中 R5 是最后取出的当前剩余宝石,当与其进行比较之后,如果宝石充足,则会跳转到红色分支开始购买,增加金币扣除宝石。因此应当修改的逻辑是图中 1 处,通过 nop(mov r0, r0)掉跳转强制执行购买流程。为了在修改金币的同时不减少宝石,将2处宝石运算改为 add r0 , r0 , r5 。
确定了修改点之后将上述两条命令汇编,使用之前的 hook 稍作修改,当执行到 MainButtonClicked 编译时修改程序机器码( mono 已经很贴心的 mprotect 过了),完成对游戏的修改,接下来尝试购买宝石,哇,奇迹发生了。
0X04 后记
本文从一个简单小游戏的破解出发,介绍了 Unity3D 引擎使用 mono 进行 C# JIT 编译的思路,并设计了实验性质的 hook 方案。其实针对这款简单的小游戏,更简单的破解方式还有很多种,牛刀杀鸡是为了以后更容易杀牛。因为在实际的环境中,分析游戏面临着过反调试、脱壳、对抗去符号、DLL 解密等多重挑战。限于篇幅不可能对这些技术一一介绍,感兴趣的朋友可以自行百度/谷歌。另外,由于使用了 AOT 机制,文章中介绍的 hook 思路可能会更适用于 iOS ,条件所限没有尝试。攻击是为了更好的防御,使用的实例,介绍的工具都为了更好的说明技术本身,请不要用违法的目的。
参考文献
[1]: Mono为何能跨平台?聊聊CIL(MSIL) - 慕容小匹夫 - 博客园 "Mono为何能跨平台?聊聊CIL(MSIL)"
[2]: Welcome to Ecma International "EMCA官网"