逆向 02 跳过结算动画

发布于 5 小时前  10 次阅读


  本篇文章将从Unity UI 劫持的角度来讨论如何跳过某些游戏动画。我们从最外层的网络抓包,一路寻找到内部的动画调用。穿透业务逻辑、xLua桥接层、多线程限制、汇编指令,最终直达Unity引擎的C++底层。

一、基础问题

  首先我们通过Frida-Tools完成了一个基本的Gizp->Json->AuthKey->Session中的json解包功能。并提取出了如下的

// ...
    "mission_win_result": {
        // ...
        "user_exp": "88",
        "reward_gun": [
            {
                "gun_with_user_id": "530081393",
                "gun_id": "107"
            }
        ],
        // ...
    },
// ...

其中,我们只需要关注reward_gun,即我们获得的T-Dolls。这里我们需要引入的问题是:我们能否跳过T-Dolls的结算动画?

二、移除字段

  一种最简单的尝试是我们直接移除reward_gun这一个字段。那么然后将Json压缩回Gzip,将其传递给游戏。

  • 优点:从上述的字段中我们不难发现,游戏本地没有保存任何有关T-Dolls的数据,全部存放在服务器的数据库中。因此,通过移除字段的方式,我们实现了:
    1. 游戏本地没有任何T-Doll的数据;
    2. 在服务器端我们成功结算了任务,获取到了T-Doll的奖励。
  • 缺点:在我们编写的MaaPipeline中,包含了T-Doll拆解的流程。但是当我们采取上述方式来跳过结算动画时,我们发现:
    1. 本地的仓库永远不会增加(这是在意料之中的);
    2. 服务器端会在部署任务过程中进行二次校验,即——正常情况下服务器会提示本地客户端执行拆解MessageBox;但是我们得到的结果确是踢下线MessageBox

服务器通过重新登陆的方式,来强制同步远程与本地的数据。虽然我们实现了跳过结算动画,优化了部分的时间,但是频繁上下线所带来的额外开销不是我们期望的。

三、从Unity角度思考

3.1 The Controller Hunt

  最初,我们试图寻找结算界面,通过script.json找到了BattleResultController。但是我们这个游戏它的UI是队列化的、分散式的,例如:上述json中的user_expreward_gun不在同一个控制器中。

  接着,由于在script.json的上百万行代码中找一个函数是不现实的,我们转而使用Taint Analysis的思路。因为我们要找的是“展示获得T-Dolls(Gun)”的界面,那么这个界面的初始化函数必定会接收“Gun”作为参数。我们通过Python脚本遍历script.json,寻找参数签名中包含GF_Battle_Gun_o*,且类名包含Controller/UI的方法。

  最终,我们找到了CommonGetNewGunController

3.2 The Execution Pitfalls

  找到目标后,我们试图在Frida中延迟调用它的Close()方法,引发了连环事故:

  1. 0x10空指针崩溃:
    • 原因:Unity是严格的单线程渲染引擎。Frida的setTimeout运行在独立的JS线程中,试图从外部线程调用Unity原生UI函数,无法获取TLS(线程局部存储),直接崩溃。
    • 修改:改用Hook引擎每一帧都会调用的Update方法,在主线程中完成修改。
  2. Frida注入失败unable to intercept function
    • 原因:我们试图Hook返回布尔值的判定函数CanShowUI。但在x64汇编中,这种函数只有极短的3字节xor eax,eax; ret,而Frida的跳板跳转指令(Trampoline)需要至少5-14字节,强行注入会破坏内存。
    • 修改:放弃高级API,使用Memory.protect解除写保护,直接将机器码覆写进内存(Direct Memory Patching)。

3.3 The Placeholder

  当我们利用内存补丁阻断了UI,并尝试伪造信号跳过时,屏幕上出现了经典的“M4 SOPMODII Nodata”占位符。如下图所示:

图1
占位符图片

这是因为,该UI预制体实际上是由底层的xLua直接Instantiate生成的,然后才把控制权交回给C#(调用InitGunInfo)。因为我们拦截了InitGunInfo且伪造了完成信号,UI内部状态机未初始化完成。此时调用游戏原生的Close()会被其内部的安全检查拒绝(没有初始化,不能关闭),导致占位符出现在屏幕上。

  既然我们的游戏没有完成上述的GC工作,那么我们则直接尝试调用Unity的引擎来完成资源回收。

  1. 我们在script.json中搜索了Unity的API——UnityEngine.Component$$get_gameObjectUnityEngine.Object$$Destroy
  2. 当Lua把Nodata空壳塞进屏幕,并调用InitGunInfo时,我们瞬间拦截。
  3. 解析IL2CPP Delegate的底层结构(偏移量0x18是函数体,0x20是目标),直接提取出Lua传过来的Action回调指针并Invoke触发。Lua以为玩家看完了动画,于是继续执行下一步。
  4. 我们拿着被拦截的__this(组件指针),调用引擎的get_gameObject获取物理实体,立刻SetActive(0)防止闪烁,最后塞给引擎的Destroy()

最终,我们的结算动画在出现的第0帧即被"kill"。

四、总结

  script.json不仅仅是一本“字典”,结合Python编写简单的启发式脚本(基于参数类型、基于类名后缀),可以让在几百万行代码中瞬间捞出目标,这比传统的IDA静态分析快无数倍。

  在Unity C#中,回调函数极多。掌握从内存偏移量(0x180x20)提取指针并使用NativeFunction强行执行的技术,是Control Flow Hijacking的常用手段。

  在处理UI跳过时,最好的办法不是阻止它生成(容易卡死队列),而是让它生成,但在它被GPU渲染出第一帧之前,拿到GameObjectDestroy它。

  当游戏(业务)的Close/Hide函数不生效时,永远记得去找UnityEngine.Object$$DestroySetActive