游戏性能优化与逆向分析技术

游戏性能优化与逆向分析技术

【USparkle专栏】如果你深怀绝技,爱“搞点研究”,乐于分享也博采众长,我们期待你的加入,让智慧的火花碰撞交织,让知识的传递生生不息!


一、前言

一直以来性能优化的工作,非常依赖于工具,从结果反推过程,采集产品运行时信息,反推生产环节中的问题,性能问题的定位其实就是在做各种逆向。

不同的工具有不同的检测面,一般会按照由粗及细的顺序使用,直到找到问题的答案。

  • 粗粒度的工具,可大致定位到问题是出在哪个硬件上,比如发热问题,可能的负载点在于CPU、GPU、其它硬件(屏幕、传感器、网络),一般应该是系统级的工具,常用的有Perfetto、Xcode、GamePerf、PerfDog。
  • 细粒度的工具,检测面较窄,但能提供更深入的信息,比如:定位到是CPU的问题时,可使用Unity Profiler、Simpleperf看问题堆栈;当定位到是GPU的问题时,则使用RenderDoc、SnapdragonProfiler、Arm Graphics Analyzer截帧。

打个比喻,粗粒度的工具好比地铁,能带你到大致的区域范围,更细粒度的工具帮你解决最后一公里路,在实际情况中,“打通”一公里的问题往往是卡点,通用性质的工具可能满足不了需求,常常做一些定制化的东西,通过一定积累,形成强大的工具链以应对各种突发问题,本文主要对于这些底层的技术栈做一些总结。

二、动态库注入

Android系统的数据基本都能通过读各种文件实现(统计线程,读取CPU利用率/频率),但有严格的权限限制,非root环境下,只能读取自己进程相关的文件、内存信息。

我们注入到目标进程的动态库,就好像我们派出的“间谍”一样,利用目标进程的身份执行我们自己的代码。

使用JDWP Shellifier是最常用的方式,我们用C++在NDK环境下编写一个动态库so文件,这个脚本利用Java调试服务加载我们自己的库。这也是RenderDoc、‌LoliProfiler、Matrix用的方式,需要应用Debug权限,或者root开全局调试,或者使用APKTool,解包修改AndroidManifest文件的Debug权限。

https://github.com/IOActive/jdwp-shellifier

这个脚本用Python封装了注入过程,在onCreate函数触发时,加载我们的库。

jdwp_start("127.0.0.1", 500, "android.app.Activity.onCreate", None, libname)


控制台输出显示注入成功

当动态库注入成功时,C++侧入口函数JNI_OnLoad会被执行,我们就可以干自己想干的事情了,这只是打开大门的第一步。

JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM *vm, void *reserved)
{
    (void)reserved;
    LOGI("JNI_OnLoad");

    JNIEnv *env;
    LOGI("------------------ 4000 : %d", (int)JNI_VERSION_1_6);
    if (vm->GetEnv((void **)&env, JNI_VERSION_1_6) != JNI_OK)
    {
        LOGI("JNI version not supported");
        return JNI_ERR; // JNI version not supported.
    }
    else
    {
        LOGI("JNI init complete");
    }
}

下一步介绍Hook技术,俗称钩子,能对特定函数劫持,两种常见Hook手段为PLT Hook、Inline Hook。

三、PLT Hook

先大概讲一下程序调用动态链接库中函数的流程,以libunity.so中调用libc.so的Open函数为例子:会先访问PLT(Procedure Linkage Table),第一次访问它会使用动态连接器查找libc.so中Open函数的地址,然后地址保存到GOT(Global Offset Table)地址表,之后的调用就直接查GOT表了,如下:

所谓的PLT Hook就是在这个过程做文章、钻空子,比如xHook就是修改GOT表的函数地址为我们的自定义函数实现拦截,xHook是一个常用的库,较多运用于各种工具底层实现,我们可以直接使用它,同时它也是开源的,我们可以参考它里面的很多代码。

https://github.com/iqiyi/xHookgithub.com/iqiyi/xHook

PLT Hook比较适合去Hook一些公用库的调用,不管上层怎么变,IO的行为最终落地到对Open、Close、Read、Wirte的调用,实际项目中主要用于IO、内存分配、线程、网络等行为的监控,但它的局限性在于不能Hook内部函数,比如引擎内部的函数调用。

四、实战:打印引擎启动时的IO调用

随便创建一个空的Demo,打包APK,将下面C++代码通过NDK编译成动态库后,使用JDWP注入运行。

这里在JNI_OnLoad函数创建一个新的线程,延迟3秒后再执行Hook的动作,是因为时机太早libunity.so未加载会导致失败(据说xHook的作者后续开发了一个新的库叫bHook,改进了这一点)。

#include <jni.h>
#include <dlfcn.h>
#include "xhook/xhook.h"
#include <thread>
#include <unistd.h>
#include <fcntl.h>
#include <android/log.h>

int MyOpen(const char *pathname, int flags, mode_t mode)
{
    int ret = open(pathname, flags, mode);
    __android_log_print(ANDROID_LOG_INFO, "TestHook", "unity open %s %d", pathname, ret);
    return ret;
}

void TestHook()
{
    // 延迟3秒,等待Unity加载完成
    std::this_thread::sleep_for(std::chrono::seconds(3));

    // 对Open函数Hook注册
    xhook_register("libunity.so", "open", (void *)MyOpen, nullptr);

    // 执行Hook
    xhook_refresh(0);
}

JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM *vm, void *reserved)
{
    JNIEnv *env;
    if (vm->GetEnv((void **)&env, JNI_VERSION_1_6) != JNI_OK)
    {
        return JNI_ERR; // JNI version not supported.
    }

    std::thread(TestHook).detach();

    return JNI_VERSION_1_6;
}

这样我们可以观察到Unity启动时加载的一些东西:


正在加载obb文件


正在加载il2cpp.so

五、Inline Hook

前面提到,PLT Hook不能Hook到库内部的函数调用,这个时候就应该轮到Inline Hook出场,它是通过对目标函数地址插入跳转指令实现,理论上可以Hook住任意内部函数,功能更为强大,由于涉及到在不同CPU架构上的运行状态机器码修改,看起来很复杂,其实一点也不简单,虽兼容性不如PLT Hook,不推荐在生产环境使用,但作为测试环境中的性能工具还是很强的。

ShadowHook是我常用的库,可以将它的C++源码下载下来,和自己库一起编译。

https://github.com/bytedance/android-inline-hook

如果Hook的目标库是带符号表的,可以通过函数名hook,像这样:

stub = shadowhook_hook_sym_name(
               "libart.so",
               "_ZN3art9ArtMethod6InvokeEPNS_6ThreadEPjjPNS_6JValueEPKc",
               (void *)proxy,
               (void **)&orig);

但是我们常见的libunity.so、libil2cpp.so的符号表是分离的,可以尝试用llvm-objcopy合并回去,这里更推荐另一种做法,ShadowHook也可以直接通过函数地址进行Hook

void *shadowhook_hook_func_addr(
    void *func_addr,
    void *new_addr,
    void **orig_addr);

这里的func_addr函数地址是绝对地址,为动态库基地址、函数偏移地址之和,找到这两个地址加起来就行。

动态库基地址每次进程启动都不一样,需要我们在程序中动态获取,可以通过dl_iterate_phdr(Android 5.0以上)获取,也可以读/proc/self/maps实现(Android 4.0版本以上),之前介绍的xHook有源码可以抄一下。


/proc/self/maps能查询到动态库基地址

而函数的偏移地址可以使用NDK下llvm-readelf -s指令,读取符号表获取到:


readelf读取出的引擎内部函数地址

接下来,对函数Hook后,需要对参数进行内存分析提取里面的有用信息,如果有源码,就是开卷考试,按照其内存布局定义出来;没源码,我们也可以通过一些技巧把信息提取出来,下面以实战说明一下。

六、实战:统计引擎内部调用

我曾经在《使用Simpleperf+Timeline诊断游戏卡顿》[1]这一篇文章中提到过,一些常见的卡顿归因,能通过Simpleperf识别,但我们只知道触发堆栈,今天我们更进一步。

这里以AddComponent函数为例,做一个Demo,然后尝试使用Hook把触发的GameObject、组件名字都打印出来,C# 测试代码如下:

// New Game Object节点添加一些Unity内置组件
var go = newGameObject();
go.AddComponent<MeshFilter>();
go.AddComponent<MeshRenderer>();
go.AddComponent<MeshCollider>();
// 相机节点添加一个自定义脚本组件
gameObjet.AddComponent<TestCom>();

通过Simpleperf锁定我们的目标函数为AddComponent(GameObject&, Unity::Type const*, ScriptingClassPtr, core::basic_string<char, core::StringStorageDefault >*)


Simpleperf-Timeline查看命中的native函数

接下来通过llvm-readelf -s指令,查询函数在符号表中的位置,名字稍微和Simpleperf中的显示形式有点区别,但是我们还是能认出它,它的地址就是0x5126a4。


搜索符号表内AddComponent函数地址

接下来,我们需要在代理函数里面,对函数参数做一些解析,从函数签名可以看到,参数有4个:void *go、void *unitytype、void *scriptclassptr和void *error。

我们的目标是获取节点名和组件名,解析前3个就行,主要有两种方案:

1.在符号表里多收集一些工具函数地址,比如获取GameObject名字的方法0x435010,这个方法传入GameObject对象指针作为参数,返回名字字符串,所以可以把这个函数地址存起来,直接调用,我管这叫“他山之石,可以攻玉”。


获取GameObject名字的方法地址能轻易搜索到

2.针对另外两个参数,可以将结构直接定义出来使用,比如ScriptClass前两个参数是指针,第三个就是C字符串。这些工作,在有相关源码的情况下会容易很多,如果没有的话,只能通过LLDB无源码动态调试之类的手段来获取其内存布局,会涉及到一些二进制分析手段、工具。

有了这些准备工作,就可以开始编码了:

#include <jni.h>
#include <dlfcn.h>
#include "shadowhook.h"
#include <thread>
#include <unistd.h>
#include <fcntl.h>
#include <android/log.h>
#include <link.h>

classScriptclass
{
    public:
        void *placeholder1;
        void *placeholder2;
        constchar *name;
};

classUnityType
{
    public:
        void *placeholder1;
        void *placeholder2;
        constchar *name;
};

uintptr_t baseaddr = 0;
int callback(struct dl_phdr_info *info, size_t size, void *data)
{
    constchar *target = (constchar *)data;

    // Check if the current shared library is the target library
    if (strstr(info->dlpi_name, target))
    {
        __android_log_print(ANDROID_LOG_INFO, "TestHook", "Base address of %s: 0x%lx\n", target, (unsigned long)info->dlpi_addr);
        baseaddr = info->dlpi_addr;
        return1; // Return 1 to stop further iteration
    }

    return0; // Continue iteration
}

void *old_AddComponent = nullptr;
typedef void *(*AddComponentFunc)(void *go, void *unitytype, void *scriptclassptr, void *error);
typedef constchar*(*GameObjectGetNameFunc)(void *ptr);

void *MyAddComponent(void *go, void *unitytype, void *scriptclassptr, void *error)
{
    constchar *goName = nullptr;
    constchar *typeName = nullptr;

    if(go != nullptr)
    {
        // 计算GameObjectGetName的地址
        uintptr_t addr = baseaddr + 0x435010; 
        // 调用GameObjectGetName获取名称
        GameObjectGetNameFunc func = (GameObjectGetNameFunc)(addr);
        goName = func(go);
    }

    if (scriptclassptr != nullptr)
    {
        Scriptclass *t = (Scriptclass *)scriptclassptr;
        typeName = t->name;
    }
    elseif (unitytype != nullptr)
    {
        UnityType *t = (UnityType *)unitytype;
        typeName = t->name;
    }

    if(goName == nullptr)
        goName = "null";

    if(typeName == nullptr)
        typeName = "null";

    __android_log_print(ANDROID_LOG_INFO, "TestHook", "UnityAddComponent: %s %s\n", goName, typeName);

    return ((AddComponentFunc)old_AddComponent)(go, unitytype, scriptclassptr, error);
}

void TestHook()
{
    // 延迟3秒,等待Unity加载完成
    std::this_thread::sleep_for(std::chrono::seconds(3));

    // 查询libunity的基地址
    constchar *library_name = "libunity.so";
    dl_iterate_phdr(callback, (void *)library_name);

    // 计算AddComponent的函数地址
    uintptr_t addr = baseaddr + 0x5126a4;

    // 执行Hook并保存原函数地址到old_AddComponent
    void *stub = shadowhook_hook_func_addr((void *)addr, (void *)MyAddComponent, (void **)&old_AddComponent);
    if (stub == nullptr)
    {
        int err_num = shadowhook_get_errno();
        constchar *err_msg = shadowhook_to_errmsg(err_num);
        __android_log_print(ANDROID_LOG_INFO, "TestHook", "hook error %d - %s\n", err_num, err_msg);
    }
    else
    {
        __android_log_print(ANDROID_LOG_INFO, "TestHook", "hook success\n");
    }
}

JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM *vm, void *reserved)
{
    JNIEnv *env;
    if (vm->GetEnv((void **)&env, JNI_VERSION_1_6) != JNI_OK)
    {
        return JNI_ERR; // JNI version not supported.
    }

    // 初始化Shadowhook
    int ret = shadowhook_init(SHADOWHOOK_MODE_UNIQUE, true);
    if (ret != 0)
    {
        constchar *err_msg = shadowhook_to_errmsg(shadowhook_get_init_errno());
        __android_log_print(ANDROID_LOG_INFO, "TestHook", "init error %d - %s\n", shadowhook_get_init_errno(), err_msg);
    }
    else
    {
        __android_log_print(ANDROID_LOG_INFO, "TestHook", "init success\n");
    }

    std::thread(TestHook).detach();

    return JNI_VERSION_1_6;
}

和前面PLT Hook的例子一样,使用JDWP注入执行,最终可以输出Demo中调用AddComponet的参数详情,利用这些信息,接下来就可以做很多事情了,我们现在可以几乎Hook任意函数!


控制台最终能正常输出节点、组件名

七、栈回溯

在栈上每个函数都有自己的储存空间,被称之为栈帧(Frame),上面保存了部分参数、局部变量。当调用其它函数时,会将这个函数返回后的下一行指令地址也保存在栈帧,栈回溯就是分析这些栈上面函数地址,还原函数运行轨迹的过程。


函数A调用函数B,0x40056a是函数B结束后返回的地址

栈回溯经常和Hook一起配合,当Hook住某个函数后,输出它的调用栈,能更进一步分析问题归因,如果对性能要求不高,可以直接使用libunwind库,它在不需要开-fno-omit-frame-pointer编译选项、dwarf调试信息的情况下,也能输出函数地址,然后我们通过符号表将函数名解析出来。

#include <unwind.h>
#include <android/log.h>

// 栈回溯上下文结构
struct BacktraceState
{
    void **current;
    void **end;
};

static _Unwind_Reason_Code UnwindCallback(struct _Unwind_Context *context, void *arg)
{
    BacktraceState *state = static_cast<BacktraceState *>(arg);
    uintptr_t pc = _Unwind_GetIP(context);
    if (pc)
    {
        if (state->current == state->end)
        {
            return _URC_END_OF_STACK;
        }
        else
        {
            *state->current++ = reinterpret_cast<void *>(pc);
        }
    }
    return _URC_NO_REASON;
}

size_t CaptureBacktrace(void **buffer, size_t max)
{
    BacktraceState state = {buffer, buffer + max};
    _Unwind_Backtrace(UnwindCallback, &state);
    return state.current - buffer;
}

void DumpBacktrace(std::ostream &os, void **buffer, size_t count)
{
    for (size_t idx = 0; idx < count; ++idx)
    {
        constvoid *addr = buffer[idx];
        constchar *symbol = "";

        Dl_info info;
        if (dladdr(addr, &info) && info.dli_sname)
        {
            symbol = info.dli_sname;
        }

        // 这里将函数的绝对地址转换为相对地址
        uintptr_t relative = (uintptr_t)addr - (uintptr_t)info.dli_fbase;
        os << "  #" << std::setw(2) << idx << ": " << info.dli_fname << " " << (void *)relative << "\n";
    }
}

// 经封装后的打印函数
void PrintStacktrace(const size_t count)
{
    void* buffer[count];
    std::ostringstream oss;
    DumpBacktrace(oss, buffer, CaptureBacktrace(buffer, count));
    __android_log_print(ANDROID_LOG_INFO, "TestHook", oss.str().c_str());
}

栈回溯的步骤虽然看起来繁琐,但只要经过封装后,使用起来其实和在C# 里面一样方便,下一步我们来试一下。

八、实战:为IO调用加入栈统计

沿用之前的PLT Hook的例子,这次我们将调用堆栈打印出来:


调用封装好的PrintStacktrace


现在打印日志里多了调用栈函数地址

使用NDK目录下的addr2line.exe对这些地址进行解析,最终得到我们想要的结果。

LocalFileSystemPosix::Open(FileEntryData&, FilePermission, FileAutoBehavior)
zip::CentralDirectory::Enumerate(bool (*)(FileSystemEntry const&, FileAccessor&, char const*, zip::CDFD const&, void*), void*)
VerifyAndMountObb(char const*)
MountObbs()
UnityPause(int)
UnityPlayerLoop()
nativeRender(_JNIEnv*, _jobject*)

九、结语

本文从以性能优化分析目的入手,介绍了常用的逆向分析手段 —— 注入、Hook、堆栈回溯,这里只是浅显地聊了一下运用场景,事实上每一个坑都能挖到很深,比如注入与反注入,如何对竞品进行注入,Hook的相关调试方法、内存分析、更高性能的栈回溯、聚合显示(火焰图)等等。

之所以总结此文,是因为我在近期的工作中感觉到,了解一点逆向分析的知识,对性能优化、程序调试方面很有好处,也不局限于游戏开发领域,技多不压身。

参考
[1] 使用Simpleperf+Timeline诊断游戏卡顿
https://zhuanlan.zhihu.com/p/666443120


这是侑虎科技第1878篇文章,感谢作者其乐陶陶供稿。欢迎转发分享,未经作者授权请勿转载。如果您有任何独到的见解或者发现也欢迎联系我们,一起探讨。(QQ群:793972859)

作者主页:https://www.zhihu.com/people/jun-yan-76-80

再次感谢其乐陶陶的分享,如果您有任何独到的见解或者发现也欢迎联系我们,一起探讨。(QQ群:793972859)