浅谈 Android Binder 监控方案

在 Android 应用开发中,Binder 可以说是使用最为普遍的 IPC 机制了。我们考虑监控 Binder 这一 IPC 机制,一般是出于以下两个目的:

  • 卡顿优化:IPC 流程完整链路较长,且依赖于其他进程,耗时不可控,而 Binder 调用本身通常又是以 RPC 形式对外提供能力的,使得我们在使用时更容易忽略其 IPC 的本质。总的来说,主线程阻塞在同步 Binder 调用上是导致应用卡顿的一大典型原因。
  • 崩溃优化:Binder 调用过程中可能出现各类异常情况,典型的是 Binder 缓冲区耗尽的情况( 经典 TransactionTooLargeException )。Binder 的缓冲区大小大约为 1M( 注意到这里的缓冲区是最早 mmap 出来进程内全局共享的,而非单次 Binder 调用的限制 ),而异步调用( oneway )的情况更是被限制只能使用缓冲区的一半大小( 约 512K )。

考虑只监控某些个系统服务的情况,得益于 ServiceManager 和 AIDL 的设计,我们可以简单基于动态代理替换当前进程对应的 Proxy 对象来实现监控;而要实现进程内全局 Binder 监控的话,我们需要考虑怎么拦截到 Binder 调用通用的 transact 方法。

基于 Binder.ProxyTransactListener

注意到 Android 10 上系统引入了 Binder.ProxyTransactListener,在 Binder 调用前后( BinderProxy 的 transactNative 方法内部 )会触发回调。从提交记录来看,ProxyTransactListener 引入的目的之一就在于支持 SystemUI 监控主线程的 Binder 调用。

美中不足的地方在于,ProxyTransactListener 和相应的设置接口都是 hide 的,且 BinderProxy 的类属性 sTransactListener 在 hidden api 名单中。不过到目前为止,我们始终是有稳定的方案可以绕过 hidden api 的限制的,因此我们可以基于动态代理创建一个 ProxyTransactListener 实例设置给 BinderProxy,来实现对进程内 Java 的 Binder 调用的全局监控。

这里稍微提一下 hidden api 的绕过方案,在 Android 11 系统禁掉元反射之后,一个简单的方案是先创建一个 Native 线程再 Attach 拿到 JNIEnv,就可以在该线程内正常使用

VMRuntime.getRuntime().setHiddenApiExemptions(new String[]{"L"}); 

来实现全局 hidden api 加白。原理是系统对于基于 JNI 访问 Java API 的情况,在回溯 Java 堆栈找不到 caller 的情况,会信任该次调用不做 hidden api 的拦截,详细逻辑见 GetJniAccessContext。因此我们可以通过创建 Native 线程再 AttachCurrentThread 访问 JNI 接口的方式来构造没有 Java caller 的情况( 这也是 Native 线程 AttachCurrentThread 无法访问应用类的原因,没有 caller 就找不到可用的 ClassLoader )。比较有意思的地方是实际上官方早就意识到了这类 hidden api 后门的存在,但由于改动风险太大一类的原因一直没有合入限制逻辑,类似的讨论可以参考 Don’t trust unknown caller when accessing hidden API。

这一方案实现简单,兼容性好,主要的毛病在于:

只支持 Android 10 及以上版本,不过现状 Android 10 以下的设备占比已经不高了。
ProxyTransactListener 的接口设计上不带 data 参数( Binder 调用的传入数据 ),我们也就无法做传输数据大小的统计。此外,对于多进程应用来说,实践上可能会以统一的 AIDL 对象作为通信通道封装一层屏蔽了匿名 Binder 对象传递和 AIDL 模版代码的 IPC 框架,实际 IPC 调用的目标逻辑则以统一的调用约定封装在 data 参数中。这种情况下,只有拿到 data 参数( 将 IPC 调用统一放到线程池中执行的情况,堆栈没有意义 )我们才能实际确认 IPC 调用的真正目标逻辑。

JNI Hook BinderProxy.transactNative

实际上 Java 的 Binder 调用总是会走到 BinderProxy 的 JNI 方法 transactNative,我们可以基于 JNI Hook 来 hook transactNative,实现全版本的进程内 Java 的 Binder 调用的全局监控,也可以拿到 Binder 调用的完整参数和返回结果。

这里稍微提一下 JNI Hook,JNI Hook 基于 JNI 边界 hook Java JNI 方法对应的 Native 函数实现,具体实现上 hack 点少,稳定性较好,算得上是线上比较常用的一类通用 Native Hook 方案。大体上讲,JNI Hook 实现上分为两步,找到原 Native 函数和替换该 Native 函数。

  • Native 函数的替换比较简单,我们通过调用 JNI 的 RegisterNatives 接口就可以实现 Native 函数的覆盖。( 注意到如果原始 JNI 方法也是通过 RegisterNatives 注册的,我们需要保证 JNI Hook 的 RegisterNatives 执行在后 )
  • 找到原 Native 函数则稍微复杂些,而且我们总是需要依赖原 Native 函数已经先行注册才能找到该 Native 函数。
    • 一个可行的方案是手动实现一个 JNI 方法,用来计算实际 ArtMethod 对象( 即 Java 方法在 art 中的实际表示 )中存放 Native 函数的属性的偏移。得到这个偏移之后即可以基于被 Hook JNI 方法的 ArtMethod 对象拿到原 Native 函数。怎么拿到 ArtMethod 对象?实际上,在 Android 11 以前,jmethodID 就是 ArtMethod 指针,在 Android 11 之后,jmethodID 默认变成了间接引用,但我们仍然可以通过 Java Method 对象的 artMethod 属性拿到 ArtMethod 指针。详细介绍可参考一种通用超简单的 Android Java Native 方法 Hook,无需依赖 Hook 框架。
    • 另一个可行的方案是可以基于 art 的内部函数 GetNativeMethods 来直接查询得到原 Native 函数。GetNativeMethods 几个函数是 art 用于支持 NativeBridge 的,稳定性上也有所保证。NativeBridge 详细介绍可参考用于 Android ART 虚拟机 JNI 调用的 NativeBridge 介绍。

具体到 JNI Hook BinderProxy.transactNative,实际在跑到应用的第一行业务代码( Application 的 attachBaseContext )之前,就已经有 Java 的 Binder 调用发生,因此我们根本不需要手动触发 Binder 调用来保证 BinderProxy.transactNative 的 Native 函数注册。另外,注意到 BinderProxy 的 transactNative 也是 hidden api,这里也需要先行绕过 hidden api 的限制。

Hook BinderProxy.transactNative 的方案可以很好地满足监控进程内全局 Java Binder 调用的需要,但却监控不到 Native 的 Binder 调用。注意到这里的 Java/Native Binder 调用的差异在于 IPC 通信逻辑的实现位置,而非实际业务逻辑的实现位置。典型的如 MediaCodec 一类的音视频接口,实际的 Binder 调用封装都实现在 Native 层,我们使用 Java 调用这些接口,通过 BinderProxy.transactNative 也无法监控到实际的 Binder 调用。要实现包含 Native 的全局 Binder 调用监控,我们需要考虑 Hook 更下一层的 Native 的 transact 函数。

PLT Hook BpBinder::transact

和 Java 层的 Binder 接口设计类似,Native 层 Client 端发起的 Binder 调用,总是会走到 libbinder.so 中 BpBinder 的 transact 函数。注意到 BpBinder 的 transact 函数是一个导出的虚函数,而且使用上总是基于基类 IBinder 指针做动态绑定调用( 也就是说,其他 so 总是基于 BpBinder 的虚函数表来调用 BpBinder::transact,而不是直接依赖 BpBinder::transact 这一符号,而 BpBinder 的虚函数表在 libbinder.so 内部 ),因此我们直接 PLT Hook libbinder.so 对于 BpBinder::transact 的调用即可。

具体看下 BpBinder::transact 的函数声明:

    // NOLINTNEXTLINE(google-default-arguments)
    virtual status_t    transact(   uint32_t code,
                                    const Parcel& data,
                                    Parcel* reply,
                                    uint32_t flags = 0) final;

其中,status_t 实际上只是 int32_t 的别名,但 Parcel 则不是 NDK 暴露的接口,我们没有途径拿到绝对稳定的 Parcel 对象的布局,好在 transact 函数对于 Parcel 的使用是基于引用和指针的( 引用在汇编层面的实现和指针类似 ),我们不需要依赖 Parcel 对象的布局也可以实现一个 transact 的替代函数。

在成功拦截到 BpBinder::transact 的调用之后,我们还需要考虑怎么基于 transact 的调用参数和返回值来获取到我们需要的信息。

对于 Binder 对象( 即 transact 的隐含调用参数 this 指针 )本身,我们通常会关注它的 descriptor( 再结合 code 参数可以定位到实际 IPC 调用的目标逻辑 ),这里我们直接调用导出接口 BpBinder::getInterfaceDescriptor 即可。

    virtual const String16&    getInterfaceDescriptor() const;

比较麻烦的是 String16 也不是 NDK 暴露的接口,而且它用来转成 char16_t* 字符创的函数实现是内联的,

    inline  const char16_t*     string() const;

我们只能重新 hardcode 声明一个类似的 String16 类来做强转。好在从系统源码看,String16 的对象布局比较简单且稳定,只有一个 const char16_t* 类型的私有属性 mString,而且不存在虚函数。类似这样:

class String16 {
public:
    [[nodiscard]] inline const char16_t *string() const;
private:
    const char16_t* mString;
};

inline const char16_t* String16::string() const {
    return mString;
}

拿到 String16 对应的 char16_t* 字符串之后,我们直接在回调 Java 时用 JNI 接口将其转为 jstring 即可。

另一个常用的信息是 data 的数据大小。我们可以直接调用导出接口 Parcel::dataSize 来获取。注意到 transact 函数 的 data 参数是 Parcel 的引用,我们直接声明一个空类来承接 data 参数,再对拿到的 data 取址,让编译器可以正常完成引用到指针的转换即可。类似这样:

class Parcel {};

// size_t dataSize() const;
typedef size_t(*ParcelDataSize)(const Parcel *);

// virtual status_t transact(uint32_t code, const Parcel& data, Parcel* reply, uint32_t flags = 0) = 0;
status_t HijackedTransact(void *thiz, uint32_t code, const Parcel &data, Parcel *reply, uint32_t flags);

ParcelDataSize g_parcel_data_size = nullptr;
auto data_size = g_parcel_data_size(&data);

此外,注意到对于 Java 的 Binder 调用而言,会在 BinderProxy.transactNative 的内部再调用到 BpBinder::transact,我们可以结合 JNI Hook 和 PLT Hook 两个方案,对 Java 的 Binder 调用,基于 JNI Hook 拿到完整的 Java 参数,方便我们在 Java 回调中直接基于 Java 参数做进一步处理。

One More Thing

拦截到 Binder 调用只是监控的第一步,更为重要的是在这个基础上如何做数据处理来发现和定位问题。

前面提到的两类经典问题:IPC 耗时卡顿和传输数据过大崩溃,可以通过前后打点统计 transact 耗时以及调用前获取传输数据大小的方式来挖掘。 定位问题上,堆栈和当次 Binder 调用的 descriptor 和 code 是比较有价值的信息。

如果你还没有掌握Framework,现在想要在最短的时间里吃透它,可以参考一下《Android Framework核心知识点》,里面内容包含了:Init、Zygote、SystemServer、Binder、Handler、AMS、PMS、Launcher……等知识点记录。

《Framework 核心知识点汇总手册》:https://qr18.cn/AQpN4J

Handler 机制实现原理部分:
1.宏观理论分析与Message源码分析
2.MessageQueue的源码分析
3.Looper的源码分析
4.handler的源码分析
5.总结

浅谈 Android Binder 监控方案_第1张图片

Binder 原理:
1.学习Binder前必须要了解的知识点
2.ServiceManager中的Binder机制
3.系统服务的注册过程
4.ServiceManager的启动过程
5.系统服务的获取过程
6.Java Binder的初始化
7.Java Binder中系统服务的注册过程

浅谈 Android Binder 监控方案_第2张图片

Zygote :

  1. Android系统的启动过程及Zygote的启动过程
  2. 应用进程的启动过程

浅谈 Android Binder 监控方案_第3张图片

AMS源码分析 :

  1. Activity生命周期管理
  2. onActivityResult执行过程
  3. AMS中Activity栈管理详解

浅谈 Android Binder 监控方案_第4张图片

深入PMS源码:

1.PMS的启动过程和执行流程
2.APK的安装和卸载源码分析
3.PMS中intent-filter的匹配架构

浅谈 Android Binder 监控方案_第5张图片

WMS:
1.WMS的诞生
2.WMS的重要成员和Window的添加过程
3.Window的删除过程

浅谈 Android Binder 监控方案_第6张图片

《Android Framework学习手册》:https://qr18.cn/AQpN4J

  1. 开机Init 进程
  2. 开机启动 Zygote 进程
  3. 开机启动 SystemServer 进程
  4. Binder 驱动
  5. AMS 的启动过程
  6. PMS 的启动过程
  7. Launcher 的启动过程
  8. Android 四大组件
  9. Android 系统服务 - Input 事件的分发过程
  10. Android 底层渲染 - 屏幕刷新机制源码分析
  11. Android 源码分析实战

浅谈 Android Binder 监控方案_第7张图片

你可能感兴趣的:(移动开发,Android,Framework,android,移动开发,架构,framework,安卓)