【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);// 执行Hookxhook_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 libraryif (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_AddComponentvoid *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. }// 初始化Shadowhookint 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)