HWASan不是一个新颖的话题,事实上早在4年前我就写过它。这次再写自然不是炒冷饭,而是基于以下两个原因:
-
从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,主要是针对应用开发者说的。
-
HWASan的错误提示比较复杂,尤其是某些场景下的输出比较模棱两可。这几年,同事或朋友来问我HWASan日志怎么看的问题已经不下5次了,所以我希望写一篇使用指南,和官方指导不同的是,这里不会停留在"什么输出对应什么问题"的表象,而是结合设计初衷理解为什么要这么输出。4年前写HWASan时主要依赖的材料是一些网页,但这对于深入理解还远远不够。因此在写这篇文章之前,我将HWASan的源码研究了一遍,确保文中所述皆有据可查。
好了,结束啰嗦的前言,下面让我们以一个实际的例子展开讨论。
HWASan检测到错误后会直接abort,SIGABRT(信号6)会导致进程崩溃并生成tombstone文件,因此以上信息均来自/data/tombstones/tombstone_xx
文件。为了更加清晰,我将上面的信息分为8个部分,依次阐述。
(一) 首先是"tag-mismatch"的错误提示,它并非问题的根因,而只是abort的直接原因。对HWASan来说,这种直接原因有三种:
-
tag-mismatch:所有的内存访问指令(ldr/str指令)都有HWASan插桩的检测,而检测的内容就是tag是否一致,当指针的tag和内存的tag不匹配时报出此错误。
-
invalid-free:除了内存访问指令外,进程还可以通过malloc/free/realloc之类的标准API和内存互动。其中内存释放动作(free/realloc)也会有检测,检测的内容既包括tag是否一致,也包括指针是否异常等,检测到错误报为invalid-free。
-
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结合诸多信息综合判断的问题的根因,判断的逻辑如下:
图中的信息比较精简,主要是为了展示判断的时序和逻辑,下面来详述:
-
is shadow memory:这种情况比较少见,表示当前出错的地址落在shadow memory里。要知道shadow memory和正常memory隔得还挺远的,想要踩到实属不易。如果出现这种情况,大概率判断是wild access,譬如程序人为计算了一个指针来访问。
-
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
-
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
区。 -
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
来达到这个目的。 -
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年写成的《糖史》,显然这并非显学,但其在学术圈却有着重要的地位,其严谨的治学态度也时常激励着后人。我想,要是能学到季老的一点皮毛,把所接触的技术清晰准确地梳理下来,也算是有点价值的罢。