阅读 3459

Android Native | 内存问题的终极武器——MTE

本文分析基于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)。

工具的演进过程.jpg

ASan和HWASan由Google自主开发,MTE由Google和ARM联合开发。尽管这三种工具的内部实现各有不同,但它们底层的思想其实是一致的。简而言之,这些工具的工作过程只有两步:

  1. 在内存分配时为它生成一个独特的tag(标签)。
  2. 在内存访问时去检测tag是否合规。

在这个框架的基础上,每个工具的具体实现其实都在回答下述三个问题:

  1. tag如何生成?
  2. tag如何存储?
  3. tag是否合规的判据是什么?

我们以ASan为例,回答上述三个问题。

  1. tag由单字节表示,可以反映8个真实内存字节的状态。生成时根据该块内存的实际状态选取tag值,因此tag值具有事先规定的含义。0x00表示8个字节均可访问,0x01表示8个字节中只有第一个字节可以访问,0xFD表示这块内存已经被释放。

ASAN基本概念.jpg

  1. tag值存在内存中,但是这块区域称为shadow memory,它和用户可访问的内存之间存在固定的映射关系。

  2. 如果内存的tag值为0x00,则访问合规。如果是0x01,则需判断此次访问是否只访问第一个字节,如果不是则不合规(属于越界行为)。如果是0x00~0x07以外的其他值,则不合规。

让我们再来思考一下,所谓的是否合规到底在判断什么?其实它真正想判断的是内存的所有权问题。一块内存到底属于谁?我们以最容易发生内存问题的堆为例,当我们调用malloc时,系统会返回一个地址,而后续所有的内存操作都基于该地址。那么这时,虚拟意义上的“属于谁”就变成了实际意义上的“属于哪个指针”。指针和所指向的内存之间如何判断所有权?最直接的想法有点类似于“虎符”,指针和内存各持有一个tag,根据二者是否一致来判断所有权。在32位进程中,指针值的每一个比特都被用于寻址,因此没有多余的比特来记录所有权相关的信息(tag),当然也就无法通过对比来判断所有权。而在64位进程中,地址只有低48位用于寻址,因此高比特可以用来存储tag。HWASan和MTE都采用了这种方式,这也限定了它们只能用于64位进程,不过由于tag的可选范围有限,因此检测具有一定的漏检率(false-negatives)。32位进程中没办法判断所有权,只能退而求其次,给每块内存标记状态,只要访问特定状态的内存就不会出错,这也是ASan所采用的策略。

关于ASan和HWASan的实现细节,在此不再赘述。仅贴几张新画的图,有需要的可以参考我之前的文章

[ASan的分配和释放]

ASAN图例.png

[HWASan的分配和释放]

HWASAN图例.png

[HWASan访问越界的情况]

HWASAN错误.png

不过当初这篇文章有些浅表,通篇都在讲述”是什么“,而少了”为什么“的思考。因此今年在研究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存在56~63位)。MTE也是每16bytes的内存共享一个tag,但是它的tag长度只有4bits(指针中的tag存在56~59位),因此可选择的数值更少。

这样的改动带来两个革命性的优势:

  1. 性能大大提升,MTE首次具备了线上部署(in production)的可能。
  2. 检测不再需要代码插桩,因此也无需重新编译,大大方便了使用。

以下是三个工具的开销对比,可以直观感受到MTE的提升。

Overhead typeMTEHWASanASan
RAM3%-5%10%-35%~2x
CPU0%-5%~2x~2x
Code size2%-4%40%-50%50%-2x

检测模式

MTE具有两种检测模式:同步(Synchronous)和异步(Asynchronous)。同步模式性能开销大,但检测及时,错误信息收集详尽;异步模式虽然检测不及时,但性能开销小,可以用于release版本(也可以抵御内存攻击)。

  • 同步模式:在内存访问(ldr/str)时,同步检测tag是否匹配,如果不匹配则触发异常。进程收到SIGSEGV信号,其中的 siginfo.si_code = SEGV_MTESERR (SERR中的S表示synchronous),siginfo.si_addr = <fault-address> 。
  • 异步模式:在内存访问时,异步检测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指令的描述,如下图所示。

ARMv9 LDR 1.jpg

这个operation其实就是ldr指令具体做的事情,接着查看Mem,一路追踪下去发现:在MTE enable的情况下,会最终执行CheckTag的动作。

ARMv9 LDR 2.jpg

此外,ARM新的架构中还提供了一些特殊指令,方便快速地对tag进行操作。譬如如下两条指令。

  • IRG Xd, XnXn 拷贝到 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函数。当内存分配出来后,系统会调用如下代码。

//Primary Allocator
  const uptr OddEvenMask =
	  computeOddEvenMaskForPointerMaybe(Options, BlockUptr, ClassId);
  TaggedPtr = prepareTaggedChunk(Ptr, Size, OddEvenMask, BlockEnd);
  storePrimaryAllocationStackMaybe(Options, Ptr);
...
//Secondary Allocator
} else {
  storeTags(reinterpret_cast<uptr>(Block), reinterpret_cast<uptr>(Ptr));
  storeSecondaryAllocationStackMaybe(Options, Ptr, Size);
}
复制代码

1.1 Primary Allocator

Primary Allocator动态分配的内存块如下所示。Block是大小相同且连续排列的内存块,其中Header存储了该内存块的一些元数据便于释放时进行状态检测,而真实返回给用户的指针为Ptr。

Chunk Tag.png

对于分配出来的内存块,Primary Allocator使用如下代码给返回地址(指针)Ptr增加tag。

const uptr OddEvenMask =
    computeOddEvenMaskForPointerMaybe(Options, BlockUptr, BlockSize);
TaggedPtr = prepareTaggedChunk(Ptr, Size, OddEvenMask, BlockEnd);
复制代码

OddEvenMask先按下不表,这里我们只关心prepareTaggedChunk。其内部所做的事情正是为刚分配的内存生成tag。

inline void *prepareTaggedChunk(void *Ptr, uptr Size, uptr ExcludeMask,
                                uptr BlockEnd) {
  // Prepare the granule before the chunk to store the chunk header by setting
  // its tag to 0. Normally its tag will already be 0, but in the case where a
  // chunk holding a low alignment allocation is reused for a higher alignment
  // allocation, the chunk may already have a non-zero tag from the previous
  // allocation.
  __asm__ __volatile__(".arch_extension memtag; stg %0, [%0, #-16]"
                       :
                       : "r"(Ptr)
                       : "memory");

  uptr TaggedBegin, TaggedEnd;
  setRandomTag(Ptr, Size, ExcludeMask, &TaggedBegin, &TaggedEnd);

  // Finally, set the tag of the granule past the end of the allocation to 0,
  // to catch linear overflows even if a previous larger allocation used the
  // same block and tag. Only do this if the granule past the end is in our
  // block, because this would otherwise lead to a SEGV if the allocation
  // covers the entire block and our block is at the end of a mapping. The tag
  // of the next block's header granule will be set to 0, so it will serve the
  // purpose of catching linear overflows in this case.
  uptr UntaggedEnd = untagPointer(TaggedEnd);
  if (UntaggedEnd != BlockEnd)
    __asm__ __volatile__(".arch_extension memtag; stg %0, [%0]"
                         :
                         : "r"(UntaggedEnd)
                         : "memory");
  return reinterpret_cast<void *>(TaggedBegin);
}
复制代码

由于需要直接使用汇编指令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情况如下所示:

Chunk with Tag.png

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的偶数。

uptr computeOddEvenMaskForPointerMaybe(Options Options, uptr Ptr,
                                       uptr ClassId) {
  if (!Options.get(OptionBit::UseOddEvenTags))
    return 0;

  // If a chunk's tag is odd, we want the tags of the surrounding blocks to be
  // even, and vice versa. Blocks are laid out Size bytes apart, and adding
  // Size to Ptr will flip the least significant set bit of Size in Ptr, so
  // that bit will have the pattern 010101... for consecutive blocks, which we
  // can use to determine which tag mask to use.
  return 0x5555U << ((Ptr >> SizeClassMap::getSizeLSBByClassId(ClassId)) & 1);
}
复制代码

这样一来,相邻的两个内存块一定不会使用相同的tag,保证了相邻的越界可以100%被检测出来。不过凡事有利有弊,由于每个内存块tag可选择的范围缩小一半,因此UAF的漏检率(false-negatives)反倒提高了。该特性可以通过mallopt系统调用的M_MEMTAG_TUNING选项进行选择。

int mallopt(M_MEMTAG_TUNING, level)
where level is:
● M_MEMTAG_TUNING_BUFFER_OVERFLOW   (OddEvenMask打开,默认值)
● M_MEMTAG_TUNING_UAF               (OddEvenMask关闭)
复制代码

1.2 Secondary Allocator

Secondary MTE.png

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字节的整数倍。可是如果是下面的代码呢?

char *p = (char *)malloc(88);
*(p + 89) = 'n';
复制代码

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的具体含义如下所示。

Stack trace数组.png

通过上图可以看到,marker中的1~32bits用来存储hash值。这个值由调用栈的所有返回地址经由散列算法共同计算得出,相当于调用栈的特殊ID。

Primary在分配时将hash值存在自己的Block中。如下图所示,Header后面原本用于对齐的padding目前用来存储hash值。Padding总长度为8字节,其中4字节存储hash值,另外4字节存储分配时的线程ID。

stack trace存储.png

这里的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中。

  struct AllocationRingBuffer {
    struct Entry {
      atomic_uptr Ptr;
      atomic_uptr AllocationSize;
      atomic_u32 AllocationTrace;
      atomic_u32 AllocationTid;
      atomic_u32 DeallocationTrace;
      atomic_u32 DeallocationTid;
    };

    atomic_uptr Pos;
#ifdef SCUDO_FUZZ
    static const uptr NumEntries = 2;
#else
    static const uptr NumEntries = 32768;
#endif
    Entry Entries[NumEntries];
  };
  AllocationRingBuffer RingBuffer;
复制代码

此外,Entry数组不单用于存储Primary Block释放后的hash值,还用于存储Secondary Block的hash值,不论其是否释放。当初看到这里的时候,我脑中又出现了一个问题:为什么Secondary和Primary在处理未释放Block的hash值时做法不一致?

原因是Primary Allocator和Secondary Allocator对于其中Block的管理方式不同。Primary中的Block是线性排列,而Secondary里的Block是链表结构。虽然我们可以通过遍历的方式寻找到Secondary里的目标Block,但是需要在遍历过程中增加很多对目标进程内存的拷贝操作。而如果将Secondary的调用栈信息全部存在Entries数组中,我们只需在收集调用栈信息前将这个数组拷贝一次即可。

讨论完了调用栈的保存,那么调用栈的恢复是怎么进行的呢?

  1. 根据PC值在memory maps中找到对应的elf文件。
  2. 根据elf文件中的symbols信息,查询该PC值属于哪个函数的执行范围。
  3. Demangle这个函数的名称,得到更具可读性的字符串。

根据上述的步骤,可以知道有两种情况会丢失调试信息。

  1. 当相应的动态库在内存错误发生前被卸载,那么那一帧最终就会显示<unknown>
  2. 当相应的动态库通过-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问题既收集当初分配时的调用栈,也收集上一次释放的调用栈。

static void getErrorInfo(struct scudo_error_info *ErrorInfo,
                         uintptr_t FaultAddr, const char *DepotPtr,
                         const char *RegionInfoPtr, const char *RingBufferPtr,
                         const char *Memory, const char *MemoryTags,
                         uintptr_t MemoryAddr, size_t MemorySize) {
  *ErrorInfo = {};
  if (!allocatorSupportsMemoryTagging<Params>() ||
      MemoryAddr + MemorySize < MemoryAddr)
    return;

  auto *Depot = reinterpret_cast<const StackDepot *>(DepotPtr);
  size_t NextErrorReport = 0;

  // Check for OOB in the current block and the two surrounding blocks. Beyond
  // that, UAF is more likely.
  if (extractTag(FaultAddr) != 0)
    getInlineErrorInfo(ErrorInfo, NextErrorReport, FaultAddr, Depot,
                       RegionInfoPtr, Memory, MemoryTags, MemoryAddr,
                       MemorySize, 0, 2);

  // Check the ring buffer. For primary allocations this will only find UAF;
  // for secondary allocations we can find either UAF or OOB.
  getRingBufferErrorInfo(ErrorInfo, NextErrorReport, FaultAddr, Depot,
                         RingBufferPtr);

  // Check for OOB in the 28 blocks surrounding the 3 we checked earlier.
  // Beyond that we are likely to hit false positives.
  if (extractTag(FaultAddr) != 0)
    getInlineErrorInfo(ErrorInfo, NextErrorReport, FaultAddr, Depot,
                       RegionInfoPtr, Memory, MemoryTags, MemoryAddr,
                       MemorySize, 2, 16);
}
复制代码

收集的错误信息通过ErrorInfo(类型为scudo_error_info)存储,它里面包含一个定长为3的数组,表明可以为一个内存错误判定最多三种可能的原因。这种判断只是一种参考,而并非金标准。譬如一个UAF的问题,当我们检查它两侧的Block时,可能会发现和错误地址tag一样的Block,这样该问题也可以被判定为OOB的问题。至于具体是什么问题,还需使用者结合调用栈自己去判断。

struct scudo_error_info {
  struct scudo_error_report reports[3];
};

struct scudo_error_report {
  enum scudo_error_type error_type;

  uintptr_t allocation_address;
  uintptr_t allocation_size;

  uint32_t allocation_tid;
  uintptr_t allocation_trace[64];

  uint32_t deallocation_tid;
  uintptr_t deallocation_trace[64];
};
复制代码

使用方法

Android提供了多种方式来开启MTE。乍一看很容易迷糊,但如果了解每种方式运行的原理,用起来便会得心应手的多。

从大的方向上可以分为以下几种类型:

  1. 运行时环境变量
  2. 系统Property
  3. 编译时环境变量
  4. 编译选项
  5. 应用Manifest配置
  6. 运行时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的同步模式。

# set up the global environment
on early-init
+	export MEMTAG_OPTIONS sync (打开MTE的同步检测模式)
    export ANDROID_BOOTLOGO 1
    export ANDROID_ROOT /system
    export ANDROID_ASSETS /system/app
    export ANDROID_DATA /data
    export ANDROID_STORAGE /storage
    export ANDROID_ART_ROOT /apex/com.android.art
    export ANDROID_I18N_ROOT /apex/com.android.i18n
    export ANDROID_TZDATA_ROOT /apex/com.android.tzdata
    export EXTERNAL_STORAGE /sdcard
    export ASEC_MOUNTPOINT /mnt/asec
    %EXPORT_GLOBAL_ASAN_OPTIONS%
    %EXPORT_GLOBAL_GCOV_OPTIONS%
    %EXPORT_GLOBAL_CLANG_COVERAGE_OPTIONS%
    %EXPORT_GLOBAL_HWASAN_OPTIONS%
复制代码

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的同步模式。

$ setprop arm64.memtag.process.ping sync
复制代码

3. 编译时环境变量

SANITIZE_TARGET & SANITIZE_TARGET_DIAG

异步模式:

hangl@ubuntu$ export SANITIZE_TARGET=memtag_heap
hangl@ubuntu$ m
复制代码

同步模式:

hangl@ubuntu$ export SANITIZE_TARGET=memtag_heap SANITIZE_TARGET_DIAG=memtag_heap
hangl@ubuntu$ m
复制代码

首先通过export声明环境变量,之后通过Android编译系统提供的快捷操作m来编译整个system image。值得注意的是,同步模式需要同时声明两个环境变量,后一个变量的DIAG意味着diagnostics mode(诊断模式),这表明不单单要检测内存错误,还要收集尽可能多的调试信息。

4. 编译选项

memtag_heap

异步模式:

//Android.bp
sanitize: {
    memtag_heap: true,
}
复制代码

同步模式:

//Android.bp
sanitize: {
    memtag_heap: true,
    diag: {
        memtag_heap: true,
    },
}
复制代码

不论是编译时环境变量还是编译选项,它们的本质都是往ELF文件中增添一个新的note section(注释节)。这个section里的内容记录了MTE的配置信息,会由linker在程序启动时读取。下面代码展示的就是如何将异步模式的MTE配置信息存入note section。

__bionic_asm_custom_note_gnu_section()
  .section ".note.android.memtag", "a", %note
  .p2align 2
  .long 1f - 0f         // int32_t namesz
  .long 3f - 2f         // int32_t descsz
  .long NT_TYPE_MEMTAG  // int32_t type
0:
  .asciz "Android"      // char name[]
1:
  .p2align 2
2:
  .long (NT_MEMTAG_LEVEL_ASYNC | NT_MEMTAG_HEAP) // value
3:
  .p2align 2
复制代码

存入ELF文件的note信息可以通过llvm-readelf读取出来,以下为示例。

hangl@ubuntu$ llvm-readelf --notes app_process64
...
Displaying notes found in: .note.android.memtag
  Owner                Data size        Description
  Android              0x00000004       Unknown note type: (0x00000004)
  description data: 05 00 00 00
复制代码

名称为".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)

如果在<application>标签下配置,则该属性对应用中的所有进程都生效;如果在<process>标签下配置,则该属性仅对单个进程生效,且会覆盖<application>标签下的配置。

不过需要注意,该配置生效有一个前提,即zygote进程的MTE已经打开,否则所有经由zygote fork出来的进程都无法开启MTE。

<application
    ...
    android:memtagMode="async">
复制代码

在开发者模式中,我们可以直接在设置选项中修改单个应用的MTE配置。

Settings > System > Developer options > App Compatibility Changes,其中NATIVE_MEMTAG_ASYNCNATIVE_MEMTAG_SYNC的选项与MTE相关。

此外,am指令也支持修改MTE配置。

$ 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)无法进行检测。具体的限制如下:

  1. 进程必须在启动时就已经开启MTE检测。
  2. 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进行配置。

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文件,下面给出一个示例。

*** *** *** *** *** *** *** *** *** *** *** *** *** *** *** ***
Build fingerprint: xxxx
Revision: '0'
ABI: 'arm64'
Timestamp: 1970-01-01 00:36:59.185340333+0000
pid: 3304, tid: 3304, name: main  >>> zygote64 <<<
uid: 0
tagged_addr_ctrl: 000000000007fff3
signal 11 (SIGSEGV), code 9 (SEGV_MTESERR), fault addr 0x800007c6c70f600
    x0  0000000000000025  x1  0800007c6c70f5e9  x2  0000000000084000  x3  0000000000000000
    ...

backtrace:
      #00 pc 000000000009cc38  /apex/com.android.runtime/lib64/bionic/libc.so (__openat+8)
      #01 pc 000000000005b234  /apex/com.android.runtime/lib64/bionic/libc.so (__open_2+76)
      ...
Note: multiple potential causes for this crash were detected, listing them in decreasing order of probability.

Cause: [MTE]: Buffer Underflow, 128 bytes left of a 96-byte allocation at 0x7c6c70f680  // 0x680 -0x600 = 128(0x80) bytes.

allocated by thread 3304:
      #00 pc 0000000000043f34  /apex/com.android.runtime/lib64/bionic/libc.so (scudo::Allocator<scudo::AndroidConfig, &(scudo_malloc_postinit)>::allocate(unsigned long, scudo::Chunk::Origin, unsigned long, bool)+1260)
      #01 pc 0000000000044204  /apex/com.android.runtime/lib64/bionic/libc.so (scudo_malloc+36)
      #02 pc 000000000003ec7c  /apex/com.android.runtime/lib64/bionic/libc.so (malloc+36)
      ...

Cause: [MTE]: Buffer Overflow, 0 bytes right of a 96-byte allocation at 0x7c6c70f5a0  // 0x600 - 0x5a0 = 0x60(96) bytes

allocated by thread 3304:
      #00 pc 0000000000043f34  /apex/com.android.runtime/lib64/bionic/libc.so (scudo::Allocator<scudo::AndroidConfig, &(scudo_malloc_postinit)>::allocate(unsigned long, scudo::Chunk::Origin, unsigned long, bool)+1260)
      #01 pc 0000000000044204  /apex/com.android.runtime/lib64/bionic/libc.so (scudo_malloc+36)
      #02 pc 000000000003ec7c  /apex/com.android.runtime/lib64/bionic/libc.so (malloc+36)
      ...

Cause: [MTE]: Buffer Underflow, 1696 bytes left of a 88-byte allocation at 0x7c6c70fca0  //0xca0 - 0x600 = 1696 bytes

allocated by thread 3304:
      #00 pc 0000000000043f34  /apex/com.android.runtime/lib64/bionic/libc.so (scudo::Allocator<scudo::AndroidConfig, &(scudo_malloc_postinit)>::allocate(unsigned long, scudo::Chunk::Origin, unsigned long, bool)+1260)
      #01 pc 00000000000445ac  /apex/com.android.runtime/lib64/bionic/libc.so (scudo::Allocator<scudo::AndroidConfig, &(scudo_malloc_postinit)>::reallocate(void*, unsigned long, unsigned long)+248) 
      #02 pc 0000000000044450  /apex/com.android.runtime/lib64/bionic/libc.so (scudo_realloc+36)
      #03 pc 000000000003ef48  /apex/com.android.runtime/lib64/bionic/libc.so (realloc+84)
      ...
复制代码

上面是MTE同步模式下输出的tombstone信息,tagged_addr_ctrl中记录了三个信息:

tag_ctrl.jpg

  • 最低位为1表示MTE检测功能打开。
  • 1~2bits01意味着检测模式为同步,10意味着检测模式为异步。
  • 3~18bits记录了tag选取的范围,1111111111111110意味着tag不选择0。

因此0x7fff3表示同步模式打开,且tag不选择0。

Code SEGV_MTESERR表示synchronous,SEGV_MTEAERR表示asynchronous。

Backtrace标签下记录了错误发生时当前线程的调用栈,之后的三个Cause分别记录了错误的三种可能。OOB问题的检测由近及远,且先Underflow,后Overflow。

为了方便比对,下面省略调用栈,直接将三个Cause罗列在一起。

Cause: [MTE]: Buffer Underflow, 128 bytes left of a 96-byte allocation at 0x7c6c70f680  // 0x680 -0x600 = 128(0x80) bytes.
Cause: [MTE]: Buffer Overflow, 0 bytes right of a 96-byte allocation at 0x7c6c70f5a0  // 0x600 - 0x5a0 = 0x60(96) bytes
Cause: [MTE]: Buffer Underflow, 1696 bytes left of a 88-byte allocation at 0x7c6c70fca0  //0xca0 - 0x600 = 1696 bytes
复制代码

案例.png

错误地址指向的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应该是这次错误的真实原因,因为它的调用栈和错误调用栈都和Gralloc2Mapperpreload有关。

后记

随着Android S(12)的正式发布,估计会有越来越多的人听到MTE。所以我就想着写一篇尽可能详细的文章,帮助大家更深入地了解它、使用它。另外在研究源码时,我也发现了scudo MTE中的一些小瑕疵,给Google提了3个bug和2个建议,幸运的是都被采纳了,也算为开源社区做些贡献。

不知不觉,本文已过万字,但其中肯定还有不少细节没有推敲到位,希望各位朋友发现之后帮忙指正,多谢多谢!

文章分类
Android
文章标签