本文讨论的只是32位Android应用中问题,不是每个应用都有这个问题,越是头部应用该问题越严重。(VSS = 虚拟内存占用大小)
0x04 背景
截止目前,国内市场仍有大量应用是32位架构,特征就是仅提供了 armeabi-v7a 的 so,Android 系统在启动此类应用的时候,通过一系列的判断,最终确定为 32 位应用的时候,会使用 32 位的 Zygote 进程孵化应用,整个应用会运行在 32 位兼容模式,Android 早在 5.0 期间就已经支持了 64 位 CPU,但是多年以来,大部分国内应用仍然运行在 32 位兼容模式,而 Google Play 在 19 年 1 月开始就强制要求开发者上传包含 64 位 so 的应用,保证应用运行在 64 位模式。
32 位的应用有一个众所周知的问题,就是虚拟内存的寻址上限只有 2^32=4GB,根据观察线上的 Crash 情况,可以发现 Native Crash 的 Top 10 中有大量的 libc abort,也就是信号 6。实际上信号 6 和其他的常见的几种信号不同,比方说 11 和 5 等等,信号 6 是主动调用的 abort() 抛出的,而这些 abort 中,有相当大的一部分是因为虚拟内存地址空间不足导致的,典型的特征就是在 Crash 堆栈中可以发现地址空间的总和接近4GB、Console log 中有大量的 (Out of memory)、malloc (xxxx) failed, returning null pointer 等等,想要解决这个问题,关键是解决虚拟内存不足,而 64 位应用的虚拟内存地址空间上限是 2^39=512GB ,所以目前该问题的唯一解法就是升级到 64 位,为什么是 512G 而不是 256T 读者可以自行研究,不在本文的范围。升级 64 位确实可以一劳永逸的解决该问题,因为 64 位带来的巨大地址空间除非bug的情况下,无法触顶,升级64位有两种办法:合包和拆包,合包会带来巨大的包大小压力,据 Google Play 的调研报告,包大小每增加6m会减少1%的下载率,而拆包需要一定的改造成本和维护成本,诚然终态一定是64位单架构,无论是出于技术探索还是用户体验来说,都有必要研究一下这个问题,让你的应用在不牺牲包大小的情况下平稳过度到64位架构。
0x08 探索
该问题在去年双十一前后被大家重视起来,各路大神纷纷下场投入了研究,也有很多阶段性结论,其中广为人知的两种观点:1. Android 10 的内存分配器存在bug导致内存泄漏,2. Jemalloc5.1(Android10的内存分配器)的脏页释放条件相比前代版本进行了修改,导致了内存的延迟释放,最终使得虚拟内存的水位居高不下。
这里简单的科普一下内存分配器:大多数情况下,我们写 Native 代码的时候,并不会直接调用内核的 API 去申请物理内存,而是使用 malloc 族的函数进行内存申请,这时候返回的指针是指向虚拟内存中的地址空间,之后在这部分地址空间真的被使用的时候,才会发生缺页中断触发真实的物理内存分配,所以通常是两层分配结构,用户态的代码申请的内存来自于内存分配器的二次分配,常见的内存分配器有 JeMalloc、TcMalloc、PtMalloc 等等,当然我们在写业务的时候并不需要关心内存是哪个内存分配器分配的,Android 9、10 使用的内存分配器均为 JeMalloc,静态链接在 libc 中,Android 11 使用的是更加高性能,更加安全的 Scudo 分配器。
针对前辈提出的分析,进行了一些资料查阅,在CSDN上看到一篇文章《jemalloc疑似内存泄漏分析》,里面提出的观点和上述文章中的分析比较接近,不过文中作者给出针对延迟释放的解决方案:1. 编译的时候修改编译参数(不可行,无法决定用户手机中的 Android 系统的编译参数),2. 将 dirty_decay_ms 设置为0,可以修改 Jemalloc 的脏页释放方式。经过查阅 Android 10 源码,发现 Google 在 Android 10 中,确实将 dirty_decay_ms 设置为0了,可知并不存在脏页释放延迟的问题。
也是就是说,结合目前的资料来看,JeMalloc 可能并没有什么问题,但是数据上展现出了一个奇怪的特征,就是 Top 1 的 crash 中,Android 10 和 11 的比例达到了惊人的 4:1 左右,难免会让人觉得这个问题是 Android 10 才突然出现的问题,那么 JeMalloc 多半是有问题,Android 11 换成 Scudo 就好了。这个结论是否正确按下不表,这里诞生了我的第一次尝试:是不是可以在 Android 10 中使用 Android 11 才有的 Scudo 分配器,不使用 JeMalloc 是不是就解决了这个问题?
0x0c 尝试1:移植 Scudo 到 Android 10
过程比较艰辛,花了差不多两周时间,主要是两部分工作:1. 把 Android 11 中的 Scudo 分配器源码拉出来单独编译成一个动态库依赖到我们的应用 2. 通过一系列 Hook 手段,将我们应用中的 Native 代码中涉及内存分配的函数替换成 Scudo 分配器中的分配函数
这个方案在实现时候发现存在一些问题:
1. 不只是 malloc 族函数会申请内存,strdup 和 strndup 这两个函数会在内部自己 malloc 一波,也要注意 Hook 掉
2. 安卓应用的 Native 内存申请不只是我们自己的 so 中的代码,还有相当一部分是安卓自己的系统库,虽然 Hook 系统库不是不行,但是会存在下面的一个致命问题
3. 在我们自行提供内存分配器的时候,会有两种case
a. 使用系统的 JeMalloc 申请的内存尝试在我们自定义的分配器中释放:可以判断一下如果不是我们申请的,调用 libc 的 free,如果判断是不是我们自己申请,这里不展开了,可以学习一下 Scudo 的源码,写的非常精致
b. 使用我们提供的分配器分配的内存,尝试使用系统的 JeMalloc 释放:无解,JeMalloc 才不会管这种奇葩情况,恭喜你,你会得到一个信号11,当然可以自己去处理 sgev,这么费劲有点没必要了
复制代码
在出现 3.b 的情况下,这种方案充满了不确定性,因为无法理论上证明可以100%覆盖所有的内存操作,你不能预料用户是怎么来申请内存的,达不到上线标准,替换掉 JeMalloc 的路子是行不通了
0x0c – 4 重新思考
其实这时候我又回头 review 了一下我们的历史数据,意外的发现,其实这个 abort 并不是 Android 10 突然才有的,之前的版本中也是存在的,捞了一下数据,发现和我们的用户设备版本是大致能对的上的,也同时对比了一下集团内其他应用的情况。这里没有结论,只是我的一个猜测:因为 Android 10 增加了 apex,什么是 apex 可以自行研究一下,导致 libc 的路径发生了变化,进而影响了堆栈聚合。
这时候探索进入了僵局,难道真的解决不掉么?尝试写个脚本分析一下地址空间中各部分的构成,实际上是存在普遍规律的,首先是 1GB 的虚拟机预占用的地址(开启了 LargeHeap,大部分应用都开启了 LargeHeap),然后是各种静态资源:字体、文件等等,还有就是显存、匿名申请内存等等,综合来看,各个部分似乎没有太大可以减少的可能,除了我们正常 malloc 的部分(libc:malloc) , 在虚拟内存触顶的时候,这部分通常会申请 1.5G 左右,使用了高德提供的内存泄漏分析工具进行多轮分析后发现,实际上 Native 部分内存泄漏并不多,也就是说大部分情况下 malloc 的内存,最终都 free 掉了,但是虚拟内存地址空间的占用水位仍然居高不下。既然不能缩小这部分的内存使用,那能不能增大虚拟内存的上限呢?文章背景中已经介绍过了,受限于 32 位应用的内存寻址范围,上限就是 4GB,虽然在兼容模式下运行 32 位应用可以几乎使用完全的 4GB,不过要是还想继续增加是不现实的,这之后我突然想到了一个脑洞大开的玩法:也就是第二次尝试,是不是可以把 Native malloc 的内存,分配到 art 预分配的那 1GB 中去呢,跟着 java 使用的内存一起分配进去,虽然最后发现这个方法行不通,但是也为最终的解决办法提供了大量的灵感和技术储备。
0x0c 尝试2:将 Native 申请的内存分配到 ART 虚拟机中去
这个方案比较脑洞,大家看个乐呵就行了,实际上没有可行性,尝试这个玩法的前提是对 ART 的内存分配足够了解,ART 中有好几种 GC,好几种内存管理的方案,早期的 DlMallocSpace、RosMallocSpace 到后来的 Bump Pointer,以及 Region Space 等等,这里唯一可能被外部分配的内存就是 DlSpace,因为 DlSpace 最终使用 DlMalloc 进行管理,DlMalloc 提供了全套 malloc 族的函数,所以说假如拿到了 DlMallocSpace 的实例,那么就可以使用 DlMalloc 的函数进行内存分配了,下图看下 DlMalloc 的函数定义:
这里面唯一的难点的就是怎么拿到 DlMallocSpace 这个成员变量,如果有逆向经验的同学,对于如何破解art的namespace隔离机制、如果通过偏移找到成员变量在内存中的位置、如何通过符号找到内存中的函数应该是比较熟悉的,这里也不多展开了,写一写又是一篇文章了,大家可以自行查找资料学习,总是就是一顿操作,拿到了 DlMallocSpace 这个实例在内存中的指针,用以上 DlMalloc 的函数就可以愉快的分配一下了,结果可想而知,分配是没啥问题的,但是 Android 8 以后 ART 里面只有 LOS 使用的是 DlMallocSpace 了。。。所以上限就是 64M,没有啥折腾的必要。
0x10 最终解决方案
以上的两种方案最终都没有什么卵用,不过我还没有放弃,很快又发现了一种解决方案,既然不能把 native 的内存分配到 art 虚拟机中去,那么干脆把这部分地址空间让出来好了,交还给操作系统之后,不需要对 native 的内存分配进行任何修改和hook,即可让libcmalloc使用这部分地址空间,堵不如疏,可以极大的缓解虚拟内存不足的问题。
那么问题的重点就是如何让 art 把这部分地址空间让出来,首先说一下为什么要压缩这部分地址空间:通常情况下,我们的应用都会开启 largeHeap,来获得更大的内存上限,因为默认可用的空间只有 192M,这是由参数 dalvik.vm.heapgrowthlimit 决定的,对于大部分集团应用来说显然是不够的,但是开启 largeHeap 之后实际可用则可达到 512m = 1024 / 2 ,这也导致了应用启动的时候就会申请 1gb 的地址空间,同样对于大部分应用来说,直到 abort 或者应用被杀死,都不会使用如此多的地址空间,实际上我们需要的是一个 middleHeap,那么我们接下来看看如何压缩这部分地址空间
要想操作这部分地址空间,自然要研究一下 Android 是怎么管理这部分地址空间的,上文中提到 Android 实际上存在多种地址空间管理办法,目前的8+中都是通过 Region Space, 具体细节大家可以自行查阅 Android 源码,那么问题就明确成了如何调整 Region Space 的大小,这里需要一些前提,Region Space 之所以一经启动就会占用地址空间,显然是通过 mmap 拿到的,那分配结果自然会保存在某个成员变量上,具体怎么找到它?自然是去阅读源码找到答案:region_space_ 保存在 Heap 实例上,而 Heap 实例则保存在 Runtime 上的 heap_,而这些属性都是最终编译在 libart.so 中,所以要想操作这些,前提是至少拿到 libart.so, 这里会遇到第一个问题,就是 Android 中存在一个 namespace 隔离机制,保证无法跨命名空间加载其他 so,本意上不是为了防止大家胡乱加载,而是防止 Android 碎片化不断加剧,减少应用层对于底层的依赖,下面说一说怎么破解这个 namespace 隔离机制,这也是破解 hiden_api 限制的关键
最终需要 ready 一个逻辑链条:
libart ——> runtime_ ——> heap_ ——> region_space_
首先要确定一下 namespace 隔离机制是怎么实现的,可以通过阅读源码了解一下 Android 中 so 是怎么加载的,要看到最后下层,不是 dlopen 和 android_dlopen_ext, 最终会使用到 libdl 的 __loader_dlopen,这个 dlopen 和我们常见的 dlopen 似乎有点区别,他多了一个参数,也就是 caller_addr,这是一个函数指针,再往下看就发现原来他是通过拿到调用者之后去查表,找到 namespace,并且和要加载的目标 so 的 namespace 进行对比,看看是否是互相可见的 namespace,来确定是否允许加载的,到了这里想要破解这个隔离机制就易如反掌了,需要两样东西:1. 拿到这个三个参数 __loader_dlopen 2. 拿到一个和目标 so 相同 namespace 的函数指针
怎么拿到这个函数以及虚假的 caller_addr 不在今天的文章范围内,可以自行研究,解析 ELF 即可。通过同样的套路,就破解了 dlsym,这样可以完成 libart.so 的加载,其实到此为止,一切骚操作的前提已经具备了,接下来就是通过找到 region_space_ 相对 heap_ 的偏移,一环套一环找到所有需要的条件,可能有同学会问,到底怎么调整 region_space_,其实也非常简单,Google 的老铁 lokeshgidra@google.com 在 2018-02-07 AM10:01 的一次提交已经帮你实现好了,那就是 RegionSpace::ClampGrowthLimit(size_t new_capacity) 也就是说,其实在 >= 9 的 Android 版本中,找到 runtime_,heap_, region_space_ 之后,直接调用 ClampGrowthLimit 就可以调整 Region Space 大小啦~ 而在 <= 8 的版本中,是没有这个方法的,不过这也难不倒大家,他这个方法中的实现,其实都是在使用已有的函数or属性,自己参照汇编和反编译的结果实现一下就好了,没有太大的难度,这里可能有同学会问:如果依赖偏移会不会有很大的适配工作,额,实际上国内厂商对 art 的修改很少,参照 aosp 的版本就 7788 了,因为本来也不需要保证 100% 都能对的上,对不上就对不上好了,自己做好保护,通过 longjump 等方式做一个 try catch 即可。不过还是需要注意一下 samsung 的全系列都对 art 有修改,加了一两个变量。。。
其实如果只是通过偏移拿到指针仅仅是 read 并不会产生任何异常,只要不进行操作:取内容,调函数等等,所以在最终的操作出做一个 sgev 的保护即可,理论上可以保证100%的稳定性,因为就算前序链路中失败了,相当于不生效而已,并不会带来副作用。
剩下的就比较简单了,上层业务做一个定时轮询,设定一个阈值,每当虚拟内存达到 n% 的时候,就对 art 的 Region Space 进行一下压缩,设置好步长,不要一下子干太猛,容易 java oom,自己保证好下限即可。把以上逻辑封装起来,辅以相关的检查和调度,输出一个SDK,就可以治疗虚拟内存不足导致的 abort,经过两轮灰度发现,相比于上一个 32 位版本,abort 出现了大幅度的下降,在全面升级 64 位之前可以稳一点了。
0x14 接入方式
以上解决方案已经全部开源到 Github :github.com/alibaba/Pat…
使用方式非常简单,只需要一行代码在合适时机初始化即可~ 第二个参数一个配置对象,有一些数值可以自定义,自行研究即可。
com.alibaba.android.patronus.Patrons.init(context, null);
复制代码