百度 App 性能优化工具篇 - Thor 原理及实践
01 背景
App 开发过程中,如果遇到一些疑难问题或者性能问题(如低端机卡顿),由于没法拿到更多系统的有效信息很难有效定位。这时,Hook 不失为一种好的解决方案,Hook 技术是在程序运行的过程中,动态的修改代码,植入自己的代码逻辑,修改原有程序执行流程的技术。Hook 技术有如下几点能力:
【耗时监控】在代码前后动态插入 Trace 打点,统计耗时;
【性能监控】IO 监控、内存监控、View 绘制监控、大图检测等;
【安全审查】Hook 敏感 API(例如定位),用以安全评审;
【逆向和脱壳】对病毒样本进行逆向和脱壳分析;
Hook 技术由来已久,目前业界 Java 和 Native Hook 都有不少优秀的开源框架,但是如果我们需要将 Hook 能力使用到线上,都或多或少有些问题,例如兼容性、稳定性、动态性等等。
鉴于此,我们开发了一套 Thor 容器框架,提供标准的 Hook 接口,降低学习成本,同时将开源框架按照接口适配成插件动态下发按需安装,保证 Hook 能力的完备和轻量性,并且后续出现更加优秀以及自研的框架的可以无缝的接入和 Hook 能力拓展,并且不需要上层业务代码和插件进行适配,保证兼容性。
02 现状
Android 系统的编程语言主要分为 Java 和 C/C++,Hook 方向也主要分为 Native 和 Java Hook 两种,其中 Native Hook 原理也主要分为 PLT / Inline Hook 两大类,然后 Java Hook 也分为替换入口点 Hook(Replace Entrypoint Hook)和类 Inline Hook 两大类。
Native 方法执行流程大概如下:
Native 方法执行过程中会先通过 PLT 表找到 GOT 表中函数全局偏移地址,然后执行其机器码指令,PLT Hook 主要是指通过修改 GOT 中函数的全局偏移地址来达到 Hook 的目的,代表框架如:xHook、bHook 等;Inline Hook 则主要是指直接将函数执行的机器码指令进行修改和指令修复来达到 Hook 的目的,代表框架如:Android-Inline-Hook 等。
GOT(Global Offset Table):全局偏移表用于记录在 ELF 文件中所用到的共享库中符号的绝对地址。
PLT(Procedure Linkage Table):过程链接表的作用是将位置无关的符号转移到绝对地址。当一个外部符号被调用时,PLT 去引用 GOT 中的其符号对应的绝对地址,然后转入并执行。
Java 方法执行流程大概如下:
Java 方法执行过程中会通过方法在虚拟机中对应的结构 Method 或 ArtMethod 结构体中的入口点(Entrypoint),来找到对应的字节码/机器码指令执行。替换入口点 Hook(Replace Entrypoint Hook)是指替换 Method/ArtMethod 中的入口点来达到 Hook 的目的,代表框架如:Xposed、Dexposed、YAHFA 等;类 Inline Hook 是指将入口点对应的字节码/机器码进指令进行修改和指令修复来达到 Hook 的目的,代表框架如:Epic 等,由于安卓虚拟机的 JIT/AOT 机制的存在,函数执行地址可能会进行各种变化,所以通常会将字节码强行编译成机器码,然后统一通过修改机器码指令来 Hook。
丨 2.1 常见 Native Hook 框架
丨 2.1.1 xHook 框架
xHook 框架通过 PLT Hook 方案来实现的,PLT Hook 是通过直接修改 GOT 表,使得在调用该共享库的函数时跳转到的是用户自定义的 Hook 功能代码。流程如下:
了解 PLT Hook 的原理之后,知道该 Hook 方式有如下特点:
由于修改的是 GOT 表中的数据,因此修改后,所有对该函数进行调用的地方就都会被 Hook 到。这个效果的影响范围是该 PLT 和 GOT 所处的整个 so 库。
PLT 与 GOT 表中仅仅包含本 ELF 需要调用的共享库函数项目,因此不在 PLT 表中的函数无法 Hook 到(比如非 export 导出函数就无法 Hook 到)。
丨 2.1.1 Andorid-Inline-Hook 框架
Inline Hook 的原理则是直接修改函数在.text 实际执行的机器码来实现 Hook,不仅对所有 SO 生效,还能 Hook 非 export 导出函数,补齐了 PLT Hook 方法的不足。流程如下:
但是由于你直接修改的是机器码指令,由于指令架构版本的差异以及后续要进行指令修复,容易有兼容性的问题。
丨 2.2 常见 Java Hook 框架
丨 2.2.1 Dexposed 框架
Dexposed 框架只支持 Dalvik 虚拟机,此虚拟机通过 Method 结构体中 accessFlags 字段来判断当前方法是字节码还是机器码。该框架通过修改 accessFlags 字段为 ACC_NATIVE,将 Java 原方法注册为 Native 方法,调用时会先调用 Native Hook 方法,再反射调用 Java 原方法来实现 Hook 的目的,流程图如下所示:
丨 2.2.2 Epic 框架
Epic 框架则是在 Dexposed 的基础上,增加了对 ART 虚拟机 Hook 的能力。由于 ART 虚拟机的复杂性(AOT 和 JIT),Java 代码执行的入口可能随时都在变化,如果通过 ArtMethod 中的 entry_point_from_quick_compiled_code_字段入口进行 Hook,可能会发生不可预期的崩溃。Epic 则是在 Wißfeld, Marvin 的论文 ArtHook: Callee-side Method Hook Injection on the New Android Runtime ART 基础上做了实现,大概思路是把 entry_point_from_quick_compiled_code_指向的机器码地址(未编译的字节码也会强制编译成机器码,类似于 Inline Hook)进行修改,跳转到跳板代码,然后通过跳转代码进行分发,调用 Hook 方法之后再调用原方法,来达到 Hook 的目的。流程图如下:
丨 2.3 常见框架对比
通过分析和对比可知,开源框架存在比较典型的几个问题如下:
Hook 能力不完备:无法同时满足所有的 Hook 场景(Java Hook 和 Native Hook);
兼容性问题:由于现有框架可能存在各种各样的稳定性问题,导致如果后续替换 Hook 框架,则所有的业务 Hook 逻辑都要修改存在兼容性问题;
不支持动态 Hook:只能将代码内置到主包中,没法动态下发安装实现动态 Hook;
没有容错机制:大部分框架都有稳定性问题且没有容灾机制,如果导致应用崩溃,会导致灾难性的后果。
03 方案选型
从现有状况来看,如果同时需要 Java/Native Hook 的能力,那么至少需要集成两个框架,业务代码也只能在主包中编写,增加包体积。其次如果替换使用更加优秀或者自研的框架时,所有的业务代码也要跟着修改,学习和适配兼容的成本巨大。最后 Hook 框架导致的崩溃,因为没有动态能力和容灾机制也只能重新发布应用和铺渠道,影响用户体验。
虽然每个框架都有各自的一些问题,但是要求我们从头开始开发一款同时支持 Java 和 Native Hook 的框架,没有稳定性问题并且兼容所有安卓版本、轻量且容灾的框架,重复造轮子并且 ROI 太低,所以我们要开发自己的一套容器框架,取长处补短板,充分利用好已有的框架来实现目标。
百度 App 作为超级 App,本身就是一个航空母舰,容器框架要在其上线至少需要达到以下几点要求:
完备性:需要支持所有的 Hook 能力(Java 和 Native Hook),能够覆盖所有代码范围;
兼容性:插件保证向后兼容,即使替换底层 Hook 框架,业务完全无感知,不需要重新学习和适配新的 Hook 框架;
轻量动态性:体积要尽量保证轻量,这对于手百尤为重要,并且支持通过云控下发的方式动态安装运行;
容灾性:发生连续启动崩溃时可以自关闭恢复,不会持续影响线上用户。
04 Thor 揭秘
为了满足上述要求,我们开发了 Thor 容器框架,提供标准的 Hook 接口,包含 Java 和 Native Hook 接口,业务方不需要关心底层实现逻辑(如同虚拟文件系统 VFS),只需要了解如何使用这些接口即可,极大的降低学习接入成本。同时将稳定的开源框架按照接口适配成插件,将这些 Hook 能力进行抽象,按需动态的安装加载,保证 Hook 能力的完备性和轻量性。并且后续出现更加优秀以及自研的框架的可以无缝的接入,对上层也是无感知的,不需要上层业务代码和插件进行适配,很好的保证了兼容性。
丨 4.1 Thor 整体结构
丨 4.1.1 Thor 架构图
支撑业务:支撑了低端机、隐私合规、OOM 和流水线等多个业务;
Thor 抽象层:主要包含 Java / Native Hook 和 Thor Module 的业务模块等抽象层接口;
应用层插件:包含了 SP、IO、线程、内存等基础插件或者业务相关插件,其适配实现了 Thor Module 的业务模块接口;
实现层插件:Epic(Java Hook)、xHook(PLT Hook)、Android-Inline-Hook(Inline Hook)或者自研等插件,其适配实现了 Java / Native Hook 接口;
Thor 框架
插件模块:支持自主开发插件,支持插件热插拔,可以通过内置或云控动态下发,即时生效;维护和调度插件的生命周期;
沙盒模块:支持在沙盒进程安装插件,不影响主进程,重启生效;
校验模块:支持对插件进行安全校验,保证插件来源安全性;
插件管理界面:支持对已有插件动态安装和卸载的控制管理界面。
Thor 实现层插件和 Thor 应用层插件都是 apk 的形式存在,但是也可以以组件源码的形式集成打包到宿主中。
丨 4.2 Thor 核心优势
丨 4.2.1 易用性
Thor 只开发抽象层接口,底层实现对业务是不可见的,不需要反复学习,这样最大程度的保证了易用性。Java/Native Hook 都提供了标准的接口供业务方使用,接口如下:
Java Hook 接口**(Thor 提供 Java Hook 能力的接口)**
Native Hook 接口(Thor 提供 Native Hook 能力的接口,包含 PLT Hook 和 Inline Hook)
Thor Module 接口 (Thor 提供的业务模块接口)
主要提供给业务模块使用,如果需要使用 Hook 能力,直接在 handleLoadModule 子类实现中调用 Thor 的各个 Hook 能力即可(不是必须使用的,Thor 作为容器框架只是额外提供了 Hook 的能力而已)。
丨 4.2.2 完备性
该框架同时支持 Java / Native Hook 的能力,具有完备的 Hook 能力。上小节讲解了提供给业务方的 Java/Native Hook 和 Thor Module 业务模块等抽象层接口,底层实现则根据接口进行适配之后,通过静态代码依赖注入或动态模块加载注入到抽象层实现中,这样 Thor 就具备了完备的 Hook 能力。Thor 的 Java Hook 能力(类 Xposed API)Hook Handler#dispatchMessage 方法,代码如下:
继续看 Thor#findAndHookMethod 的逻辑,代码如下:
ThorHook#findAndHookMethod 通过类的类类型、函数名和参数,找到相应的 Method,再调用 ThorHook#hookMethod 进行 Hook,继续看如下代码:
多个业务方如果 Hook 了同一个 java 方法,会被加到缓存中,Hook 回调的时候再逐个进行分发;继续可以看到 hookMethod 最后调用到了 getHookEntity#hookMethod 方法,最终会调用到具体 Java Hook 框架实现的 hookMethod 方法,例如 Epic 的适配代码如下:
Thor 的 Native Hook 能力使用 PLT Hook 对应 SO 所在 PLT 表的 open 函数,Inline Hook puts 方法,部分代码如下:
根据 4.2.1 中的 Native Hook 接口可知,thor_plt_hook 和 thor_inline_hook 成员都是函数指针,指针只有指向真正的 Native Hook 能力,代码才会生效,所以相应的 Hook 框架也需要根据 Native Hook 接口进行适配,例如 xHook 适配 PLT Hook 部分代码如下:
Android-Inline-Hook 适配 Inline Hook 接口部分示例代码如下:
我们在使用这些底层 Hook 框架适配组件(插件)的过程中,也遇到了一些问题,例如 Epic 在 Hook Handler#dispatchMessage 的过程中,会发生不符合预期的崩溃,但是在进一步调研了 SandHook 可以解决该问题之后,马上就适配了 SandHook 的实现来解决问题,业务方的代码不需要做任何修改和适配,再例如 xHook 的作者新写了一款 PLT Hook 框架 bHook,解决了 xHook 的一些问题(例如增量 Hook,unHook 能力等等),我们也很快跟进对 bHook 框架进行了调研和适配,同样业务方也是无感知的,这两个例子从侧面佐证了 Thor 容器框架具有良好的兼容性和可扩展性。同时同学们可能会有如下疑惑,如果 Hook 框架出问题,难道只能去找更好的开源方案进行适配吗?有没有银弹呢?这其实就回到了方案选型时所说的,由于安卓的碎片化和复杂性,从头开始开发一款同时支持 Java 和 Native Hook 的框架,没有稳定性问题并且兼容所有安卓版本、轻量且容灾的框架,重复造轮子并且 ROI 太低,所以我们要开发自己的一套容器框架,取长处补短板,充分利用好已有的框架来实现目标,当然也不排除在所有开源方案都不满足的情况下,进行深度二次开发或者自研底层 Hook 框架,不过这些对业务代码都不可见,不需要修改适配。
丨 4.2.3 轻量动态性
百度 App 作为一个航母级应用,对于包体积大小还是比较敏感的,根据 Google Store 的数据,包体积每增加 6M,就降低 1%的转化率,影响巨大,所以 Thor 容器框架要尽可能的做到轻量,基于此,我们需要把业务代码做成动态加载的插件,甚至是底层适配的 Hook 实现也要做成动态可加载的插件。业务代码可以不在宿主中编写,只在插件代码中编写,然后将生成的插件动态下发到手机上,再通过插件加载模块动态加载生效。例如:在需要监控应用 IO 的情况下,下发 IO 插件和 xHook 插件到手机上安装,Hook IO 操作(例如:open、read、write 等),将不合理的 IO 操作上报给平台,同时在不需要监控的时候动态卸载关闭即可。插件动态加载生效的大致流程如下:Thor 容器框架会对插件进行 v2 签名校验,保证插件来源的安全性;解析插件中的清单文件存储为 info 对象,包含插件包名、插件入口类、ABI List、插件版本等等;对插件中的 SO 进行释放,不然 classloader 会找不到插件中的 SO;创建自定义的 ThorClassLoader 进行插件类加载,会先加载插件中的类再加载宿主中的类,部分代码如下:
实例化插件入口类并判断其接口类型,注入相应的能力到 Thor 抽象层:如果是 Java Hook 接口实现类,则注入 Java Hook 实例能力给 Thor 抽象层;如果是 Native Hook 接口实现类,则注入 Native Hook 实例能力给 Thor 抽象层;如果是 Thor Module 业务接口实现类,则将业务实例存储到 map 中,等待后续插件管理模块调度相应的生命周期。大概流程图如下:
这里大家可能会有以下疑问:如果上层的业务层插件先安装,底层实现层插件后安装的情况怎么办?Thor 有一个 pending 模式会等到实现层安装生效之后,业务层的逻辑再开始执行生效;Android 8.0 的 classloader 有 namespace 机制,不同的 classloader 加载相同 SO,会有多份 SO 在内存中,这个时候如何将插件中 Native Hook 能力传递给 Thor 抽象层呢?通过翻看源码,Binder 调用中的 Parcel 类拥有 Native 对象的内存指针,所以我们也借鉴相同的方法,将 Native 对象内存指针地址通过 Java 层进行传递,然后使用拥有相同内存布局的 struct 结构体进行转换,这样就可以拿到 Native Hook 实现了。
丨 4.2.4 容灾性
Hook 技术毕竟是一个比较 hack 的技术,谁也无法保证百分百的兼容和稳定性,所以我们要针对这种特殊的崩溃情况进行兜底,将该框架可能造成的影响降到最低。目前有三个容灾能力:Thor 容器框架在及时性要求不高的情况下,支持沙盒进程安装。如果安装过程中发生了崩溃,不会影响主进程,用户无感知,并且会自动回滚插件进行止损;Thor 容器框架会结合安全模式,可以监控连续启动崩溃次数,如果超过阈值,就自动关闭 Thor 框架,快速自恢复及时止损;通过百度内部的性能平台监控 Thor 相关崩溃,可以通过云控动态关闭 Thor 框架。通过这三个容灾能力,基本能够保证百度 App 不会因为 Thor 容器框架发生大规模的崩溃影响用户体验,能够较好的管理风险。
05 业务实践案例
Thor 框架作为一套动态插件容器基础设施,真正让其起作用的是丰富的插件生态(如 IO、内存、线程、隐私等等),可以根据实际需要,大胆的发挥想象,开发适合业务场景的插件。目前该框架可以应用于线下 RD 开发、线下流水线和线上云控开启,由于篇幅限制,摘选其中一些案例讲述。
丨 5.1 线程插件
由于在开发过程中随手就可以创建一个线程运行,也没有强制约束使用线程池,这样会导致很多游离线程,线程过多不仅会提高内存使用导致 IO 放大和 OOM 崩溃,并且有频繁的上下文切换会导致启动和流畅度问题。线程插件则通过 Thor 框架的 PLT Hook 能力 Hook libart.so 库中的 pthead_create 的函数,来监控线程的创建。核心代码如下:
Hook 完成之后,会在创建线程的过程中先调用 proxy_pthread_create 的代理方法再调用原始的创建线程方法,在代理方法中通过反射打印创建当前线程的 Java 堆栈。在百度 App 启动阶段通过线程插件监控记录发现有 100+个 SP 线程,和 50+非线程池管理的线程,严重影响启动速度和用户体验。协助组内同学进行优化后(SP 迁移 KV 组件,所有线程通过线程池管理),降低启动过程中线程数 100+,优化 TTI(Time To Interactive) 1s+。
丨 5.2 IO 插件
由于在开发过程中有同学会把一些 IO 操作在主线程中执行,例如文件读写、网络传输,这样会导致主线程卡顿,影响启动速度、流畅度等,即使是小文件也可能因为内存不足和磁盘不足等原因导致 IO 读写放大,从而导致长耗时的 IO,同时还有一些不合理的 IO,例如:读写 buffer 过小会导致频繁的系统调用影响性能,以及重复读同一个文件等。IO 插件则通过 Thor 框架的 PLT Hook 能力 Hook IO 操作(open、read 和 write、close 等),用来记录监控主线程不合理的 IO。核心代码如下:
调用 open 时会先调用 ProxyOpen,ProxyOpen 中会存储 fd(文件描述符)和 IOInfo 的 map 映射关系,后续的 ProxyRead、ProxyWrite 和 ProxyClose 则通过 fd 来完善 IOInfo 的信息,IOInfo 部分字段如下:
在最后文件 Close 的时候通过分析 IOInfo 即可分析出不合理的 IO 操作(例如主线程 IO 耗时过长、IO 的 buffer 过小(导致系统调用增多)、重复读等)。在百度 App 启动过程中通过 IO 插件监控记录发现有 20+不合理的 IO 操作,与各个业务方的同学进行协同和优化,最终启动速度 TTI 优化 400ms+,提升了用户体验。
丨 5.3 隐私合规插件
由于个人信息法的颁布,应用不可以在隐私弹窗确认前获取用户个人信息,基于此,隐私合规插件使用 Thor 框架的 Java Hook 的能力,监控记录隐私弹窗前不合理的隐私 API 调用(例如定位、WI-FI、蓝牙等等),部分代码如下:
隐私合规插件结合了手百内部通用防劣化流水线的能力(这里不展开讲解),每天自动编译打包内置隐私合规插件,然后自动在真机上测试,监控记录隐私弹框前的隐私问题,最后自动分析、分发问题卡片给相应的业务同学进行修改,有效的规避了合规风险,防止被下架整改;
丨 5.4 内存插件
内存优化是性能和稳定性优化中的一大课题,如果内存占用过大,轻则导致频繁 GC 造成卡顿,重则内存溢出导致 OOM 应用崩溃。内存插件则通过 Thor 框架 PLT Hook 的能力,监控记录 Java 堆内存和 Native 内存(监控 malloc 和 free 等函数)。内存插件目前有两个使用场景:结合线下流水线的能力,每天自动编译打包内置内存插件,然后在真机上使用 monkey 随机测试,在内存水位高时 dump hprof 文件,并进行裁剪(通过 PLT Hook write 方法实现,参考 Tailor 在 hprof 文件写入过程中过滤不需要的信息),最后自动分析出内存泄漏问题和大对象问题,自动分发问题给相应的业务同学进行修改,将内存问题前置,防止问题上线影响用户体验。结合线上 Thor 丰富的云控能力,动态下发到 OOM 用户手机上开启内存监控能力,然后回捞上报相关的数据进行问题分析、分发,解决线下不易复现的线上 OOM 崩溃问题。
06 总结
Hook 这个话题由来以久,框架种类繁多,但是没有一款全面性、动态性以及兼容性好的框架,但是正是有这些优秀的框架(Xposed、Dexposed、Epic、xHook 等),我们才能学习和借鉴其优秀的设计和理念,补齐不足,Thor 只是在这条道路上迈出了一小步,后面需要更加完善和夯实 Thor 基础设施,并且丰富插件生态,在 Android 性能和稳定性治理上添砖加瓦。
——————END——————
相关链接:[1] Dexposed 链接:https://github.com/alibaba/dexposed[2] ArtHook 论文链接:http://publications.cispa.saarland/143/[3] Epic 链接:https://github.com/tiann/epic[4] xHook 链接:https://github.com/iqiyi/xHook[5] Android-Inline-Hook 链接:https://github.com/ele7enxxh/Android-Inline-Hook[6] Tailor 链接:https://github.com/bytedance/tailor[7] Matrix 链接:https://github.com/Tencent/matrix/
评论