解读HWASan日志

HWASan不是一个新颖的话题,事实上早在4年前我就写过它。这次再写自然不是炒冷饭,而是基于以下两个原因:

  1. 从Android 14开始,应用可以很方便地使用HWASan了。应用开发者和HWASan在早年间基本是两条平行线,想要用上它是很困难的事。这主要是因为开启需要HWASan版本的libc.so而这需要重编libc.so,这种系统层面的调整对于开发者而言自然难以办到(可以在ci.android.com中寻找HWASan的版本来烧,但这对于非Pixel的手机也很困难)。不过从Android 14开始情况有了改变,Google在23年4/5月份合入了几笔"unbundled HWASan"的改动,unbundled表示拆分,这意味着HWASan的使用不再需要系统重编。如今应用开启HWASan已经变得十分简单,具体可以参考官方文档。因此当下这个时间点再讲HWASan,主要是针对应用开发者说的。

  2. HWASan的错误提示比较复杂,尤其是某些场景下的输出比较模棱两可。这几年,同事或朋友来问我HWASan日志怎么看的问题已经不下5次了,所以我希望写一篇使用指南,和官方指导不同的是,这里不会停留在"什么输出对应什么问题"的表象,而是结合设计初衷理解为什么要这么输出。4年前写HWASan时主要依赖的材料是一些网页,但这对于深入理解还远远不够。因此在写这篇文章之前,我将HWASan的源码研究了一遍,确保文中所述皆有据可查。

好了,结束啰嗦的前言,下面让我们以一个实际的例子展开讨论。

HWASan检测到错误后会直接abort,SIGABRT(信号6)会导致进程崩溃并生成tombstone文件,因此以上信息均来自/data/tombstones/tombstone_xx文件。为了更加清晰,我将上面的信息分为8个部分,依次阐述。

(一) 首先是"tag-mismatch"的错误提示,它并非问题的根因,而只是abort的直接原因。对HWASan来说,这种直接原因有三种:

  1. tag-mismatch:所有的内存访问指令(ldr/str指令)都有HWASan插桩的检测,而检测的内容就是tag是否一致,当指针的tag和内存的tag不匹配时报出此错误。

  2. invalid-free:除了内存访问指令外,进程还可以通过malloc/free/realloc之类的标准API和内存互动。其中内存释放动作(free/realloc)也会有检测,检测的内容既包括tag是否一致,也包括指针是否异常等,检测到错误报为invalid-free。

  3. allocation-tail-overwritten:该错误也发生在内存释放检测中,和上面错误不同的时,此时的指针tag和内存tag是一致的,且指针也无异常。既然如此,怎么还说是错误呢?这里要提一下HWASan的使用模式。进程中的ELF文件可以分为两种,一种是可执行文件,另一种是诸多的共享库。而HWASan的开启是在编译期间,因此我们既可以为整个系统(可执行文件和所有的共享库)开启HWASan,也可以只为可执行文件和部分共享库开启HWASan。在部分共享库开启HWASan的场景中,假设未开启HWASan的库如下所示申请了一段内存,长度为24个字节。那么在这个库里发生的内存踩踏是无法被检测到的,因为它未开启HWASan,也就意味着它的内存访问并没有被HWASan插桩。但是别急,HWASan还留了一手。由于HWASan的内存申请要求16字节对齐,因此即便我们只需要24个字节,但实际上分配出来的是32个字节。尾部的8个字节刚好可以被拿来用于检测,除去最后一个字节用于short granule(此处按下不表,后文有详细介绍),剩下的7个字节填入系统已知的魔数。这样一来,当这块内存释放时,便可以检测这7个字节是否是我们先前填入的魔数,如果不匹配,则表示内存已经被踩踏,且踩踏发生于未开启HWASan的库里。因此碰到"allocation-tail-overwritten"的报错,我们通常无法得知问题的根因,而是需要为更多库开启HWASan方才能抓到有效信息。

(二/三) address表明此次内存访问出错的地址,而pc则表示程序运行的位置,它值得解读一下。从#0帧的信息里可以看出,该pc值相对于文件的偏移为0x7eaac,对应如下汇编代码的bl跳转指令。

assembly 复制代码
7eaa0: adrp	x20, 0x10e000 <dlsym+0x10e000>
7eaa4: mov	x19, x0
7eaa8: ldr	x20, [x20, #0xd90]
7eaac: bl	0x516c4 <__hwasan_check_x0_2_short_v2>
7eab0: ldr	w0, [x0]

等等,不是说内存访问导致的问题么,这里怎么解析出来的是一个跳转指令?原因是检测指令通常插桩于访问指令之前,而这里的bl则用于跳入到具体的检测里。跳转过去的代码块也是HWASan编译时生成的,这些代码块的名称就比较有意思了。__hwasan_check_x0_2_short_v2中的x0表示检测的是x0寄存器,2(0b00010)表示此次访问是读访问(第5个bit为0,为1则表示写访问),且访问的长度为1<<2=4字节,short_v2表示需要检测short granule。它对应的真实访问刚好是下一条指令:ldr w0, [x0],这和第三部分的"READ of size 4"是匹配上的。

(四) 我们真实拿到的访问地址是0x89003f077f1980,最高位的0x89就是ptr tag。而通过这个地址访问的内存,它的memory tag存在于对应的shadow memory中,取出为0xa6。二者不匹配,因此报错。

在tags和调用栈之间,有的案例会打印额外的一行:Invalid access,如下图所示。

这主要针对的是部分访问错误的情况,常见于heap-overflow。此话怎讲?一次访问并非访问单个字节,而是一串字节。这其中,有些访问是合法的,譬如上图的前4个字节;而有些访问是非法的,譬如上图的后4个字节。因此这里输出的offset就是为了进一步细化,告知我们非法的访问是从哪个偏移开始的。

(五) 如果错误地址属于heap的话,将会输出该地址所属内存块的基本信息。当HWASan开启后,scudo/jemalloc这些内存分配器就靠边站了,所有的内存管理将有HWASan接管,而其分配内存的方式和scudo如出一辙。大体上来说,小内存交给primary allocator管理,大内存交给secondary allocator管理。而primary allocator内部又细分了众多class,每种class负责一种大小的内存块。譬如说,class 1专门分配32字节的内存块,class 2专门分配64字节的内存块,以此类推。因此这里输出的正是该内存块的基本信息。但需要注意的是,即便我们申请48个字节,HWASan依然会给我们返回64字节的内存块,只不过其中只有48字节可合法使用。而这里打印的依然是64字节的内存块信息,如例子所示。Offset代表错误地址在这块内存块中的偏移。

(六) 这里的"use-after-free"是HWASan结合诸多信息综合判断的问题的根因,判断的逻辑如下:

图中的信息比较精简,主要是为了展示判断的时序和逻辑,下面来详述:

  1. is shadow memory:这种情况比较少见,表示当前出错的地址落在shadow memory里。要知道shadow memory和正常memory隔得还挺远的,想要踩到实属不易。如果出现这种情况,大概率判断是wild access,譬如程序人为计算了一个指针来访问。

  2. stack tag-mismatch:错误地址落在栈上。至于到底是栈里的out-of-bounds还是use-after-scope,这需要so的调试信息做进一步判断,因此通常HWASan的日志里不再做更进一步的归因。不过这里有个技巧,我们看memory tags的信息,如果发现错误地址的tag前后都是0,那么大概率是use-after-scope错误,反之则为out-of-bounds错误。这是因为use-after-scope发生在调用返回之后,因此之前的栈上空间全部被释放,其所对应的tag都会被置为0。

    ini 复制代码
    [use-after-scope]
    =>0x007dff15def0: 00  00  00  00  00  00 [00] 00  00  00  00  00  00  00  00  00
    [out-of-bounds]
    =>0x007bfd0d3400: cf  cf  cf  cf  cf  cf  cf  cf  cf  cf [00] 00  00  00  00  00
  3. heap-overflow/global-overflow:它们有很多相似之处,因此可以并案处理。

    简言之,overflow的判断就是"左看看,右看看",在错误地址的周边寻找和ptr tag一致的内存块。一旦找到,就表明这个地址是当时申请那块内存得到的,我们本应该在那块内存里"游荡",如今却越界到其他内存里了。如果找到的内存块紧邻ptr,我们称它为close candidate,这种时候overflow的概率是极大的。如果找到的内存块并非紧邻ptr,我们也将它记录在案,但这种情况overflow的概率就没那么大,因为很少有人跳着去越界。"左看看,右看看"并不能无限地去检查,HWASan里设定的是左右各看1000个tag,换算成内存空间的大小是16000 bytes。

    说完二者的共同点,再看看它们的不同。heap-overflow要求错误地址落在进程的heap里,而global-variable则要求地址落在共享库中,具体而言,已经初始化的全局变量落在.data区,未初始化的全局变量落在.bss区。

  4. use-after-free:HWASan为每个线程都创建了长度为1023的ring buffer,用于在free时记录内存块的信息。在归因阶段,HWASan会拿着错误地址去每个线程的ring buffer中搜查,一旦发现tag相同且地址匹配的内存块,就会报出use-after-free的错误。不过这种设计会有漏检的存在,一是错误发生在free线程退出之后,而搜查只会在存活的线程里进行;二是当初free的记录被挤出了ring buffer,这种时候就要增大ring buffer的大小,应用可以在wrap.sh中增加HWASAN_OPTIONS=heap_history_size=8095来达到这个目的。

  5. can't describe address:当以上原因都没有被命中时,HWASan会输出这句话。这种时候多半是use-after-free的漏检。

(七) 当use-after-free从线程的ring buffer中找到匹配的内存块信息后,这里便会打印该内存块的信息。和(五)打印不同的是,这里的size指的是申请的大小,而非allocator分配的大小。具体而言,class 2里分配的大小都是64字节,但使用者只申请了48字节,因此(五)里的大小是64,而这里的大小是48。

(八) 这里打印的是内存块之前申请和释放的调用栈,它们对于根因的定位至关重要。但这篇文章不会停留在"什么是什么"的浅层表述上,而是再深究一个问题:这些调用栈是如何存储的?

这个问题看起来挺无聊的,但对于有一类人群会有价值,因为他们在App中也有频繁收集存储调用栈的需求。收集(unwind)是另外一个话题,暂不讨论,这里仅讨论存储。为每一次收集都创建一个存储,这显然是很差的方案,因为调用栈们很容易雷同。那有没有其他方案呢?这里展示HWASan所采用的方案,它不一定是最优的,但起码是合理的,可供参考。

首先将调用栈的信息处理成64bit的哈希值,这样可以快速查找。之后根据哈希值从tab数组中取出32bit的N。这个32bit的最高位用于加锁,所以接下来用于寻址的只有31bit,其对应的索引范围为2G。拿着这个索引去元素为StackDeoptNode的列表中查询,该列表具有两级,第一级长度为32768,它在一开始就创建;第二级长度为65536,它在运行时按需创建。查询到的元素为StackDeoptNode,这个类里有三个字段:

  • stack_hash:和最初的hash对比,如果一致,则表明StackDeoptNode和存入的调用栈匹配,因此不用再创建新的存储。
  • link:考虑到tab数组的长度只有65536,因此不同hash值可能会计算出相同的索引。这里采用的解决方案就是让一个索引下面的StackDeoptNode呈现链表结构。而link就是下一个节点的索引值(在TwoLevelMap中索引)。
  • store_id:这个值将去StackStore中索引,得到最终的调用栈信息。

StackStore和TwoLevelMap类似,依然采用两级列表的设计,这里不多赘述。而最终代表调用栈的是上图中的"N"值,它会在allocate时存入内存块的meta data中,也会在deallocate时存入线程的ring buffer中。

综上,可以看到HWASan在查询速度和内存占用之间做了很好的平衡,且占用的内存也尽量按需动态分配,进一步控制了内存的增长。不过我从源码中并没有看到调用栈内存的回收动作。

日志到此还没结束,下面继续。

(九)hwasan_dev_note开头的有三行打印,这里只截取了一句,是因为其他两句实在不太重要。这里的1表示找到的free调用栈在线程ring buffer中的序号,而1023则表示ring buffer的大小。根据这个值,我们也可以在调整ring buffer大小时确认是否成功。

(十) 这里输出的是线程的stack范围和thread local storage范围,一般来说没啥用。

(十一) 这里是错误地址对应的shadow memory,它里面存储的全是memory tag,信息十分丰富。红框内的a6有三个,由于每个tag对应16字节的内存,因此可以知道这块内存的可访问空间是16×3=48字节。另外我们可以观察,周边tag最多重复4个,这是因为当前class为class 2,它分配的内存统一为64字节,对应的刚好是4个tag。分配64字节,你可以只用44个字节、48个字节,但你无法使用70个字节。如果你想使用70个字节,那么HWASan会将它分配到class 3。再下面有"Tags for short granules"的信息,granule的意思是颗粒,short granule则表示16字节中只有部分可访问。

我们以一个实际的例子来具体阐述。

08是我们从shadow memory中读出来的tag,它并非表示真实的memory tag,而是表示16个字节中只有前8个字节可访问。至于真实的memory tag,由于16个字节后8个字节无人使用,因此可以用最后一个字节来存储它。这里读出来的值是0x30,和前面的内存tag吻合。

日志还剩下最后一个部分。

(十二) 由于HWASan在检测错误到最终abort之间会运行很多代码,因此错误最初发生时的寄存器早已被破坏。而这些寄存器对于理解错误逻辑和恢复变量值都很有价值,因此HWASan会记录下来,并输出在这里。

十二之后便是正常的abort调用栈,由于它并非第一现场,因此价值不大,不再讨论。

后记

老实说,这篇文章是吃力不讨好的,因为我知道它的受众并不多,但我还是对着源码一点一点把它梳理下来了,花费的时间也比一般的文章要多。从产出的结果来看,这自然是事倍功半。但到底什么才是有价值的事呢?这个问题在生活中也经常困扰着我。面向升职写作?抑或面向流量写作?其实我也羡慕那些动辄几百点赞的文章,但我终究写不来,也写不了那些文章。这让我想起了季羡林先生晚年耗时17年写成的《糖史》,显然这并非显学,但其在学术圈却有着重要的地位,其严谨的治学态度也时常激励着后人。我想,要是能学到季老的一点皮毛,把所接触的技术清晰准确地梳理下来,也算是有点价值的罢。

相关推荐
我想吃余37 分钟前
【Linux修炼手册】Linux开发工具的使用(一):yum与vim
linux·运维·学习·vim
Android 小码峰啊39 分钟前
Android Compose 框架物理动画之捕捉动画深入剖析(29)
android·spring
bubiyoushang88840 分钟前
深入探索Laravel框架中的Blade模板引擎
android·android studio·laravel
cyy29841 分钟前
android 记录应用内存
android·linux·运维
言之。1 小时前
基于 Ubuntu 24.04 部署 WebDAV
linux·运维·ubuntu
CYRUS STUDIO1 小时前
adb 实用命令汇总
android·adb·命令模式·工具
这儿有一堆花2 小时前
安卓应用卡顿、性能低下的背后原因
android·安卓
byte轻骑兵2 小时前
【Bluedroid】蓝牙HID DEVICE断开连接流程源码分析
android·c++·蓝牙·hid·bluedroid
Edward Nygma2 小时前
springboot3+vue3融合项目实战-大事件文章管理系统-更新用户密码
android·开发语言·javascript
前进的程序员2 小时前
ARM 芯片上移植 Ubuntu 操作系统详细步骤
linux·arm开发·ubuntu