字节Android Native Crash治理之Memory Corruption工具原理与实践

4,853 阅读11分钟

作者:字节跳动终端技术——庞翔宇

内容摘要

​ MemCorruption工具是字节跳动AppHealth (Client Infrastructure - AppHealth) 团队开发的一款用于定位野指针(UseAfterFree)、内存越界(HeapBufferOverflow)、重复释放(DoubleFree)类问题检测工具。广泛用于字节跳动旗下各大 App 线上问题检测。本文将通过方案原理和实践案例来介绍此工具。

背景

​ 随着 Android App 开发的技术栈不断向Native层扩展,带来的线上Native稳定性问题日趋严重。Android中有超过半数的漏洞都来源于Memory Corruption问题。分析定位线上此类问题的难点在于,首先线下难复现,其次问题发生时已经不是第一案发现场,且此类问题调用栈表现类型多样化。这就导致了此类问题短期内难分析、难定位、难解决的现状。

什么是Memory Corruption问题

UseAfterFree

UseAfterFree下面简称UAF,野指针类问题;

void HeapUseAfterFree() {
  int *ptr1 = (int*)malloc(4);
  if(ptr1 != NULL){
    *ptr1 = 0xcccc;
    free(ptr1);           //free ptr1           
    *ptr1 = 0xabcd;       //free后write ptr1 mem这里不会崩溃
  }
}

​ 这里以UAF问题说明Native崩溃后不是第一现场的场景。假设上面代码运行在线程A,第2行申请4byte大小的一块堆内存,第5行释放这块堆内存,执行第6行前线程A时间片执行完,切换到线程B执行,线程B此时申请4byte大小的内存块,内存管理器会概率性的分配之前已经释放的ptr1指向的内存块分配给线程B使用,线程B给ptr2指向内存赋值0xff,之后线程B时间片执行完让出CPU,切换线程A执行,ptr1被赋值0xabcd,之后切换回线程B进行条件判断,ptr2内存值不为0xff触发异常逻辑。不是线程B预期的值。这样的场景在大型的App程序运行过程中时有发生。

DoubleFree

DoubleFree下面简称DF,堆内存二次释放类问题;

void DoubleFree() {
  int *ptr = (int*)malloc(4);
  free(ptr);
  free(ptr);
}

​ 同一块堆内存地址多次释放问题,在实际开发中会有这样的场景,A线程某个C++类X申请了一块堆内存,将内存地址传递给Y类方法使用,使用后通过析构函数释放,B线程中申请同样大小的内存,申请到了这个已经释放的地址,此时A线程的X类执行析构函数释放对应内存。

HeapBufferOverflow

HeapBufferOverflow下面简称HBO,堆内存越界类问题;

void HeapBufferOverflow() {
  char *ptr = (char*)malloc(sizeof(char)*100);
  *(ptr+101) = "aa";
  *(ptr+102) = "bb";
  *(ptr+103) = "cc";
  *(ptr+104) = "dd";
  free(ptr);
}

堆越界问题就更容易理解了,这里不再赘述。

工具现状

​ 业界有很多优秀的工具用于Memory Corruption问题分析,如Asan(Address Sanitizer)、HWASAN、Valgrind或Coredump等。但由于兼容性、性能功耗、接入成本过高、系统限制等因素导致这些工具无法在Android App客户端线上大规模使用。因此难以定位大规模用户场景下的复杂问题。

工具对比:

20211104-143725.png

字节方案

能否开发一个线上检测Memory Corruption类问题的工具?答案是肯定的。

开发前首先要明确需要解决那些问题。

  • 需要解决的问题如下:

    • 兼容性强、性能开销低、内存消耗小、稳定性高;
    • 栈回溯高效且准确,需要记录线程信息、内存分配大小和内存地址信息;
  • 功能可配置化管理,方便线上线下使用,接入成本低;

  • 用户无感知检测,发生异常时不触发崩溃;

​ 主旨思想是对App申请和释放的内存进行统一管理,达到对内存分配和释放的监管。由于内存申请释放非常频繁,如果监控所有内存并记录想要的信息,会对性能造成影响,所以工具通过mmap来申请一块内存,自己维护管理。内存申请策略根据随机采样分配,命中采样规则后,通过工具管理的内存池进行分配和释放,并对内存访问权限进行控制,在分配的内存块前后添加隔离区,对释放后的内存设置为不可读写权限,并标记内存状态。通过一个数据结构来记录线程信息、线程栈帧、记录当前内存块状态达到检测的目的。同时通过线上动态下发配置方式实现可配置化管理。

Hook工具选型

​ 定位Memory Corruption类问题,首先要Hook内存申请和释放的相关函数,达到对内存监控。这里涉及到Hook方案的选型,线上首先需要考虑的是高效稳定、兼容性好。

常用的线上Hook工具类型如下:

​ 从工具对比看,经过大量实验,首选dispatch table hook,因为malloc/free相关函数非常高频使用,hook dispatch table方式高效稳定,性能影响小,线上可以大规模开启。hook原理主要是找到dispatch表地址,替换表中malloc相关函数地址就可以达到hook malloc相关函数的需求。因为是hook callee,所以不用考虑hook增量库的问题。同时Google/LLVM在对malloc进行代理处理时就是使用这种方式。针对Memory Corruption类问题往往都是小内存申请释放(4k以内)造成的问题,所以暂时不需要hook mmap相关函数。兼容方面我们适配了Android5.x~11。

栈回溯方案选型

​ 安卓上的栈回溯标准种类繁多,通过调研比较主流栈回溯方案。

​ 通过比较和实验,在Arm64上我们选用了fp的方式来进行栈回溯,Arm64 设备帧指针默认是开启状态,且通过实验观察线上App中64位的so没有关闭帧指针,且fp方式栈回溯几乎不耗时,通过实际测试,15层栈帧回溯平均在1~2μs,其他栈回溯基本都在ms级别。

​ 对于 Arm32 设备,帧指针默认是关闭状态。所以在Arm32设备无法通过fp栈回溯方式记录App的内存分配和释放流程。我们对libunwind_stack进行了优化,因此在Arm32下我们选择libunwind_stack来实现栈回溯,来达到记录分配和释放堆栈轨迹。

双采样内存配置策略

​ 对于现有Memory Corruption类问题监控,往往是通过注入、插桩方式监控所有内存申请和释放,而用户使用中一次滑动事件,App程序都会申请释放数千到数万次。叠加栈回溯能力,会对被监控程序造成严重的性能影响,导致用户体验变差,出现卡顿等问题。

​ 针对这类问题,这里采用双采样机制来控制用户数与客户端内存分配数的方式。双采样是指服务端发送配置文件采样和客户端针对内存分配进行随机采样分配管理的方法,来对内存分配和释放进行监控**。**服务端配置文件采样是通过服务端设置用户采样比,按照不同问题类型、版本、机型等策略来进行按比例采样;客户端随机内存分配采样是通过端上随机分配采样算法来实现。这样对用户量和端上监控内存数就可进行随机内存分配配置化管理。

无感知检测

​ 当发生Memory Corruption类问题时,正常是会触发SIGSEGV类型崩溃。要做到用户无感知,就不能让程序发生崩溃退出。这里我们的做法是通过注册SIGSEGV信号处理函数,当受控内存块被释放后会设置为不可读写权限,当发生异常时有代码访问不可读写的这块内存就会触发SIGSEGV,进入信号处理函数,在信号处理函数中,先确定当前发生异常的地址是否在我们管理的内存池中,如果是我们管理的内存段触发的异常,通过恢复对应内存段的读写权限。来保障在信号处理流程中不触发程序退出流程。达到用户无感知检测Memory Corruption类问题。如果触发SIGSEGV的内存地址不在我们管理的内存段中,就转发信号给原有的信号处理函数处理。

方案流程

方案优缺点

  • 优点

    • 线上线下可用,接入成本低,依赖aar组件初始化即可,无需额外操作;
    • 可配置化管理,通过云端下发配置,动态开关功能;
    • 内存分配采样管理,内存池线上控制在100KB~8MB,总内存开销在700KB~8.6MB;

  • 缺点

    • 监控内存块大小,最大4kb;
    • 对非堆内存导致的崩溃问题无法检测;
    • 暂时不支持ios/x86,后期可支持;
    • 不支持Android4.4及以下版本,后期可支持;

线上效果与案例分析

​ MemCorruption工具在字节多个App上线后,目前发现各类基础库Memory Corruption问题200+。通过工具已定位解决问题30+。

案例1、UseAfterFree问题

​ 日志记录信息,异常栈、Free栈、Alloc栈。Abort msg会记录内存分配大小,Free栈和Alloc栈记录分配和释放的线程信息。通过这些信息可以知道一块内存的分配和释放情况。结合源码即可定位问题。

​ 下面是线上检测字节头部业务SDK有UAF问题,Abort msg信息可知是UAF问题、申请内存大小256byte,访问内存0x7a25a28b00偏移240byte时触发UAF检测。这里也就是访问了一个结构体变量的成员变量时发生了UAF问题。说明对应结构体变量被释放后又使用。

  • 异常栈与Abort msg

触发检测代码逻辑

  • Free栈信息

​ 通过Free内存块栈信息,可以确定是11170线程释放了对应内存,结合代码可定位释放内存块变量m_pDefaultFilter,而m_filterType是m_pDefaultFilter的成员变量。

Free对象内存代码

  • Alloc内存信息

​ 通过上面信息我们很容易能判断出是因为m_pDefaultFilter实例对象已经被释放,之后访问其成员变量m_filtertype时内存已经释放,就会触发UAF检测。MemCorruption工具比传统的Tombstone只有一个异常栈的情况下,对分析问题更清晰,且抓到的是问题第一现场。缩短研发同学对问题排查时间,以提升问题处理效率。

案例2、DoubleFree问题

  • 异常栈与Abort msg

  • Free栈信息

  • Alloc栈信息

​ 从上述信息可知,libbinder库中存在double free异常,free有两条链路可以释放Parcel类的mData或mObjects对象。

链路一:在java层调用recycle-->freebuffer--...-->freeData-->freeDataNoInit-->free,在freeDataNoInit中会free mData和mObjects两个对象;

链路二:在java层调用writeString--> nativeWritexxx-->writexxx16-->writexxx-->continueWrite-->realloc-->free;

continueWrite和freeDataNoInit代码在Parcel.cpp且没有保护,对于mData和mObjects对象的生命周期存在并发导致的多次释放问题。结合异常栈信息,业务代码做保护修复。

总结

​ Memory Corruption问题是C/C++开发人员避不开的问题。MemCorruption工具原理并不复杂,在大规模用户场景下,通过采样方式监控内存分配和释放,发现问题不触发程序崩溃,能够有效的发现线上低概率、边缘场景引发的Memory Corruption问题。减少App程序漏洞、提升App稳定性。

​ MemCorruption工具只是字节治理线上Memory Corruption类问题的一个点。还有很多的方面需要完善。请持续关注字节跳动终端技术团队,后续更加精彩。

后续计划

​ MemCorruption工具为了不影响线上App性能,对内存监控范围做了限制,后续我们会扩展这部分的能力。同时iOS中也存在Memory Corruption类问题,iOS版本敬请期待。

​ 此工具未来将在 APMPlus 中上线,APMPlus 是字节跳动应用开发套件 MARS 下的性能监控产品,通过先进的数据采集与监控技术,为企业提供全链路的应用性能监控服务,解决企业对各端监控的需求。具备非侵入式监控、丰富的异常现场还原能力,助力企业提升异常问题排查与解决的效率、优化应用品质,以降低成本提高收入。

​ 目前 APMPlus面向新用户提供试用30 天的限时免费服务。其中包含 App 监控、Web 监控、Server 监控、小程序监控,App 监控和 Web 监控各500 万条事件量, Server 与小程序监控限时不限量,欢迎免费接入试用。

点击链接进入官网查看更多产品信息。

参考文献: llvm.org/docs/GwpAsa…