Scudo 强化分配器

简介

Scudo 强化分配器是一个用户模式分配器,最初基于 LLVM Sanitizers 的 CombinedAllocator。它的目标是在保持良好性能的同时,提供额外的缓解措施来对抗基于堆的漏洞。Scudo 目前是 Fuchsia 和自 Android 11 起 Android 中的默认分配器。

“Scudo” 这个名字来源于意大利语单词 shield(在西班牙语中为 Escudo),意为“盾牌”。

设计

分配器

Scudo 的设计考虑了安全性,但目标是在安全性和性能之间取得良好的平衡。它被设计为高度可调和可配置的,虽然我们提供了一些默认配置,但我们鼓励用户提出最适合其用例的参数。

该分配器结合了几个服务于不同目的的组件

  • 主分配器:快速高效,它通过将保留的内存区域划分为相同大小的块来服务于较小的分配大小。目前实现了两个主分配器,分别针对 32 位和 64 位架构。它可以通过编译时选项进行配置。

  • 辅助分配器:速度较慢,它通过底层操作系统的内存映射原语来服务于较大的分配大小。辅助支持的分配被保护页包围。它也可以通过编译时选项进行配置。

  • 线程特定数据注册表:定义了每个线程的本地缓存如何操作。目前实现了两种模型:独占模型,其中每个线程拥有自己的缓存(使用 ELF TLS);或共享模型,其中线程共享固定大小的缓存池。

  • 隔离区:提供了一种延迟释放操作的方法,防止块立即可用于重用。一旦达到某些大小标准,持有的块将被回收。这本质上是一个延迟空闲列表,可以帮助缓解一些用后释放的情况。此功能在性能和内存占用方面相当昂贵,主要由运行时选项控制,默认情况下禁用。

分配头

分配器返回给应用程序的每个堆内存块都将以一个头开始。这有两个目的

  • 存储关于块的各种信息,这些信息可以用来确保堆操作的一致性;

  • 能够检测潜在的损坏。为此,头部被校验和,并且当访问所述头部时,将检测到头部的损坏(请注意,如果未访问损坏的头部,则损坏将保持未检测到)。

以下信息存储在头部中

  • 该块的类 ID,它标识了主分配支持的分配的块所在的区域,或者对于辅助分配支持的分配为 0;

  • 块的状态(可用、已分配或已隔离);

  • 分配类型(malloc、new、new[] 或 memalign),用于检测分配 API 中潜在的不匹配;

  • 该块的大小(主分配)或未使用的字节数(辅助分配),这对于重新分配或按大小释放操作是必要的;

  • 块的偏移量,它是从返回的块的开始到后端分配(“块”)的开始的字节距离;

  • 16 位校验和;

此头部在所有支持的平台上都适合 8 个字节,并为每个分配贡献了少量开销。

校验和是使用 CRC32(在硬件支持下更快)计算的,计算对象包括全局密钥、块指针本身以及头部的前 8 个字节(校验和字段置零)。它不打算具有密码学上的强度。

头部是原子加载和存储的,以防止竞争。这很重要,因为两个连续的块可能属于不同的线程。我们在本地副本上工作,并使用比较交换原语来更新堆内存中的头部,并避免任何类型的双重获取。

随机性

随机性是分配器提供的额外安全性的关键因素。分配器信任操作系统的内存映射原语在内存中提供(大部分)不可预测的位置的页面,以及使用 ASLR 编译的二进制文件。如果这些假设之一不正确,则安全性将大大降低。Scudo 进一步随机化了块在主分配器中的分配方式,并可以随机化缓存分配给线程的方式。

内存回收

主分配器和辅助分配器在回收方面具有不同的行为。虽然辅助映射的分配可以在释放时取消映射,但主分配器的情况并非如此,这可能导致进程的 RSS 稳定增长。为了抵消这一点,如果底层操作系统允许,则可以释放主分配器中连续空闲内存块覆盖的页面(这通常意味着它们不会计入进程的 RSS,并在后续访问时填充零)。这在释放路径中完成,并且存在多个选项来调整此行为。

用法

平台

如果使用 Fuchsia 或 Android 版本高于 11,则您的内存分配已由 Scudo 服务(请注意,Android Svelte 配置仍使用 jemalloc)。

静态分配器库可以从 LLVM 树构建,这要归功于 scudo_standalone CMake 规则。相关的测试可以通过 check-scudo_standalone CMake 规则来执行。

将静态库链接到您的项目可能需要使用 whole-archive 链接器标志(或等效标志),具体取决于您的链接器。也可能需要额外的标志。

您链接的二进制文件现在应该使用 Scudo 分配和释放函数。

您也可以像这样构建 Scudo

cd $LLVM/compiler-rt/lib
clang++ -fPIC -std=c++17 -msse4.2 -O2 -pthread -shared \
  -I scudo/standalone/include \
  scudo/standalone/*.cpp \
  -o $HOME/libscudo.so

然后像这样将其与现有二进制文件一起使用

LD_PRELOAD=$HOME/libscudo.so ./a.out

Clang

使用最近版本的 Clang(rL317337 之后),如果目标平台受支持,则可以使用命令行参数 -fsanitize=scudo 在编译时将分配器的“旧”版本与二进制文件链接。目前,Scudo 唯一兼容的 sanitizer 是 UBSan(例如:-fsanitize=scudo,undefined)。使用 Scudo 编译还将对输出二进制文件强制执行 PIE。

我们将在未来将其过渡到独立的 Scudo 版本。

选项

分配器的几个方面可以通过以下方式在每个进程的基础上进行配置

  • 在编译时,通过定义 SCUDO_DEFAULT_OPTIONS 为您想要默认设置的选项字符串;

  • 通过在程序中定义一个 __scudo_default_options 函数,该函数返回要解析的选项字符串。所述函数必须具有以下原型:extern "C" const char* __scudo_default_options(void),具有默认可见性。这将覆盖编译时定义;

  • 通过环境变量 SCUDO_OPTIONS,其中包含要解析的选项字符串。以此方式定义的选项将覆盖通过 __scudo_default_options 所做的任何定义。

  • 通过标准 mallopt API,使用 Scudo 特定的参数。

在处理选项字符串时,它遵循类似于 ASan 的语法,其中不同的选项可以在同一字符串中分配,并用冒号分隔。

例如,使用环境变量

SCUDO_OPTIONS="delete_size_mismatch=false:release_to_os_interval_ms=-1" ./a.out

或使用函数

extern "C" const char *__scudo_default_options() {
  return "delete_size_mismatch=false:release_to_os_interval_ms=-1";
}

以下“字符串”选项可用

选项

默认值

描述

quarantine_size_kb

0

用于延迟块实际释放的隔离区的大小(以 Kb 为单位)。较低的值可能会减少内存使用量,但会降低缓解措施的有效性;负值将回退到默认值。将此项和 thread_local_quarantine_size_kb 都设置为零将完全禁用隔离区。

quarantine_max_chunk_size

0

可以隔离的块的最大大小(以字节为单位)。

thread_local_quarantine_size_kb

0

每个线程缓存的大小(以 Kb 为单位),用于卸载全局隔离区。较低的值可能会减少内存使用量,但可能会增加全局隔离区上的争用。将此项和 quarantine_size_kb 都设置为零将完全禁用隔离区。

dealloc_type_mismatch

false

是否报告 malloc/delete、new/free、new/delete[] 等的错误。

delete_size_mismatch

true

是否报告 new 和 delete 的大小不匹配的错误。

zero_contents

false

是否在分配时将块内容清零。

pattern_fill_contents

false

是否在分配时用字节模式填充块内容。

may_return_null

true

非致命性故障是否可以返回 NULL 指针(而不是终止)。

release_to_os_interval_ms

5000

可以尝试释放的最小间隔(以毫秒为单位)(负值禁用回收)。

allocation_ring_buffer_size

32768

如果请求堆栈跟踪收集,则在分配环形缓冲区中保留多少个先前的分配。

此缓冲区用于为 MTE 故障报告提供分配和释放堆栈跟踪。缓冲区越大,在(释放)分配和故障之间可能发生的无关分配就越多。如果您的同步模式 MTE 故障没有(释放)分配堆栈跟踪,请尝试增加缓冲区大小。

可以使用 scudo_malloc_set_track_allocation_stacks 函数请求堆栈跟踪收集。

可以指定其他标志,例如,如果 Scudo 是使用 GWP-ASan 支持编译的。

以下“mallopt”选项可用(选项在 include/scudo/interface.h 中定义)

选项

描述

M_DECAY_TIME

将释放间隔选项设置为指定值(Android 仅允许 0 或 1 分别将间隔设置为编译时指定的最小值和最大值)。

M_PURGE

强制立即内存回收,但不回收所有内容。对于较小的大小类,由于需要额外的时间以及可以回收的内存量较少,因此仍然有一些内存未被回收。该值被忽略。

M_PURGE_ALL

与 M_PURGE 相同,但将强制释放所有可能的内存,无论花费多长时间。该值被忽略。

M_MEMTAG_TUNING

调整分配器对内存标签的选择,以使其更有可能检测到特定类别的内存错误。value 参数应为 scudo_memtag_tuning 的枚举器之一。

M_THREAD_DISABLE_MEM_INIT

调整每个线程的内存初始化,0 为正常行为,1 为禁用自动堆初始化。

M_CACHE_COUNT_MAX

设置辅助缓存中可以缓存的最大条目数。

M_CACHE_SIZE_MAX

设置辅助缓存中可以缓存的最大条目大小。

M_TSDS_COUNT_MAX

增加可以使用的 TSD 的最大数量,直到编译时指定的限制。

错误类型

当检测到意外行为时,分配器将输出错误消息,并可能终止进程。输出通常以 "Scudo ERROR:" 开头,后跟发生的问题的简短摘要以及涉及的指针。再次强调,Scudo 旨在作为一种缓解措施,可能不是帮助您找出问题根本原因的最有用的工具,请考虑为此目的使用 ASan

以下是当前错误消息及其潜在原因的列表

  • "corrupted chunk header":块头部的校验和验证失败。这可能是由以下两种情况之一引起的:头部被覆盖(部分或全部),或者传递给函数的指针根本不是块;

  • "race on chunk header":两个不同的线程正在尝试同时操作同一个头部。这通常是竞争条件或在对该块执行操作时普遍缺乏锁定的症状;

  • "invalid chunk state":块不处于给定操作的预期状态,例如:在尝试释放它时它未被分配,或者在尝试回收它时它未被隔离等。双重释放是此错误发生的典型原因;

  • "misaligned pointer":我们强烈执行基本对齐要求,32 位平台为 8 字节,64 位平台为 16 字节。如果传递给我们函数的指针不符合这些要求,则肯定有问题。

  • "allocation type mismatch":当启用可选的释放类型不匹配检查时,在块上调用的释放函数必须与调用以分配它的函数类型匹配。这种不匹配的安全影响不一定明显,但在最佳情况下是情境性的;

  • "invalid sized delete":当使用 C++14 大小释放运算符并且启用可选检查时,这表明在释放块时传递的大小与分配时请求的大小不一致。这很可能是 编译器问题,就像 Intel C++ 编译器的情况一样,或者正在释放的对象上存在某种类型混淆;

  • "RSS limit exhausted":可选指定的最大 RSS 已超出;

其他几个错误消息与 libc 分配 API 上的参数检查有关,并且非常容易理解。