前言

本文仅供学习探讨之用,如果侵犯了您的权益请联系我删除。

工具

  1. Frida
  2. Unity

需求分析

来假设一种情况,虽然你已经成功得到了游戏的dump.cs,但是由于这个游戏使用了编译期符号混淆,所以你得到的东西可参考性非常的差,甚至不知道从哪里入手。

那么这个时候我们其实可以先使用内存搜索工具在游戏中找出关键的数据,然后根据IL2Cpp的结构,反向推理出该数据所在的是哪个类里和对应的字段名。

得到了类与字段名、命名空间、偏移这些信息之后我们再回到对应的dump.cs中就有了一个关键的入口点,很多时候可以节省效率和作为一种对抗混淆的手段。

创建并生成测试用游戏

为了易于理解和方便分析,我们这里先用Unity创建一个专门用来测试的游戏。

创建并配置Unity项目

  1. 打开Unity并创建一个新项目。
  2. 在GameObject菜单中,点击并选择UI > Text - TextMeshPro来创建一个文本标签,后面用来显示目标关键数据变量的指针,免去使用内存查找工具搜索的过程。
  3. 重复上述步骤添加一个文本标签,后面用来显示我们的关键数据(一个整型数值,从0开始自增)。
  4. 在GameObject菜单中,点击并选择UI > Legacy > Button来创建一个用来触发关键数据自增的按钮。
  5. 在GameObject菜单中,点击并选择Create Empty来创建一个GameObject,然后在GameObject中使用Add Component添加一个新的脚本,命名为TestScript

TestScript文件中添加以下代码:

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;
using TMPro;

public class TestScript : MonoBehaviour
{
    public TextMeshProUGUI label;
    public TextMeshProUGUI pointer;
    private int number = 0;

    // Start is called before the first frame update
    void Start()
    {
        unsafe
        {
            fixed(int* numberAddress = &number)
            { 
                pointer.text = ((long)numberAddress).ToString("X");
            }
        }
    }

    // Update is called once per frame
    void Update()
    {
        
    }

    public void OnButtonClick()
    {
        label.text = (++number).ToString();
    }
}
  1. 然后我们回到Unity中,点开GameObject,把创建出来的第一个文本标签拖到Label上,第二个拖到Pointer上进行绑定。
  2. 点开Button,往下滑找到On Click (),点击添加然后把GameObject拖进去,在右边的函数栏里我们选择TestScript > OnButtonClick ()

完成以上步骤之后,我们就搭建好了一个用来测试的游戏场景。

编译IL2Cpp游戏

在编译之前,我们需要先把游戏用的后端改成IL2Cpp,默认情况下游戏使用的后端是Mono。

  1. 在Edit菜单中,点击并选择Project Settings...
  2. 在打开的窗口中选择Player,然后选择第三个安卓图标的(Settings for Android)。
  3. 往下滑找到Configuration > Scripting Backend,将其切换为IL2CPP
  4. 为了加快编译速度,我们在Target Architectures中只勾选ARM64平台。

选哪个平台这个取决于你自己使用的手机,自行判断。

然后我们开始编译

  1. 在File菜单中,点击并选择Build Settings...
  2. 在打开的窗口中我们选择Android平台。
  3. 切换好之后直接点击Build,然后选择要输出的目录,等待编译完成即可。

分析游戏关键数据在IL2Cpp中的结构

正常情况下编译完成后在输出目录下会有一个项目名+_BackUpThisFolder_ButDontShipItWithYourGame为名的目录,这个目录里保存了IL转CPP的源码。

我们直接用VSCode打开这个目录,然后在左边的搜索栏里直接搜索TestScript::number,然后我们选择Assembly-CSharp.cpp

打开之后我们可以看到代码

// TestScript
struct TestScript_t6D759E079BA3FEA034493A5F329125444C6E8F1A  : public MonoBehaviour_t532A11E69716D348D8AA7F854AFCBFCB8AD17F71
{
    // TMPro.TextMeshProUGUI TestScript::label
    TextMeshProUGUI_t101091AF4B578BB534C92E9D1EEAF0611636D957* ___label_4;
    // TMPro.TextMeshProUGUI TestScript::pointer
    TextMeshProUGUI_t101091AF4B578BB534C92E9D1EEAF0611636D957* ___pointer_5;
    // System.Int32 TestScript::number
    int32_t ___number_6;
};

其中int32_t ___number_6;就是我们需要的关键数据。一般情况下我们使用内存搜索工具搜索这个数值的地址所得到的地址就是这个成员变量的地址,因此我们通过分析结构的构成,可能就可以得到一个通用的解决方案。

我们往上找TestScript继承的父类都有哪些,分别是

除了UnityEngine.Object中有一个intptr_t类型的成员之外,其余继承的父类是没有成员的,如果算偏移的话只需要算在自己之前的成员所占的内存总和即可。

我们再在整个目录中搜索RuntimeObject,发现并没有这个东西的定义,那么基本可以判定是进入了IL2Cpp中了。

同样的,我们打开IL2Cpp的源码进行搜索RuntimeObject,可以看到在codegen\il2cpp-codegen-metadata.h中有这么一行typedef Il2CppObject RuntimeObject;

然后我们再跟过去看看Il2CppObject的定义,代码如下

typedef struct Il2CppObject
{
    union
    {
        Il2CppClass *klass;
        Il2CppVTable *vtable;
    };
    MonitorData *monitor;
} Il2CppObject;

如果你对IL2Cpp有一定了解,那么你肯定知道看到Il2CppClass *klass;基本就代表着为所欲为

来梳理一下,我们要实现的效果是通过关键数据的内存地址,反向定位出该数据所在的类名以及对应的字段名。

那么我们可以通过读klass的name成员得到类名,然后遍历fields成员读取此类拥有的字段,然后根据偏移匹配出我们关键数据的字段的名称即可。

如何获得关键数据的偏移,其实就是我们的关键数据地址-Il2CppClass *klass;所在的内存地址

那么基本的逻辑我们都理清了,接下来写个脚本验证一下。

编写Frida脚本

创建一个search.py文件,并写入以下代码

import frida

searchScript = '''
const fieldInfoOffset = 128;
const sizeOfFieldInfo = 32;
const fieldOffsetOffset = 24;
const maxFieldSearch = 5;

const searchStages = 5;
const searchRange = 64;
const searchEndAddress = new NativePointer(`0x${{(0x{}n & ~(BigInt(Process.pointerSize) - 1n)).toString(16)}}`);

console.log(`[=] searchEndAddress:${{searchEndAddress}}`)

function IsPointerValid(pointer) {{
    try {{
        pointer.readPointer();
        return true;
    }} catch (e) {{
        return false;
    }}
}}

function TryGetString(pointer) {{
    try {{
        const result = pointer.readCString();
        
        if (null !== result)
            return /^[\x20-\x7E]*$/.test(result) ? result : null;
    }} catch (e) {{
    }}

    return null;
}}

for (let i = 0; i < searchRange; i += Process.pointerSize) {{
    const classPointer = searchEndAddress.sub(i).readPointer();

    if (0 != classPointer && IsPointerValid(classPointer)) {{
        const imagePointer = classPointer.readPointer();
        const imageName = TryGetString(imagePointer.readPointer());
        const imageNameNoExtent = TryGetString(imagePointer.add(Process.pointerSize).readPointer());

        const name = TryGetString(classPointer.add(16).readPointer());
        const namespace = TryGetString(classPointer.add(24).readPointer());
        
        if (null !== imageName)
            console.log(`[+] Found Image:${{imageName}} Namespace:${{'' === namespace ? '没有' : namespace}} ClassName:${{name}} Offset:${{i}}`);
        if (null !== namespace) {{
            const fieldInfoArrayPointer = classPointer.add(fieldInfoOffset).readPointer();

            for (let next = 0; next < maxFieldSearch * sizeOfFieldInfo; next += sizeOfFieldInfo) {{
                const offset = fieldInfoArrayPointer.add(next).add(fieldOffsetOffset).readS32();

                console.log(`[=] Enumerate field Name:${{TryGetString(fieldInfoArrayPointer.add(next).readPointer())}} Offset:${{offset}}`);
                if (i === offset) {{
                    console.log(`[+] Found we need -> Name:${{TryGetString(fieldInfoArrayPointer.add(next).readPointer())}} Offset:${{offset}}`);
                    break;
                }}
            }}
        }}
    }}
}}
'''

def onMessage(message, data):
    if 'send' == message['type']:
        print(message['playload'])
    else:
        print(message)

if __name__ == '__main__':
    device = frida.get_usb_device()
    processInstance = None

    while True:
        searchAddress = input('请输入用于搜索的起始地址或输入quit退出:')

        if None != processInstance:
            processInstance.detach()

        if 'quit' == searchAddress:
            break

        if '0x' in searchAddress:
            searchAddress = searchAddress.replace('0x', '')

        processInfo = device.get_frontmost_application()
        print('[=] Front application -> {}'.format(processInfo.identifier))

        processInstance = device.attach(processInfo.pid)
        scriptObject = processInstance.create_script(searchScript.format(searchAddress))
        scriptObject.on('message', onMessage)
        scriptObject.load()
        processInstance.resume()

测试

把前面我们编译生成的测试游戏推送到手机上并安装,打开后会出现一个地址。

如果你不想自己编译的话可以在这里下载我编译的版本:下载测试游戏

仅支持ARM64,其他平台的话还是自己想办法编译一份吧!

然后我们运行测试脚本python .\search.py,并输入游戏上显示的地址

测试结果

可以看到运行的结果基本符合我们的预期,成功的找到了该关键数据的字段名以及所在的类名。

nice

结语

没啥好说的,放首歌

那就这样了,有缘再见~