阅读 2633

Android Native | Scudo内存分配器

本文分析基于Android R

前言

在Android世界中,Native堆内存的分配通常由malloc负责。即便是面向对象的new,其底层也是malloc。那当我们调用malloc/free来分配/释放堆内存时,内存到底从哪里来,又归到何处去?以及这个过程是否会涉及内存的分割和碎片的合并?负责这所有工作的,我们称它为 Native Memory Allocator。

早期Android中使用jemalloc作为默认的allocator,但是从R开始,Scudo替代jemalloc成为了non-svelte configuration模式下默认的allocator。svelte模式下默认的allocator依然是jemalloc。

svelte的含义为苗条,意味着内存更加紧凑(通常是RAM较小的设备)。而Scudo不具有内存分割和碎片合并的功能,对内存的使用也更加宽松,因此在R上并没有成为svelte模式下默认的allocator。不过谷歌在源码中已经增加了scudo_svelte的实现,预计在未来的版本中会全面替代jemalloc

Scudo这个名字源自Escudo,后者在西班牙语和葡萄牙语中表示”盾牌“。这个含义也反映了Scudo最大的特点:增强内存的安全性,抵御一些非法攻击。它的本意是为了安全,而检测出内存错误只是其附带的属性,并不是它实现的目标。因此如果要选择内存错误的调试工具,首选还是HWASAN。

jemalloc切换到Scudo,其实是硬件资源提升带来的福利。随着64位机器和大RAM的普及,虚拟内存和物理内存的瓶颈都在不断放宽,因此给了系统更多的选择,可以在性能合理的范围内兼顾其他特性。在所有安全性问题中,内存漏洞发生的入侵占到了半数以上,因此如果能在Allocator中抵御入侵,那将极大地降低安全问题的数量。需要注意的是,单从性能角度分析Scudo未必超过jemalloc,虽然它的分配策略更加简化,但为了安全性所必须分配的chunk header会使其丧失一些性能。

目录

1. 概述

Android中有超过半数的漏洞都来源于内存错误,因此减少非法攻击的重点就要放在对内存的防御上。常见的内存错误有heap-overflow,double-free,use-after-free等。针对这些错误Scudo主要采用了两种手段进行防御,一种是随机化,让攻击者无法定向攻击某些内存块;另一种是给每个内存块增加chunk header,在分配/释放等操作时检查header的正确性,防止非法的内存篡改。Chunk header的大小为8字节,但由于64位Scudo需要16字节对齐,所以它实际占用的空间为16字节。

Scudo中的分配器有两种类型,当需求小于256K时使用Primary Allocator,大于256K时使用Secondary Allocator。

2. 具体实现

2.1 Primary Allocator

Primary Allocator中内存会分成不同的区域,每块区域只能分配出固定空间,譬如区域1只能分配32bytes(包含header),区域2只能分配48bytes。当我们分配小内存时,首先会检查最合适区域中是否有空闲位置,如果没有,则会去高一级区域中分配。当最高一级(38)区域中也没有合适的空闲空间时,这时将会使用Secondary Allocator进行分配。

2.1.1 内存分布

对于64位Android R而言,Primary Allocator会分成39个区域等级(Class)。Class 0用于存放内存管理的元数据,Class 1~38用于满足用户空间的分配需求。每一块区域为256M,其头部会随机空缺出0~16页以降低定向攻击的风险。以下为每块区域具体分配的大小。

Class ID12345678910
原始大小(bytes)3248648096112144176192224
去除Chunk Header大小163248648096128160176208
Class ID11121314151617181920
原始大小(bytes)28835244859280011041648209625763120
去除Chunk Header大小27233643257678410881632208025603104
Class ID212223242526272829
原始大小(bytes)41124624712087201166414224164001844823056
去除Chunk Header大小4096460871048704116481420816K18K22.5K
Class ID303132333435363738
原始大小(bytes)29456332966555298320131088163856196624229392262160
去除Chunk Header大小28.75K32.5K64K96K128K160K192K224K256K

在Scudo初始化阶段,系统会mmap出256M*39大小的空间。但由于声明了PORT_NONE权限,所以此时还无法访问。等到具体分配产生时,系统才会将部分区域改为PROT_READ|PROT_WRITE。

2.1.2 分配策略

在分配过程中需要重点考虑两个问题:

  1. 哪一个Class的那一块内存(chunk)可用于分配?
  2. 当多线程并发分配时,如何处理竞争关系?

一个Region中已分配和未分配的内存块,经过一段时间后必定参差交错。因此为了追踪未分配的内存块,Allocator中需要有指针来指向那些内存块。这些指针的数量最好可以按需增长,因此可以把它们设计成批量模式(也即源码中的TransferBatch),一个Batch中包含14个指针,不够时再创建新的Batch对象。这些Batch对象构建成单向链表,也即Region的FreeList。

TSD(Thread Specific Data)机制

如果是单线程运行,上面的设计已经足够了。但对于并发分配而言,如何解决竞争问题将显得十分重要。最简单的做法是加锁,但加锁会牺牲性能,对于“分配”这类高频操作显然不是最优解。既然每个Batch包含的指针不同,那让并发线程使用不同的Batch对象是不是就可以解决问题呢?这种方式就是下图中的TSD(Thread Specific Data)机制。

不过为每个线程都分配一个仅供自己使用的Batch是不经济的(A线程Batch中空间的内存无法被B线程使用),尤其是有些进程创建了一百多个线程。因此上述的TSD机制还增加了TSD pool来控制私有Batch的数量,并且采用轮询制保证每个线程尽快地完成分配。但这种方式在并发压力过大时依然免不了等待,它其实是平衡了性能和内存占用后做出的选择。

对于64位的Android R而言,TSD pool中只有两个TSD对象。每一个TSD对象含有一个SizeClassAllocatorLocalCache对象,其内部为每个class region都创建了PerClass对象。通过PerClass的chunks数组,便可以找到空闲chunk的地址。

上述的Round-robin(轮询)算法也十分有趣。它会在持有TSD对象时记录单调时间,如果当前两个TSD对象正在被其他线程使用,该线程会等待TSD持有时间更久的那个对象。因为持有更久,代表有更大可能被提早释放。

Cache机制

Primary Allocator中使用了Cache机制来加速分配。当一个线程需要分配内存时,它会通过TSD对象指向的Chunks数组来寻找合适的空闲内存。但Chunks数组的大小是有限的(28),当它们用完时就需要补充弹药了。补充弹药不需要操作class region来扩张空闲区域,而是从region的FreeList(FreeList的详细数据存放于class 0 region)中直接获取。当FreeList中的空闲对象不够时,才会最终扩张class region的空闲区域。

这个过程有点像CPU的多级缓存,详见下图。

2.1.3 释放策略

当PerClass中的Chunks数组全都是空闲指针时,需要将其中一半(14)指针退还给region的FreeList。当region的FreeList中TransferBatch过多时,需要将空闲的页解除物理映射。但对于分配内存较小的region(譬如class 1~6),由于一页中chunk数量过多,很难凑齐一页中全是free chunk的情况,因此干脆不进行物理映射解除的动作。

对需要解除物理映射的region,其采用的是madvise系统调用。

external/scudo/standalone/linux.cpp

86 void releasePagesToOS(uptr BaseAddress, uptr Offset, uptr Size,
87                       UNUSED MapPlatformData *Data) {
88   void *Addr = reinterpret_cast<void *>(BaseAddress + Offset);
89   while (madvise(Addr, Size, MADV_DONTNEED) == -1 && errno == EAGAIN) {
90   }
91 }
复制代码

通过MADV_DONTNEED参数告知系统这些物理页可以被回收。

MADV_DONTNEED

Do not expect access in the near future. (For the time being, the application is finished with the given range, so the kernel can free resources associated with it.)

2.2 Secondary Allocator

Secondary Allocator主要用于大内存的分配(>256K),直接采用mmap分配出一块新的VMA(Virtual Memory Area)。但是为了效率,它也设计了一个Cache。其内部最多可以缓存32个不超过2M的VMA。

没有放进缓存的VMA在释放时直接采用unmap的方式。

external/scudo/standalone/secondary.h

164  void empty() {
165    struct {
166      void *MapBase;
167      uptr MapSize;
168      MapPlatformData Data;
169    } MapInfo[MaxEntriesCount];
170    uptr N = 0;
171    {
172      ScopedLock L(Mutex);
173      for (uptr I = 0; I < MaxEntriesCount; I++) {
174        if (!Entries[I].Block)
175          continue;
176        MapInfo[N].MapBase = reinterpret_cast<void *>(Entries[I].MapBase);
177        MapInfo[N].MapSize = Entries[I].MapSize;
178        MapInfo[N].Data = Entries[I].Data;
179        Entries[I].Block = 0;
180        N++;
181      }
182      EntriesCount = 0;
183      IsFullEvents = 0;
184    }
185    for (uptr I = 0; I < N; I++)
186      unmap(MapInfo[I].MapBase, MapInfo[I].MapSize, UNMAP_ALL,
187            &MapInfo[I].Data);
188  }
复制代码

好奇的朋友可能会问道:为什么Primary Allocator在释放物理内存时采用的是madvise,而这里采用的是unmap?

这是因为madvise只解除了虚拟地址和物理空间的映射关系,但并没有释放虚拟空间(虚拟地址无法被其他人使用)。而unmap不仅解除了映射关系,也释放了虚拟空间。当madvise解除映射关系以后,这块内存依旧可以访问,但获取到的将是一页新的物理内存,而unmap的区域再次访问时将会出错。Primary Allocator是在一整块VMA上自行进行管理,因此无需释放虚拟空间,而Secondary Allocator则是为每一次分配都单独创建VMA,不做管理。

2.3 检测机制

为了保证每一个chunk区域的完整性,Scudo增加了header检测机制,一方面可以检测内存踩踏和多次释放,另一方面也阻止了野指针的访问。

external/scudo/standalone/chunk.h

65 struct UnpackedHeader {
66   uptr ClassId : 8;
67   u8 State : 2;  // Available = 0, Allocated = 1, Quarantined = 2 
68   u8 Origin : 2;  // Origin表示通过哪种方式发生的分配,譬如是new,malloc还是realloc
69   uptr SizeOrUnusedBytes : 20;  //这个chunk中实际被使用的大小
70   uptr Offset : 16;
71   uptr Checksum : 16;  //用于检测header是否被破坏
72 };
复制代码

上面是Chunk header的详细信息,后面的数字代表每个字段所占用的bit,加起来为64bits,也即8字节。Checksum字段用于检测chunk的完整性,其生成过程如下所示。其中校验和用的是较为简单的CRC算法,这主要是基于性能的考虑。但是目前这种校验方式已经有了破解手段,估计谷歌后续会升级这块的算法吧。

在一个chunk释放时,该地址需要经过重重检测,以保证它在使用过程中是未经破坏的。下面按时间顺序列举出一个chunk需要经过的检测。

external/scudo/standalone/combined.h

423     if (UNLIKELY(!isAligned(reinterpret_cast<uptr>(Ptr), MinAlignment)))
424       reportMisalignedPointer(AllocatorAction::Deallocating, Ptr);
复制代码
  • 地址必须16字节对齐,如果是一个未经对齐的long型数字被错误当成了指针,这里就可以检测出来。

external/scudo/standalone/chunk.h

124   if (UNLIKELY(NewUnpackedHeader->Checksum !=
125                computeHeaderChecksum(Cookie, Ptr, NewUnpackedHeader)))
126     reportHeaderCorruption(const_cast<void *>(Ptr));
复制代码
  • Checksum数字在deallocate时会再计算一遍,和Header中保存的checksum进行比较。如果二者不相等,则意味着header被破坏,或者它根本不是一个header。

external/scudo/standalone/combined.h

431     if (UNLIKELY(Header.State != Chunk::State::Allocated))
432       reportInvalidChunkState(AllocatorAction::Deallocating, Ptr);
复制代码
  • 如果header的state不为Allocated,表明此时不应该释放这块内存,这很有可能是一个double-free的错误。

external/scudo/standalone/combined.h

433     if (Options.DeallocTypeMismatch) {
434       if (Header.Origin != Origin) {
435         // With the exception of memalign'd chunks, that can be still be free'd.
436         if (UNLIKELY(Header.Origin != Chunk::Origin::Memalign ||
437                      Origin != Chunk::Origin::Malloc))
438           reportDeallocTypeMismatch(AllocatorAction::Deallocating, Ptr,
439                                     Header.Origin, Origin);
440       }
441     }
复制代码
  • 如果allocate时的方法和deallocate的方法不匹配,也会报错(前提是打开DeallocTypeMismatch选项)。譬如一块内存通过new申请,却使用free去释放(应该使用delete)。

external/scudo/standalone/combined.h

444     if (DeleteSize && Options.DeleteSizeMismatch) {
445       if (UNLIKELY(DeleteSize != Size))
446         reportDeleteSizeMismatch(Ptr, DeleteSize, Size);
447     }
复制代码
  • 如果deallocate时的size和chunk中的size不相等,报错。

以下是Android Scudo官方文档中对于典型错误信息的分析,其中不少都可以和上述的检测环节关联起来。

source.android.com/devices/tec…

  • corrupted chunk header:区块头的校验和验证失败。可能原因有二:区块头被部分或全部覆盖,也可能是传递给函数的指针不是区块。
  • race on chunk header:两个不同的线程会同时尝试操控同一区块头。这种症状通常是在对该区块执行操作时出现争用情况或通常未进行锁定造成的。
  • invalid chunk state:对于指定操作,区块未处于预期状态,例如,在尝试释放区块时其处于未分配状态,或者在尝试回收区块时其未处于隔离状态。双重释放是造成此错误的典型原因。
  • misaligned pointer:强制执行基本对齐要求:32 位平台上为 8 个字节,64 位平台上为 16 个字节。如果传递给函数的指针不适合这些函数,传递给其中一个函数的指针就不会对齐。
  • allocation type mismatch:启用此选项后,在区块上调用的取消分配函数必须与用于分配区块而调用的函数类型一致。类型不一致会引发安全问题。
  • invalid sized delete:如果使用的是符合 C++14 标准的删除运算符,在启用可选检查之后,取消分配区块时传递的大小与分配区块时请求的大小会出现不一致的情况。这通常是由于编译器出现问题或是对要取消分配的对象产生了类型混淆
  • RSS limit exhausted:已超出选择性指定的 RSS 大小上限。

3. 总结

本文重点阐述了Scudo的核心原理。

和以往的写作方式不同,这一次我尽力删减了繁杂的源码解读,而是将核心概念绘制成图。通过图片理解,我想可能会更加清楚。此外,关于Scudo的配置和使用并不是本文的重点,因为官方文档中写的已经足够清楚,我就没有必要再狗尾续貂了。

文章分类
Android
文章标签