当前位置: 首页 > 网络应用技术

Android本地|记忆问题的终极武器-MTE

时间:2023-03-08 14:04:18 网络应用技术

  汇编,C和C ++本质上是不安全的语言,因此开发人员的无意性可能会导致各种问题,例如非法访问和内存冲压。一方面,这些内存问题将影响用户的经验(过程崩溃,系统重新启动,系统重新启动,ETC。);另一方面,黑客也将使用它们来增加入侵的机会。因此,记忆问题不仅是稳定的问题,而且是安全问题。当然,如果您考虑升级的影响通过后来的安全补丁,它可能被认为是一个经济问题。

  Android的本地世界基本上由C ++语言组成(还包含少量的C,组装,并且在S上引入了锈蚀),其代码甚至是平台代码总数的70%。这些年来,Google已经开发了各种工具,以有效地发现和解决各种记忆问题。从开源世界中最早的Valgrind工具到地址消毒器(ASAN),在Android Nintroid nintroded Arm Memiry tagggging Extension(MTE)上独立开发了地址消毒器(ASAN)。

  Asan和Hwasan是由Google独立开发的,MTE由Google和ARM共同开发。尽管这三种工具的内部实施是不同的,它们的基本思想实际上是相同的。简而言之,这些工具的工作过程仅两个脚步:

  根据本框架,每个工具的具体实施实际上是回答以下三个问题:

  我们以Asan为例回答以上三个问题。

  让我们再次考虑一下,这是什么被称为的合规性?当我们调用Malloc时,系统将返回地址,所有后续内存操作均基于地址。然后,此时,从虚拟含义上,“谁属于”变成了“指针属于的实际意义”。判断指针和记忆之间的所有权?最直接的想法与“老虎”有点相似。指针和内存每个都有一个标签,该标签是根据两者是否一致的所有权。在32位进程中,每个指针值的每个位用于寻址,因此没有额外的位置可以记录与当然,所有权(标签)不可能通过比较来判断所有权。在64位流程中,地址仅为48位用于解决方案,因此可以使用高位来存储标签。使用此方法,这也限制了它们仅可用于64位过程。但是,由于标签的可选范围有限,因此检测有一定的检查率(错误的阴性)。没有办法在32位过程中判断所有权。它只能撤退并搜索每个内存标记。只要访问特定状态中的内存,就不会出错。这也是阿桑使用的策略。

  关于Asan和Hwasan的详细信息,我不会在此处详细介绍。仅发布一些新图片。如果需要,可以参考我的上一篇文章。

  [ASAN的分配和释放]

  [HWASAN的分配和释放]

  [Hwaan参观越境]

  但是,这篇文章有点浅,整篇文章都在讲“什么”,而较少的“为什么”。因此,当我今年学习MTE时,我仔细考虑了该工具的基本逻辑,并总结了两个步骤和以上三个问题。根据这种思维模式,Google开发的三个工具可以连接到统一框架,并且更容易理解它们与其各自的优势和缺点之间的差异。

  MTE是ARM New Architecture(≥Armv8.5)的功能。尽管它需要在ARM架构级别上进行支持,但这项工作已由Google.in统治2018年,Google写了一篇文章“内存标记及其如何改善C/ C/C ++内存安全性”,解释了软件和硬件级别中内存标签的不同实现和性能比较,并提出建筑制造商集成了内存标签的功能。hestectter,以下是Google和Arm合作,并最终实现了MTE在V8.5体系结构中的功能。但是,芯片的生产通常比架构更晚,因此MTE可能需要等待一段时间才能为开发人员等待一段时间。

  MTE的原理与HWASAN相似,但是它在检测时间和标签的生成存储中具有硬件级别的支持。首先是检测时间:HWASAN在所有内存访问之前插入检测代码,这是一个软件级检测;在LDR/STR指令中检测到MTE。换句话说,这是一个硬件级别的检测。按标签遵循:hwasan通过随机生成标签。MTE将通过IRG指令(≥AARMV8.5架构)生成标签。从而,标签的存储:HWASAN在商店的标签中存在(在内存中,具有虚拟地址),并且阴影内存与正常内存之间的映射关系是通过工具提前设置;MTE存在于物理内存的物理记忆中硬件保证了两个。应该指出的是,HWASAN是每16 bytes内存共享一个标签,并且标签本身为8位(指针中的56?63位数)。MTE也每16 bytes内存共享标签,但是它标签长度仅为4bits(指针中的56?59位),因此选择较少。

  这样的变化带来了两个革命性的优势:

  以下是三个工具的高架比较,可以直观地感觉到MTE的改善。

  RAM3%-5%10%-35?2XCPU0%-5%?2x?2xcode size 2%-4%40%-50%50%-2xMte具有两种检测模式:同步(同步)和异步(异步)。模式性能开销很大,但是检测是及时的,并且详细介绍了错误消息。尽管异步模式不及时,但性能开销很小,可用于发行版本(也可以抵抗内存攻击)。

  为什么同步模式和异步模式之间存在任何性能差异?这需要组装线优化的知识。记忆访问可以分为阅读和写作。写作操作可以是组装线中的激进优化策略。内存标签,等同于在编写操作时添加阅读操作。基于内存一致性的规则将使不可用的写作操作的优化策略,因此CPU的运行效率将降低。(我只了解这些知识的知识。。

  MTE的最原始含义是ARM体系结构中的一项新功能,相当于ARM提供这种检测到这种检测的能力,但是如何使用和检测哪些内存需要操作系统和编译器的合作。因此,LLVM和Android都需要添加一些代码,以使开发人员真正使用MTE。

  首先介绍LLVM中MTE的相关工作。

  有些人可能很好奇。MTE在LDR/STR指令内部的MTE顶部的MTE测试不是吗?为什么我需要干预编译器?原因是检测堆栈上的内存。由于堆栈对象的分布未显示系统调用,因此有必要通过插入函数的函数生成标签堆。编译选项可以打开堆栈内存的检测。

  接下来是MTE在Android中的相关工作。Android中MTE的检测主要针对堆内存,因此相关代码集成在分配器Scudo中,并且在动态内存分配期间为其生成了标签。

  因此,当我们讨论MTE时,我们必须注意环境,知道另一方是说是ARM MTE,LLVM MTE还是Scudo MTE。

  在上面很多时候,ARMV8.5后LDR/STR指令的结构集成到LDR/STR指令的内部实现中。

  首先下载ARMV9指令集的官方文件,然后检查LDR指令的描述,如下图所示。

  该操作实际上是LDR指令的特定内容。然后检查MEM并一直跟进,以发现在MTE启用的情况下,您最终将执行CheckTAG的动作。

  此外,ARM的新体系结构还提供了一些特殊说明来促进和快速操作标签。例如,以下两个说明。

  ARM添加了10多个指令来支持MTE。作为用户,无需了解每个说明的含义。您只需要了解这些说明即可更快,更方便地操作标签。

  (有关Scudo的相关知识可以参考我的上一篇文章)

  有两种方法可以在Scudo中分发内存分布,一种是主要的销售,用于分配小内存和经常使用;另一个是用于分配大记忆(> 256K)的次要静脉分配。

  所有堆内存的分布最终将调用该功能。分配内存后,系统将调用以下代码。

  1.1主分配抗逆转录器动态分配的内存块如下所示。块相同的大小和连续布置的内存块。其中,标题存储了内存块的一些元数据,以方便发布状态检测,并且指针返回给用户是PTR。

  对于分配的内存块,主alocator使用以下代码将标签添加到返回地址(指针)ptr。

  首先按它,我们只在这里关心它。它的确是为新分配的内存生成标签。

  由于需要直接使用装配说明,因此某些装配代码嵌入了中国(该功能中也有一些汇编代码)。此功能具有四个参数,其含义如下:

  在16个字节中,将循环分配给所有内存中的所有内存以分配标签。最终标签情况如下:

  生成标签后,由于TAG的非匹配,将发生SigSeGV。但是,应注意的是,在未使用的内存中,它仅对第一个16比较生成标签。该线性交叉订单将100%检测到,并通过概率检测到非线性跨越边界。这确实可能会错过一些飞跃的边界。根据统计数据,Chromium的开发实践中约有13%是Leapflow溢出。

  上面提到了越过边界的测试方法,那么UAF(无用户)如何检测?

  发布内存后,系统将在Scudo中调用该方法。内存的发行将生成一个新标签,该标签与上一个标签不同,因此它可以确保100%检测到即时UAF。- 由于内存经历了多次分配/发布,因此可能会丢失UAF。

  然后再次介绍,这是一个非常有趣的知识点。

  如上所述,此掩码将限制随机选择的标签。对于虚拟地址的连续内存块(块),将分配给0xAAAA和0x55555.0xa = 0b1010,0x5 = 0b0101。可以发现这两个掩码是完全相互排斥的标签集合。oddevenmask是0xaaaa,然后标签只能选择奇怪的数字。相反,标签甚至只能选择non -0。

  这样,两个相邻的内存块不得使用相同的标签,以确保可以100%检测到相邻的交叉订单。但是,所有事物都有优点和缺点。随着每个内存块标签的范围减少一半,因此增加了UAF的错误 - 阴性。可以通过系统调用的选项选择此功能。

  1.2次级分配

  次级分配器通过MMAP分配了一个新的VMA区域。上图中的内容是用户真实数据的位置,其结束地址根据页面对齐。启动地址PTR PRTER PTR Store两个标头,一个是块标头,它是块头,它与主要分配器一致;另一个是大框标头,属于辅助设计的唯一设计,包括主存储的指针(链接列表结构)。前进是一种已补充并保存在页面边界上的内存。在附加中,添加一个不可逆的内存每个和之后的保护页面。

  当MTE打开时,任务器将无法为内容设置标签,因此其标签保持默认为0。与块标头相对应的标签设置为固定值2,并且标签对应于大屏幕标题,并将填充设置为固定值1.这样,可以测试前后溢出:

  1.3关于短颗粒的讨论有一个前提,也就是说,动态分配的内存大小是16个字节的整数倍数。但是,如果是以下代码,该怎么办?

  为了能够执行更精细的溢流测试,HWASAN添加了短颗粒的特征。有关详细信息,请参阅文章。因此,Hwasan可以检测到上面的溢出。

  但是Scudo MTE无法检测到该错误,因为它不支持短颗粒。因为Tag仅由MTE中的4位组成,因此支持短颗粒将极大地压缩标签的范围,并且还将大大提高错过的检查率。在这两个危害中,很轻,Google团队最终放弃了MTE中的短颗粒的支持。不幸的是,Scudo分配的块根据16个字节对齐,因此,即使发生此溢出,也不会踩踏有效的数据。

  Schattic Scudo中的1.4 MTE目前仅检测到本机堆的内存。错误检测的类型主要是OOB(包括底流量和溢出)和UAF(使用后)。此外,Scudo本身还支持双重检测。

  (如果您不感兴趣,可以跳过,还有更多细节)

  当将MTE设置为同步模式时,Scudo将在分配和发布内存块时记录呼叫堆栈信息。它们本质上是由返回地址组成的数组。在ARM64中,X29寄存器用于保存帧指针FP,X30寄存器也称为LR寄存器以保存返回地址。在呼叫过程中,堆栈的堆栈将在每个功能的开头按堆栈,以便在最后离开堆栈。因此,一系列帧指针返回地址保存在堆栈中。这两个值通常是连续存储的,并且不同帧的FP显示了链接的列表结构。因此,链接列表可以删除所有这些值。该方法称为基于FP的堆栈框架可追溯性方法,该方法比传统的回顾方法更快。但是,此方法的前提是在调用函数时按FP。这种行为可以由编译选项控制并执行。在64 -bit 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中。

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

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

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

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

  当MTE设置为同步模式时,scudo不仅会输出相应的调用栈,还会输出内存错误可能的原因,譬如是溢出问题还是UAF的问题。不过这种判断只是一种参考(有概率发生误判),而并非金标准。

  用户空间采用作为SIGSEGV的处理函数。处理时会fork出一个crash_dump进程,用于收集错误发生时的调用栈,与此同时它也会通过收集更多的错误信息。

  在函数中,会同时收集调用栈和错误原因。首先会根据错误地址的tag是否为0来决定要不要做。这是因为Primary分配的指针tag非0,而Secondary分配的指针tag为0。是从Block中获取hash值的,而这种方式只对Primary有效。

  概括地说,用于输出Primary OOB问题,收集当初分配时的调用栈。用于输出Primary UAF、Secondary OOB和Secondary UAF问题,其中UAF问题既收集当初分配时的调用栈,也收集上一次释放的调用栈。

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

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

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

  接下来依次介绍。

  MEMTAG_OPTIONS=(off|sync|async)

  该环境变量的检测过程发生在可执行文件重定位之前,是由linker发起的。因此一旦该环境变量设为sync或async,那么之后创建的任何native进程都将开启MTE检测。

  不过Android应用进程(Java进程)并不会受到这个环境变量的影响:因为应用进程由zygote fork而来,而非通过可执行文件的方式打开。

  通常,我们在/system/core/rootdir/init.environ.rc.in中去增加新的环境变量(这样便需要重新编译),譬如下面的方式可以打开MTE的同步模式。

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

  SANITIZE_TARGET & SANITIZE_TARGET_DIAG

  异步模式:

  同步模式:

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

  memtag_heap

  异步模式:

  同步模式:

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

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

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

  android:memtagMode=(off|default|sync|async)

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

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

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

  Settings > 系统>开发人员选项> APP组成更改,其中选项与MTE相关。

  此外,AM指令还支持MTE配置。

  int mallopt(m_bionic_set_heap_tagging_level,级别)

  在进程启动之前必须配置上述五种方法,但是如果过程已经运行,我们是否仍然有一种更改其MTE配置的方法?

  答案是使用mallopt函数。可以从以下三个选项中选择上述函数中的级别。

  但是,使用此API有一些限制。原因是该过程的MTE模式只能从打开状态切换到闭合状态,并且不能沿反向方向进行操作,因为中间MTE在中间的开放将导致先前分配的内存(无标签)无法检测到。具体限制如下:

  此API实际上可以帮助在线应用程序。通常,我们可以检测到该应用程序的(性能优先级)以打开异步模式。检测到问题后,我们可以在下一个启动时切换到同步模式(首先调试)。

  应该注意的是,上述方法仅作用于本机过程,即通过EXEC加载ELF文件开始的过程(有一个例外是System_Server,并且通过“ ARM64.MEMTAG.PROCESS”系统属性控制。System_server”)。

  该应用程序不在Zygote叉中,因此将不会采取加载ELF文件的过程。子进程出现后,将执行该功能,该功能将呼叫Mallopt以配置MTE。

  默认情况下,head_tagging_level的值是。清单或组成更改时,head_tagging_level的值将更改。

  当MTE检测到问题时,将生成相应过程的墓碑文件,并在下面给出一个示例。

  以上是MTE同步模式下的墓碑信息输出,该信息记录了三个信息:

  因此,打开同步模式,标签不选择0。

  代码代表同步和求和。

  当误差发生时,回溯标签记录了当前线程的呼叫堆栈,并且这三个原因将分别记录错误的三个可能性。对OOB问题的检测是从远到远的,首先是下流,然后溢出。

  为了促进比较,省略了以下呼叫,并将三个原因直接列出。

  错误地址中的块属于第6类的区域,其中每个块为112个字节(8个bytes header+8 -Byte Padding+96字节存储空间)。但是,96个字节的存储空间并不意味着完全使用。例如,只能使用88个字节,其余的8个字节未使用。

  测试时,首先查找错误块右侧的错误块,并发现右侧块的第一个块的第一个块与错误地址相同,因此将其确定为缓冲底流量。” 128在0x7C6C70F680上的96字节静置的字节左侧,这意味着错误地址和右块的头部地址之间的差异为128 bytes,而右侧块上块的真实内容大小为96 bytes。

  然后选中左侧的第一个块,发现标签与错误地址相同,因此将其确定为缓冲区溢出。” 0 BYTES在0x7C6C70F5A0处的96字节分配的右边到左侧块的后部地址,左侧块的真实内容大小也为96 bytes。

  左右判断由多达15个街区确定。当判断右侧的第15个块时,其标签和错误地址的标签也是如此。因此,第三个可能的原因被判断为底流。” 1696字节在0x7c6c70fca0上留下了88 x 88字节的静置”,这意味着错误地址是距右侧15街区的1696 byt距离,而真实内容大小的距离为1696 by该块的块为88 by。(1696+88)/112 = 16。由于块本身占据了位置,因此仅是右侧的第15个街区。

  然后与每种类型的调用堆栈结合使用,确定溢出应该是此错误的真正原因,因为其调用堆栈与错误调用堆栈有关,并相关。

  随着Android S(12)的正式发布,估计越来越多的人会听到MTE的声音。因此,我只想写一篇尽可能详细的文章,以帮助每个人都理解并使用它。源代码,我还发现了Scudo MTE中的一些小缺陷。我向Google提出了3个错误和2个建议。幸运的是,它们都被收养了,还为开源社区做出了贡献。

  不知不觉地,本文超过了10,000个单词,但是必须有很多尚未审查的细节。我希望朋友发现后能正确提供帮助,谢谢!