本文分析基于Android S (12)
前言
汇编、C和C++本质上都是内存不安全的语言,因此开发者的无心之过可能会导致非法访问、内存踩踏等多种问题。这些内存问题一方面会影响用户的使用体验(进程崩溃、系统重启等);另一方面也会被黑客利用,增加入侵的机会。所以内存问题不仅是稳定性的问题,也是安全性的问题。当然,如果考虑到后期安全补丁带来的升级影响,它或许也能算得上是一个经济问题。
Android的native世界基本由C++语言构成(也包含少量的C、汇编和S上引入的Rust),其代码量甚至占到了平台总代码量的70%。因此,Google在这些年里研发了各种工具,目的就是为了高效地发现和解决各种内存问题。从最早期采用开源世界里的Valgrind工具,到Android N(7)上自主研发的Address Sanitizer(ASan),Android Q(10)上引入的Hardware Address Sanitizer(HWASan),和最新Android S(12)上引入的ARM Memory Tagging Extension(MTE)。
ASan和HWASan由Google自主开发,MTE由Google和ARM联合开发。尽管这三种工具的内部实现各有不同,但它们底层的思想其实是一致的。简而言之,这些工具的工作过程只有两步:
- 在内存分配时为它生成一个独特的tag(标签)。
- 在内存访问时去检测tag是否合规。
在这个框架的基础上,每个工具的具体实现其实都在回答下述三个问题:
- tag如何生成?
- tag如何存储?
- tag是否合规的判据是什么?
我们以ASan为例,回答上述三个问题。
- tag由单字节表示,可以反映8个真实内存字节的状态。生成时根据该块内存的实际状态选取tag值,因此tag值具有事先规定的含义。0x00表示8个字节均可访问,0x01表示8个字节中只有第一个字节可以访问,0xFD表示这块内存已经被释放。
- tag值存在内存中,但是这块区域称为shadow memory,它和用户可访问的内存之间存在固定的映射关系。
- 如果内存的tag值为0x00,则访问合规。如果是0x01,则需判断此次访问是否只访问第一个字节,如果不是则不合规(属于越界行为)。如果是0x00~0x07以外的其他值,则不合规。
让我们再来思考一下,所谓的是否合规到底在判断什么?其实它真正想判断的是内存的所有权问题。一块内存到底属于谁?我们以最容易发生内存问题的堆为例,当我们调用malloc时,系统会返回一个地址,而后续所有的内存操作都基于该地址。那么这时,虚拟意义上的“属于谁”就变成了实际意义上的“属于哪个指针”。指针和所指向的内存之间如何判断所有权?最直接的想法有点类似于“虎符”,指针和内存各持有一个tag,根据二者是否一致来判断所有权。在32位进程中,指针值的每一个比特都被用于寻址,因此没有多余的比特来记录所有权相关的信息(tag),当然也就无法通过对比来判断所有权。而在64位进程中,地址只有低48位用于寻址,因此高比特可以用来存储tag。HWASan和MTE都采用了这种方式,这也限定了它们只能用于64位进程,不过由于tag的可选范围有限,因此检测具有一定的漏检率(false-negatives)。32位进程中没办法判断所有权,只能退而求其次,给每块内存标记状态,只要访问特定状态的内存就不会出错,这也是ASan所采用的策略。
关于ASan和HWASan的实现细节,在此不再赘述。仅贴几张新画的图,有需要的可以参考我之前的文章。
[ASan的分配和释放]
[HWASan的分配和释放]
[HWASan访问越界的情况]
不过当初这篇文章有些浅表,通篇都在讲述”是什么“,而少了”为什么“的思考。因此今年在研究MTE的时候,仔细思考了工具的底层逻辑,总结出了上述的两个步骤和三个问题。按照这样一种思考模式,便可以将Google开发的三个工具纳于统一的框架之下,也更容易明白它们之间的差异和各自的优缺点。
概述
MTE是ARM新架构(≥ARMv8.5)上的一个特性。虽然它需要ARM架构层面的支持,但这项工作其实一直在由Google主导。2018年,Google专门写了《Memory Tagging and how it improves C/C++ memory safety》的文章,阐述了memory tagging在软件和硬件层面的不同实现和性能比较,并倡议架构厂商们都集成memory tagging的功能。之后Google和ARM通力合作,最终在v8.5的架构上实现了MTE的功能。不过芯片的生产一般都晚于架构设计,所以MTE最终面向开发者的时间可能还需要等上一会儿。
MTE的原理和HWASan类似,但是在检测时机和tag的生成存储上有了硬件层面的支持。首先是检测时机:HWASan通过重新编译的方式,在所有内存访问前插入检测代码,属于软件层面的检测;而MTE是在ldr/str的指令内部进行检测,换言之,是硬件层面的检测。其次是tag的生成:HWASan通过软件随机的方式来生成tag;而MTE是通过IRG指令(≥ARMv8.5的架构才有的指令)来生成tag。最后是tag的存储:HWASan将内存的tag存在shadow memory(在内存中,具有虚拟地址)中,shadow memory和normal memory之间的映射关系由工具提前设定;MTE将内存的tag存在物理内存的特定区域中(没有虚拟地址,因此无法被用户直接访问),二者之间的映射关系由硬件保证。需要注意的是,HWASan是每16bytes的内存共享一个tag,tag自身为8bits(指针中的tag存在5663位)。MTE也是每16bytes的内存共享一个tag,但是它的tag长度只有4bits(指针中的tag存在5659位),因此可选择的数值更少。
这样的改动带来两个革命性的优势:
- 性能大大提升,MTE首次具备了线上部署(in production)的可能。
- 检测不再需要代码插桩,因此也无需重新编译,大大方便了使用。
以下是三个工具的开销对比,可以直观感受到MTE的提升。
Overhead type | MTE | HWASan | ASan |
---|---|---|---|
RAM | 3%-5% | 10%-35% | ~2x |
CPU | 0%-5% | ~2x | ~2x |
Code size | 2%-4% | 40%-50% | 50%-2x |
检测模式
MTE具有两种检测模式:同步(Synchronous)和异步(Asynchronous)。同步模式性能开销大,但检测及时,错误信息收集详尽;异步模式虽然检测不及时,但性能开销小,可以用于release版本(也可以抵御内存攻击)。
- 同步模式:在内存访问(ldr/str)时,同步检测tag是否匹配,如果不匹配则触发异常。进程收到SIGSEGV信号,其中的 siginfo.si_code = SEGV_MTESERR (SERR中的S表示synchronous),siginfo.si_addr =
。 - 异步模式:在内存访问时,异步检测tag是否匹配,如果不匹配则更新TFSR_EL1寄存器中的TF0 bit。当下一次上下文切换(调度)或线程返回用户空间时,系统会去检测TFSR_EL1寄存器,进而产生SIGSEGV信号。只不过此时的 siginfo.si_code = SEGV_MTEAERR (AERR中的A表示asynchronous),siginfo.si_addr = 0 ,表明系统并不知道是哪一条具体的指令导致的问题。
为什么同步和异步模式之间存在性能差异呢?这需要牵涉到流水线优化的知识。内存访问可以分为读和写,写操作在流水线中是可以有些激进的优化策略的。譬如将连续的写操作合为一次写操作,或者将写操作缓存起来,稍后再发生实际的写动作。对同步检测而言,它必须要读取内存的tag,相当于在写操作的同时增加了一个读操作。基于内存一致性的规则,这将使得写操作的某些优化策略无法使用,因此CPU的运行效率降低。(这一块知识我只是粗浅的理解,如果有了解的朋友希望不吝赐教)
多重含义
MTE最原始的含义是ARM架构里的一个新的特性,相当于ARM提供了这种检测的能力,但是怎么使用、检测什么内存是需要操作系统和编译器配合的。因此LLVM和Android里都需要增加一些代码,方能让MTE真正地被开发者使用。
首先介绍下LLVM里关于MTE的相关工作。
可能有人会好奇,上面不是说了MTE的检测时机已经放到ldr/str指令的内部了么,为什么还需要编译器的介入呢?原因是为了进行栈上内存的检测。由于栈对象的分配没有显示的系统调用,因此必须通过函数插桩的方式来为该内存生成tag。通过-fsanitize=memtag -march=armv8.5-a+memtag
的编译选项即可打开栈内存的检测。
接着是Android中关于MTE的相关工作。Android中MTE的检测主要针对的是堆内存,因此相关的代码都集成在分配器Scudo中,在动态内存分配时为其生成tag。
所以当我们讨论MTE时,一定需要注意语境,知道对方说的到底是ARM MTE,还是LLVM MTE,抑或是Scudo MTE。
指令简介
上面多次提到ARMv8.5以后的架构中,ldr/str指令的内部实现中集成了tag检测。那么如何确认这一点呢?
首先下载一份ARMv9指令集的官方文档,然后查看ldr指令的描述,如下图所示。
这个operation其实就是ldr指令具体做的事情,接着查看Mem,一路追踪下去发现:在MTE enable的情况下,会最终执行CheckTag的动作。
此外,ARM新的架构中还提供了一些特殊指令,方便快速地对tag进行操作。譬如如下两条指令。
IRG Xd, Xn
将Xn
拷贝到Xd
,并且为Xd
中的值随机生成一个tag,存在它的[56:59]位。STG Xd, [Xn]
将内存[Xn, Xn + 16)
的tag更新为Xd
的tag值。
ARM总共新增了10余条指令用于支持MTE。作为使用者其实没必要了解每条指令的含义,只需要明白这些指令是为了更快速、更方便地操作tag即可。
Scudo中的MTE
1. 检测方式
(关于Scudo的相关知识可以参考我之前的文章)
Scudo中的内存分配有两种方式,一种是Primary allocate,用于分配小内存,使用频繁;另一个是Secondary allocate,用于分配大内存(>256K)。
所有堆内存的分配最终都会调用Allocator::allocate
函数。当内存分配出来后,系统会调用如下代码。
1 | c++复制代码//Primary Allocator |
1.1 Primary Allocator
Primary Allocator动态分配的内存块如下所示。Block是大小相同且连续排列的内存块,其中Header存储了该内存块的一些元数据便于释放时进行状态检测,而真实返回给用户的指针为Ptr。
对于分配出来的内存块,Primary Allocator使用如下代码给返回地址(指针)Ptr增加tag。
1 | c++复制代码const uptr OddEvenMask = |
OddEvenMask
先按下不表,这里我们只关心prepareTaggedChunk
。其内部所做的事情正是为刚分配的内存生成tag。
1 | c++复制代码inline void *prepareTaggedChunk(void *Ptr, uptr Size, uptr ExcludeMask, |
由于需要直接使用汇编指令stg
,因此prepareTaggedChunk
中内嵌了一些汇编代码(setRandomTag
函数中也有一些汇编代码)。该函数有四个参数,含义分别如下:
- Ptr:一个没有tag的指针,也即它的56~59位均为0。表明chunk的起始地址。
- Size:要求分配的大小。
- ExcludeMask:MTE的tag为4 bits,因此有0~15共16种可能。Android默认不选用0,因此还剩下15种可能。ExcludeMask用于从15种可能中再删去一些选择,譬如该值为0x6,则tag不会选择1或2(0x6==0b0110,从低到高的1、2比特均为1)。
- BlockEnd:块的结束地址,由于Scudo中region存储的都是大小相同的块,因此块大小可能大于要求分配的大小。
prepareTaggedChunk
中的setRandomTag
会以16bytes为单位,循环为chunk中的所有内存分配tag。最终的tag情况如下所示:
Tag生成之后,越界的内存访问就会因tag不匹配而发生SIGSEGV。不过需要注意一点,Unused内存中只对第一个16bytes生成了tag,这样线性的越界将会100%检测出来,而非线性的跨越式越界则是概率性检测出来。至于为什么没有将Unused内存全部tag为0,Google的工程师说是基于性能的考虑,不过这样确实可能会漏检一些跨越式的越界。据统计,Chromium的开发实践中约13%的overflow是跨越式的overflow。
上文提到了越界的检测方法,那么UAF(Use-After-Free)是如何检测的呢?
当一块内存释放时,系统会去调用Scudo中的quarantineOrDeallocateChunk
方法。释放的内存会生成一个新的tag,该tag有别于之前的tag,因此可以保证immediate UAF被100%地检测出来。不过长时间的UAF可能会因为该内存经历了多次分配/释放而发生漏检。
接着再介绍下OddEvenMask
,这是一个很有趣的知识点。
上文提到,这个mask会限制tag从哪些数中随机选取。对于虚拟地址连续的内存块(Block),OddEvenMask将会间隔地赋值为0xaaaa和0x5555。0xa=0b1010,0x5=0b0101,可以发现这两个mask是完全互斥的tag集合。OddEvenMask为0xaaaa,则tag只能选择奇数,反之tag只能选择非0的偶数。
1 | c++复制代码uptr computeOddEvenMaskForPointerMaybe(Options Options, uptr Ptr, |
这样一来,相邻的两个内存块一定不会使用相同的tag,保证了相邻的越界可以100%被检测出来。不过凡事有利有弊,由于每个内存块tag可选择的范围缩小一半,因此UAF的漏检率(false-negatives)反倒提高了。该特性可以通过mallopt
系统调用的M_MEMTAG_TUNING
选项进行选择。
1 | csharp复制代码int mallopt(M_MEMTAG_TUNING, level) |
1.2 Secondary Allocator
Secondary Allocator通过mmap分配出新的vma区域。上图中的Content是用户真实数据存放的位置,它的结束地址是按页对齐的。起始地址Ptr前面存放两个Header,一个是Chunk Header,与Primary Allocator保持一致;另一个是LargeBlock Header,属于Secondary独有的设计,其中主要存储前后vma的指针(链表结构)。再往前是补齐的内存,一直补齐到页边界。此外,前后再各加一个不可访问的保护页。
当MTE开启后,分配器不会为Content设置tag,因此它的tag保持默认值0。Chunk Header对应的tag设置为固定值2,LargeBlock Header和Padding对应的tag设置为固定值1。这样一来,前后溢出均可被检测:
- 线性Overflow会直接访问置于尾部的Guard Page,由于其不可访问,因此会直接触发SIGSEGV。
- 线性Underflow如果访问到Chunk Header/LargeBlock Header/Padding,由于其tag不为0(而指针tag为0),因此会产生SIGSEGV的错误;如果访问到头部的Guard Page,则也会触发SIGSEGV。
1.3 Short granules
上面的讨论有个前提,即动态分配的内存大小是16字节的整数倍。可是如果是下面的代码呢?
1 | c复制代码char *p = (char *)malloc(88); |
HWASan为了能够进行更加细粒度的溢出检测,增加了short granules的特性,详情可以参考文章。因此上面的溢出情况可以被HWASan检测出来。
但是Scudo MTE却无法检测出该错误,原因是它并不支持short granules。由于tag在MTE中仅由4bits构成,所以支持short granules会极大的压缩tag选取范围,也会大大提升漏检率。两害相权取其轻,Google团队最终放弃了在MTE中对short granules的支持。不过好在Scudo分配出来的Block都是按16字节对齐的,所以即便发生了这种溢出,也不会踩踏有效数据。
1.4 小结
Scudo中的MTE目前只检测native堆的内存,检测的错误类型主要为OOB(Out-of-Bounds,包含Underflow和Overflow)和UAF(Use-After-Free)。另外,Scudo本身也支持Double-Free的检测。
2. 调用栈的保存和恢复
(不感兴趣的可以跳过,细节较多)
当MTE设置为同步模式时,Scudo会在分配和释放内存块的时候去记录当时的调用栈信息。它们本质上是由返回地址构成的数组。在ARM64上,x29寄存器用于保存帧指针FP,x30寄存器又称为LR寄存器,用于保存返回地址。在调用过程中,每个函数开始时都会将寄存器压栈,结束时出栈。因此栈中就保存了一系列帧指针和返回地址,这两个值通常是连续存放的,而不同帧的FP又呈现出链表结构,因此遍历链表便可以将这些值全部取出。这种方式称为基于FP的栈帧回溯方法,比传统回溯方法要快。不过这种方法可行的前提是函数调用时对FP进行压栈,该行为可由编译选项-fomit-frame-pointer
和-fno-omit-frame-pointer
进行控制。在64位的Android上,默认会对FP进行压栈,因此该回溯方法有效。如果碰到没有开启FP压栈的三方库,该方法虽然会失效,但不会死循环或崩溃,只是缺少些调试信息。
不同帧的返回地址构成了一个数组,它反映的就是当时的调用栈信息。为了控制这些信息所占用的内存,返回地址组成的数组长度不得超过64,也即最多存储64帧调用栈信息。对于调试而言,64帧的调用栈已经足够看出问题。
由于每个调用栈的大小不一致,所以没法创建统一的数组长度。如果将数组长度设为64,那么当调用栈不足64帧时会浪费内存空间。所以为了更高效地使用内存,Scudo中用一个大型数组存储下所有的返回地址。该数组长度为524288(1<<19),不同调用栈的返回地址间会插入一个元素进行分隔。这个用于分隔的元素称为”stack trace marker”。那么如何区分一个marker和一个正常的返回地址呢?让我们把目光投向marker的最后一位。由于PC值在64位的机器上都是按4字节对齐的,所以其最后一位必然为0。这样我们就可以人为地将marker的最后一位设为1,以区分它和返回地址。marker的具体含义如下所示。
通过上图可以看到,marker中的1~32bits用来存储hash值。这个值由调用栈的所有返回地址经由散列算法共同计算得出,相当于调用栈的特殊ID。
Primary在分配时将hash值存在自己的Block中。如下图所示,Header后面原本用于对齐的padding目前用来存储hash值。Padding总长度为8字节,其中4字节存储hash值,另外4字节存储分配时的线程ID。
这里的hash值相当于调用栈的特殊ID,那么如何通过它来定位返回地址在数组中的序号呢?答案是需要通过一层tab数组进行中转。因为hash值本身具有随机性,所以无法直接将它和具有规律性排列的返回地址关联起来。
首先用hash值模上65536,得到一个tab数组的序号。接着取出tab数组中的元素,元素的值即为返回地址数组的序号。通常这个序号所对应的元素是”stack trace marker”,根据marker中记录的调用栈长度便可以依次取出后续所有的返回地址。
当初看到这个设计时我就问自己,为什么不可以直接将marker的序号存在Block中,而一定要存hash值呢?
后来才想明白原因。返回地址数组被设计成Ring Buffer,因此其中的内容可能被循环覆盖。如果将marker的序号存在Block中,则它可能取到完全不属于自己的调用栈。而采用hash值就可以规避这个问题。拿到marker后去比对下Block中的hash值和marker中的hash值是否一致,不一致则表明自己原来的调用栈已经被覆盖了。
tab数组的长度为65536,返回地址数组的长度为524288。二者相除的结果为8,表明如果平均调用栈长度小于7,则scudo最多可以记录65536个调用栈;如果长度大于7,则scudo最多可记录的调用栈数小于65536。当调用栈被覆盖后,虽然问题依然可以报出来,但缺少关键的调试信息后,内存问题还是很难定位。
当Primary Block释放时,这块内存便可以分配给其他人使用,因此之前存在Block中的hash值和此次释放的调用栈的hash值都要另存他处。
为此scudo中实现了一个全局的Entry数组,长度为32768。Entry结构体中的AllocationTrace存储的是分配时调用栈的hash值,DeallocationTrace存储的是释放时调用栈的hash值。当Primary Block释放时,它首先会取出存在Block中的分配调用栈的hash值,将它存到Entry的AllocationTrace字段中,之后将是释放的调用栈hash值存到Entry的DeallocationTrace中。
1 | c++复制代码 struct AllocationRingBuffer { |
此外,Entry数组不单用于存储Primary Block释放后的hash值,还用于存储Secondary Block的hash值,不论其是否释放。当初看到这里的时候,我脑中又出现了一个问题:为什么Secondary和Primary在处理未释放Block的hash值时做法不一致?
原因是Primary Allocator和Secondary Allocator对于其中Block的管理方式不同。Primary中的Block是线性排列,而Secondary里的Block是链表结构。虽然我们可以通过遍历的方式寻找到Secondary里的目标Block,但是需要在遍历过程中增加很多对目标进程内存的拷贝操作。而如果将Secondary的调用栈信息全部存在Entries数组中,我们只需在收集调用栈信息前将这个数组拷贝一次即可。
讨论完了调用栈的保存,那么调用栈的恢复是怎么进行的呢?
- 根据PC值在memory maps中找到对应的elf文件。
- 根据elf文件中的symbols信息,查询该PC值属于哪个函数的执行范围。
- Demangle这个函数的名称,得到更具可读性的字符串。
根据上述的步骤,可以知道有两种情况会丢失调试信息。
- 当相应的动态库在内存错误发生前被卸载,那么那一帧最终就会显示
<unknown>
。 - 当相应的动态库通过
-fvisibility=hidden
的编译选项来关闭符号表的输出时(商业APK),我们将无法判断PC值属于哪个函数,因此那一帧只会打印so的名称,但没有函数名。
3. 判断内存错误的原因
当MTE设置为同步模式时,scudo不仅会输出相应的调用栈,还会输出内存错误可能的原因,譬如是溢出问题还是UAF的问题。不过这种判断只是一种参考(有概率发生误判),而并非金标准。
用户空间采用debuggerd_signal_handler
作为SIGSEGV的处理函数。处理时会fork出一个crash_dump进程,用于收集错误发生时的调用栈,与此同时它也会通过__scudo_get_error_info
收集更多的错误信息。
在getErrorInfo
函数中,会同时收集调用栈和错误原因。首先会根据错误地址的tag是否为0来决定要不要做getInlineErrorInfo
。这是因为Primary分配的指针tag非0,而Secondary分配的指针tag为0。getInlineErrorInfo
是从Block中获取hash值的,而这种方式只对Primary有效。
概括地说,getInlineErrorInfo
用于输出Primary OOB问题,收集当初分配时的调用栈。getRingBufferErrorInfo
用于输出Primary UAF、Secondary OOB和Secondary UAF问题,其中UAF问题既收集当初分配时的调用栈,也收集上一次释放的调用栈。
1 | c++复制代码static void getErrorInfo(struct scudo_error_info *ErrorInfo, |
收集的错误信息通过ErrorInfo
(类型为scudo_error_info)存储,它里面包含一个定长为3的数组,表明可以为一个内存错误判定最多三种可能的原因。这种判断只是一种参考,而并非金标准。譬如一个UAF的问题,当我们检查它两侧的Block时,可能会发现和错误地址tag一样的Block,这样该问题也可以被判定为OOB的问题。至于具体是什么问题,还需使用者结合调用栈自己去判断。
1 | c++复制代码struct scudo_error_info { |
使用方法
Android提供了多种方式来开启MTE。乍一看很容易迷糊,但如果了解每种方式运行的原理,用起来便会得心应手的多。
从大的方向上可以分为以下几种类型:
- 运行时环境变量
- 系统Property
- 编译时环境变量
- 编译选项
- 应用Manifest配置
- 运行时API
接下来依次介绍。
1. 运行时环境变量
MEMTAG_OPTIONS=(off|sync|async)
该环境变量的检测过程发生在可执行文件重定位之前,是由linker发起的。因此一旦该环境变量设为sync或async,那么之后创建的任何native进程都将开启MTE检测。
不过Android应用进程(Java进程)并不会受到这个环境变量的影响:因为应用进程由zygote fork而来,而非通过exec
可执行文件的方式打开。
通常,我们在/system/core/rootdir/init.environ.rc.in中去增加新的环境变量(这样便需要重新编译),譬如下面的方式可以打开MTE的同步模式。
1 | bash复制代码# set up the global environment |
2. 系统Property
arm64.memtag.process.
= (off|sync|async)
这里的basename通常指的是可执行文件的名称,所以也只会影响native进程。不过有个例外是system_server,因为forkSystemServer中会主动读取”arm64.memtag.process.system_server”系统属性的值。
举个例子,下面的操作会同时打开/system/bin/ping和/data/local/tmp/ping的MTE选项,之后启动的ping进程都会开启MTE的同步模式。
1 | shell复制代码$ setprop arm64.memtag.process.ping sync |
3. 编译时环境变量
SANITIZE_TARGET & SANITIZE_TARGET_DIAG
异步模式:
1 | shell复制代码hangl@ubuntu$ export SANITIZE_TARGET=memtag_heap |
同步模式:
1 | shell复制代码hangl@ubuntu$ export SANITIZE_TARGET=memtag_heap SANITIZE_TARGET_DIAG=memtag_heap |
首先通过export声明环境变量,之后通过Android编译系统提供的快捷操作m
来编译整个system image。值得注意的是,同步模式需要同时声明两个环境变量,后一个变量的DIAG意味着diagnostics mode(诊断模式)
,这表明不单单要检测内存错误,还要收集尽可能多的调试信息。
4. 编译选项
memtag_heap
异步模式:
1 | makefile复制代码//Android.bp |
同步模式:
1 | makefile复制代码//Android.bp |
不论是编译时环境变量还是编译选项,它们的本质都是往ELF文件中增添一个新的note section(注释节)。这个section里的内容记录了MTE的配置信息,会由linker在程序启动时读取。下面代码展示的就是如何将异步模式的MTE配置信息存入note section。
1 | assembly复制代码__bionic_asm_custom_note_gnu_section() |
存入ELF文件的note信息可以通过llvm-readelf读取出来,以下为示例。
1 | shell复制代码hangl@ubuntu$ llvm-readelf --notes app_process64 |
名称为”.note.android.memtag”的section是我们关注的。Owner为”Android”,是固定的字符串;Date Size为4,表示description data的存储空间为4字节;Description下面的数据实质上是Type,0x4即为NT_TYPE_MEMTAG;description data才是MTE的配置信息,0x5表示(NT_MEMTAG_LEVEL_ASYNC | NT_MEMTAG_HEAP),0x6表示(NT_MEMTAG_LEVEL_SYNC | NT_MEMTAG_HEAP),因为NT_MEMTAG_LEVEL_ASYNC =1,NT_MEMTAG_LEVEL_SYNC =2,NT_MEMTAG_HEAP=4。
5. 应用Manifest配置
android:memtagMode=(off|default|sync|async)
如果在
不过需要注意,该配置生效有一个前提,即zygote进程的MTE已经打开,否则所有经由zygote fork出来的进程都无法开启MTE。
1 | ini复制代码<application |
在开发者模式中,我们可以直接在设置选项中修改单个应用的MTE配置。
Settings > System > Developer options > App Compatibility Changes,其中NATIVE_MEMTAG_ASYNC
和NATIVE_MEMTAG_SYNC
的选项与MTE相关。
此外,am指令也支持修改MTE配置。
1 | shell复制代码$ adb shell am compat enable NATIVE_MEMTAG_[A]SYNC my.app.name |
6. 运行时API
int mallopt(M_BIONIC_SET_HEAP_TAGGING_LEVEL, level)
以上5种方法必须在进程启动前进行配置,可是如果一个进程已经在运行中,我们还有办法改变它的MTE配置么?
答案是使用mallopt函数,上述函数中的level可以从以下三个选项中选择。
- M_HEAP_TAGGING_LEVEL_NONE
- M_HEAP_TAGGING_LEVEL_ASYNC
- M_HEAP_TAGGING_LEVEL_SYNC
不过这个API的使用有些限制,原因是进程的MTE模式只能从开启状态切换到关闭状态,而无法逆向操作,因为中途开启MTE会导致先前分配的内存(没有生成tag)无法进行检测。具体的限制如下:
- 进程必须在启动时就已经开启MTE检测。
- M_HEAP_TAGGING_LEVEL_SYNC或M_HEAP_TAGGING_LEVEL_ASYNC切换到M_HEAP_TAGGING_LEVEL_NONE是一个单向的操作,一旦关闭,无法再次开启。
这个API对于线上应用其实有着挺大的帮助。平时我们可以对APP开启异步模式的检测(性能优先),一旦检测到问题,便可以在下次启动时切换到同步模式(调试优先)。
不同方式的优先级
- 最高优先级:运行时环境变量(全局配置,对所有native进程生效),一旦该变量设定以后,下面的配置都不会起作用。
- 中等优先级:系统property,该变量可以为特定进程配置MTE。
- 最低优先级:编译时环境变量及编译选项,它们会在ELF文件中增加note section,但note section的检测只会发生在上面两个方式没有使用的时候。
需要注意的是,上述方式只作用于native进程,也即通过exec加载ELF文件来启动的进程(有一个例外是system_server,通过”arm64.memtag.process.system_server”系统属性控制)。
APP由zygote fork出来,因此不会走ELF文件加载的流程。子进程fork出来后,会执行SpecializeCommon
函数,其中会调用mallopt对MTE进行配置。
1 | c++复制代码mallopt(M_BIONIC_SET_HEAP_TAGGING_LEVEL, heap_tagging_level); |
默认情况下,heap_tagging_level的值为M_HEAP_TAGGING_LEVEL_NONE
。当Manifest或Compatibility Changes中开启了MTE后,heap_tagging_level的值便会发生更改。
案例解析
当MTE检测到问题后,会生成相应进程的tombstone文件,下面给出一个示例。
1 | less复制代码*** *** *** *** *** *** *** *** *** *** *** *** *** *** *** *** |
上面是MTE同步模式下输出的tombstone信息,tagged_addr_ctrl
中记录了三个信息:
- 最低位为1表示MTE检测功能打开。
- 1~2bits
01
意味着检测模式为同步,10
意味着检测模式为异步。 - 3~18bits记录了tag选取的范围,
1111111111111110
意味着tag不选择0。
因此0x7fff3
表示同步模式打开,且tag不选择0。
Code SEGV_MTESERR
表示synchronous,SEGV_MTEAERR
表示asynchronous。
Backtrace标签下记录了错误发生时当前线程的调用栈,之后的三个Cause分别记录了错误的三种可能。OOB问题的检测由近及远,且先Underflow,后Overflow。
为了方便比对,下面省略调用栈,直接将三个Cause罗列在一起。
1 | ini复制代码Cause: [MTE]: Buffer Underflow, 128 bytes left of a 96-byte allocation at 0x7c6c70f680 // 0x680 -0x600 = 128(0x80) bytes. |
错误地址指向的Block属于Class 6的region,其中每个Block的大小都为112字节(8字节Header+8字节Padding+96字节存储空间)。不过96字节的存储空间并不意味着完全使用,譬如可以只使用88字节,剩下8字节unused。
检测时先去寻找错误Block右边的Block,发现右边第一个Block的tag和错误地址一样,因此判定为Buffer Underflow。”128 bytes left of a 96-byte allocation at 0x7c6c70f680”,表示错误地址和右边Block的头部地址之间相差128bytes,而右边Block的真实content大小为96bytes。
接着查看左边第一个Block,发现tag也和错误地址一样,因此判定为Buffer Overflow。”0 bytes right of a 96-byte allocation at 0x7c6c70f5a0”表示错误地址刚好等于左边Block的尾部地址,且左边Block的真实content大小也为96bytes。
左右各判定最多15个Block,判断到右边第15个Block时,发现它的tag和错误地址的tag也是一样的,因此第三种可能的原因判断为Underflow。”1696 bytes left of a 88-byte allocation at 0x7c6c70fca0”,表示错误地址距离右边第15个Block的头部有1696bytes的距离,且该Block的真实content大小为88bytes。(1696+88)/112=16,由于本身Block占据一个位置,因此刚好是右边第15个Block。
接着结合每种类型的调用栈,判定Overflow应该是这次错误的真实原因,因为它的调用栈和错误调用栈都和Gralloc2Mapper
的preload
有关。
后记
随着Android S(12)的正式发布,估计会有越来越多的人听到MTE。所以我就想着写一篇尽可能详细的文章,帮助大家更深入地了解它、使用它。另外在研究源码时,我也发现了scudo MTE中的一些小瑕疵,给Google提了3个bug和2个建议,幸运的是都被采纳了,也算为开源社区做些贡献。
不知不觉,本文已过万字,但其中肯定还有不少细节没有推敲到位,希望各位朋友发现之后帮忙指正,多谢多谢!
本文转载自: 掘金