字节 Android Native Crash 治理之 Memory Corruption 工具原理与实践
作者:字节跳动终端技术——庞翔宇
内容摘要
MemCorruption 工具是字节跳动 AppHealth (Client Infrastructure - AppHealth) 团队开发的一款用于定位野指针(UseAfterFree)、内存越界(HeapBufferOverflow)、重复释放(DoubleFree)类问题检测工具。广泛用于字节跳动旗下各大 App 线上问题检测。本文将通过方案原理和实践案例来介绍此工具。
背景
随着 Android App 开发的技术栈不断向 Native 层扩展,带来的线上 Native 稳定性问题日趋严重。Android 中有超过半数的漏洞都来源于 Memory Corruption 问题。分析定位线上此类问题的难点在于,首先线下难复现,其次问题发生时已经不是第一案发现场,且此类问题调用栈表现类型多样化。这就导致了此类问题短期内难分析、难定位、难解决的现状。
什么是 Memory Corruption 问题
UseAfterFree
UseAfterFree 下面简称 UAF,野指针类问题;
这里以 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,堆内存二次释放类问题;
同一块堆内存地址多次释放问题,在实际开发中会有这样的场景,A 线程某个 C++类 X 申请了一块堆内存,将内存地址传递给 Y 类方法使用,使用后通过析构函数释放,B 线程中申请同样大小的内存,申请到了这个已经释放的地址,此时 A 线程的 X 类执行析构函数释放对应内存。
HeapBufferOverflow
HeapBufferOverflow 下面简称 HBO,堆内存越界类问题;
堆越界问题就更容易理解了,这里不再赘述。
工具现状
业界有很多优秀的工具用于 Memory Corruption 问题分析,如 Asan(Address Sanitizer)、HWASAN、Valgrind 或 Coredump 等。但由于兼容性、性能功耗、接入成本过高、系统限制等因素导致这些工具无法在 Android App 客户端线上大规模使用。因此难以定位大规模用户场景下的复杂问题。
工具对比:
字节方案
能否开发一个线上检测 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 与小程序监控限时不限量,欢迎免费接入试用。
点击链接进入官网查看更多产品信息。
版权声明: 本文为 InfoQ 作者【字节跳动终端技术】的原创文章。
原文链接:【http://xie.infoq.cn/article/04f4d53ce27deb771beb88f89】。文章转载请联系作者。
评论