1.佛手在线这款游戏可以开挂,确实是有挂的,通过添加客服微信【9287706】
2.在"设置DD功能DD微信手麻工具"里.点击"开启".
3.打开工具加微信【9287706】.在"设置DD新消息提醒"里.前两个选项"设置"和"连接软件"均勾选"开启"(好多人就是这一步忘记做了)
4.打开某一个微信组.点击右上角.往下拉."消息免打扰"选项.勾选"关闭"(也就是要把"群消息的提示保持在开启"的状态.这样才能触系统发底层接口。)
【央视新闻客户端】
【USparkle专栏】如果你深怀绝技,爱“搞点研究”,乐于分享也博采众长,我们期待你的加入,让智慧的火花碰撞交织,让知识的传递生生不息!
这是侑虎科技第1878篇文章,感谢作者其乐陶陶供稿。欢迎转发分享,未经作者授权请勿转载。如果您有任何独到的见解或者发现也欢迎联系我们,一起探讨。(QQ群:793972859)
https://www.zhihu.com/people/jun-yan-76-80
一、前言
一直以来性能优化的工作,非常依赖于工具,从结果反推过程,采集产品运行时信息,反推生产环节中的问题,性能问题的定位其实就是在做各种逆向。
不同的工具有不同的检测面,一般会按照由粗及细的顺序使用,直到找到问题的答案。
粗粒度的工具,可大致定位到问题是出在哪个硬件上,比如发热问题,可能的负载点在于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,改进了这一点)。
"xhook/xhook.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启动时加载的一些东西:
五、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有源码可以抄一下。
而函数的偏移地址可以使用NDK下llvm-readelf -s指令,读取符号表获取到:
接下来,对函数Hook后,需要对参数进行内存分析提取里面的有用信息,如果有源码,就是开卷考试,按照其内存布局定义出来;没源码,我们也可以通过一些技巧把信息提取出来,下面以实战说明一下。
六、实战:统计引擎内部调用
我曾经在《使用Simpleperf+Timeline诊断游戏卡顿》[1]这一篇文章中提到过,一些常见的卡顿归因,能通过Simpleperf识别,但我们只知道触发堆栈,今天我们更进一步。
这里以AddComponent函数为例,做一个Demo,然后尝试使用Hook把触发的GameObject、组件名字都打印出来,C# 测试代码如下:
// New Game Object节点添加一些Unity内置组件 var go = newGameObject(); go.AddComponent (); go.AddComponent (); go.AddComponent (); // 相机节点添加一个自定义脚本组件 gameObjet.AddComponent ();
通过Simpleperf锁定我们的目标函数为AddComponent(GameObject, Unity::Type const*, ScriptingClassPtr, core::basic_string *)
接下来通过llvm-readelf -s指令,查询函数在符号表中的位置,名字稍微和Simpleperf中的显示形式有点区别,但是我们还是能认出它,它的地址就是0x5126a4。
接下来,我们需要在代理函数里面,对函数参数做一些解析,从函数签名可以看到,参数有4个:void *go、void *unitytype、void *scriptclassptr和void *error。
我们的目标是获取节点名和组件名,解析前3个就行,主要有两种方案:
1. 在符号表里多收集一些工具函数地址,比如获取GameObject名字的方法0x435010,这个方法传入GameObject对象指针作为参数,返回名字字符串,所以可以把这个函数地址存起来,直接调用,我管这叫“他山之石,可以攻玉”。
获取GameObject名字的方法地址能轻易搜索到
2. 针对另外两个参数,可以将结构直接定义出来使用,比如ScriptClass前两个参数是指针,第三个就是C字符串。这些工作,在有相关源码的情况下会容易很多,如果没有的话,只能通过LLDB无源码动态调试之类的手段来获取其内存布局,会涉及到一些二进制分析手段、工具。
"shadowhook.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- } elseif (unitytype != nullptr) { UnityType *t = (UnityType *)unitytype; typeName = t- } 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调试信息的情况下,也能输出函数地址,然后我们通过符号表将函数名解析出来。
栈回溯的步骤虽然看起来繁琐,但只要经过封装后,使用起来其实和在C# 里面一样方便,下一步我们来试一下。
八、实战:为IO调用加入栈统计
沿用之前的PLT Hook的例子,这次我们将调用堆栈打印出来:
使用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
文末,再次感谢其乐陶陶 的分享, 作者主页:https://www.zhihu.com/people/jun-yan-76-80, 如果您有任何独到的见解或者发现也欢迎联系我们,一起探讨。(QQ群: 793972859 )。
【学堂上新】
【学堂上新】
【学堂上新】
【万象更新】
特别声明:以上内容(如有图片或视频亦包括在内)为自媒体平台“网易号”用户上传并发布,本平台仅提供信息存储服务。
Notice: The content above (including the pictures and videos if any) is uploaded and posted by a user of NetEase Hao, which is a social media platform and only provides information storage services.