Scudo 加固分配器¶
简介¶
Scudo 加固分配器是一个用户模式分配器,最初基于 LLVM Sanitizers 的 CombinedAllocator。它旨在提供额外的缓解措施来抵御基于堆的漏洞,同时保持良好的性能。Scudo 目前是 Fuchsia 中的默认分配器,并且从 Android 11 开始在 Android 中也是默认分配器。
名称“Scudo”源自意大利语中“盾牌”(以及西班牙语中的 Escudo)。
设计¶
分配器¶
Scudo 在设计时就考虑了安全性,但旨在在安全性和性能之间取得良好的平衡。它被设计为高度可调和可配置的,虽然我们提供了一些默认配置,但我们鼓励用户根据自己的使用案例来确定最合适的参数。
分配器结合了几个用于不同目的的组件
主分配器:快速高效,它通过将保留的内存区域分割成相同大小的块来服务较小的分配大小。目前实现了两个主分配器,分别针对 32 位和 64 位架构。可以通过编译时选项进行配置。
次要分配器:速度较慢,它通过底层操作系统的内存映射原语来服务较大的分配大小。次要支持的分配周围环绕着保护页。它也可以通过编译时选项进行配置。
线程特定数据注册表:定义每个线程的本地缓存如何操作。目前实现了两种模型:独占模型,其中每个线程拥有自己的缓存(使用 ELF TLS);或共享模型,其中线程共享一个固定大小的缓存池。
隔离区:提供了一种延迟释放操作的方法,防止块立即可用以重用。保留的块将在达到特定大小条件后进行回收。这本质上是一个延迟的空闲列表,可以帮助缓解一些使用后释放的情况。此功能在性能和内存占用方面代价相当高,主要由运行时选项控制,默认情况下处于禁用状态。
分配头¶
分配器返回给应用程序的每个堆内存块之前都会有一个头。这有两个目的
存储有关块的各种信息,这些信息可以用来确保堆操作的一致性;
能够检测潜在的损坏。为此,对头进行校验和计算,并且在访问该头时将检测到头的损坏(请注意,如果未访问损坏的头,则损坏将保持未检测状态)。
头中存储以下信息
该块的类 ID,用于标识主支持的分配中块所在的区域,或次要支持的分配中的 0;
块的状态(可用、已分配或已隔离);
分配类型(malloc、new、new[] 或 memalign),以检测分配 API 使用中潜在的不匹配;
该块的大小(主)或未使用的字节量(次要),这对于重新分配或大小释放操作是必要的;
块的偏移量,即从返回块的开头到后端分配(“块”)开头的字节距离;
16 位校验和;
此头在所有支持的平台上都适合 8 个字节,并为每个分配带来少量开销。
校验和使用全局密钥、块指针本身以及校验和字段清零的 8 字节头的 CRC32(通过硬件支持加速)计算。它并非旨在具有加密强度。
头以原子方式加载和存储以防止竞争条件。这很重要,因为两个连续的块可能属于不同的线程。我们处理本地副本并使用比较交换原语来更新堆内存中的头,并避免任何类型的双重获取。
随机性¶
随机性是分配器提供的额外安全性的关键因素。分配器信任操作系统的内存映射原语在内存中(大部分)不可预测的位置提供页面,以及使用 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 |
调整分配器对内存标签的选择,使其更有可能检测到特定类别的内存错误。值参数应为 |
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 上的参数检查相关,并且很容易理解。