Unity游戏菜鸡玩家的制胜之路

转自长亭知乎专栏,实习时小姐姐的约稿,已经不在那边了所以版权不归我哈

笔者一直自认玩过不少游戏,无奈水平太菜,日常送人头。痛定思痛,决定冲(xie)冠(xiu)一(gai)怒(qi),经过几次失败的尝试之后,终于搞定了几款时下热门的Unity游戏。出于各种原因,本文以一款不具名的国外游戏作为实例,分享笔者研究过程中的一些心得,与各位分享。

0X00 打包党的鶸改法

首先采用最简单的打包党策略,演示一下如何快速修改一款单机游戏的金币/宝石等资源。这部分快速带过,主要负责熟悉 Unity 游戏结构,时至今日已经不算一种技术了。

首先将游戏安装 APK 解包,这里使用 apktool 或者直接看作 zip 解包是没有区别的,因为游戏严重依赖框架,Java 层和 Manifest 等文件价值不大。作为 Unity 游戏的一个特征点,可以很明显的发现这样一个文件夹。


image

Assembly-CSharp.dll 等几个文件是 Unity 游戏最鲜明的特点。通过 file 或者 binwalk 查看可以发现它们是 C# 字节码格式( IL ),这种格式如果不进行加密,可以轻松的还原 C#指令。

接下来在手机中安装一次游戏,看一下大致的游戏逻辑,确定需要修改什么。

image

看起来右上角的宝石不错。。。

一般来说在游戏里边宝石都是稀缺资源,这里以其作为目标。为了快速定位,尝试使用 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 文件如下:

image

接下来使用 ILASM 命令 ilasm.exe name.il /DLL 可以将 IL 文件回编译成 DLL ,将其替换 APK 包中的对应 DLL ,签名,安装之后可以发现修改生效了。

image

这就是 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 结构:

image

这个结构与 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;
        }

image

途中的两个分支就是 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官网"

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 158,560评论 4 361
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,104评论 1 291
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 108,297评论 0 243
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,869评论 0 204
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,275评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,563评论 1 216
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,833评论 2 312
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,543评论 0 197
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,245评论 1 241
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,512评论 2 244
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,011评论 1 258
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,359评论 2 253
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,006评论 3 235
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,062评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,825评论 0 194
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,590评论 2 273
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,501评论 2 268

推荐阅读更多精彩内容