GWP-ASan

简介

GWP-ASan 是一种采样分配器框架,有助于在生产环境中查找释放后使用和堆缓冲区溢出错误。它非正式地是一个递归缩写,“GWP-ASan Will Provide Allocation SANity”。

GWP-ASan 基于经典的 Electric Fence Malloc 调试器,并进行了关键的改编。值得注意的是,我们只选择一小部分分配进行采样,并仅对这些采样分配应用保护页。采样量足够小,可以使我们获得非常低的性能开销。

存在一个小的、可调整的内存开销,该开销在进程的生命周期内是固定的。使用默认设置,每个进程大约为 ~40KiB,具体取决于分配的平均大小。

GWP-ASan 与 ASan 的比较

AddressSanitizer 不同,GWP-ASan 不会产生明显的性能开销。ASan 通常需要使用专用的金丝雀才能在生产环境中有效,因此通常不切实际。

GWP-ASan 只能发现 ASan 检测到的一部分内存问题。此外,GWP-ASan 的错误检测功能只是概率性的。因此,我们建议在测试中使用 ASan 而不是 GWP-ASan,以及在保证错误检测比 2 倍执行速度下降/二进制文件大小膨胀更有价值的任何其他地方。对于大多数生产环境,这种影响过高,而 GWP-ASan 证明非常有用。

设计

请注意:GWP-ASan 的实现很大程度上处于变化之中,这些细节可能会发生变化。目前还有其他 GWP-ASan 的实现,例如 Chromium 中提供的实现。长期的支持目标是在合理的情况下确保功能一致性,并支持 compiler-rt 作为参考实现。

分配器支持

GWP-ASan 不是传统分配器的替代品。相反,它的工作原理是将存根插入支持分配器中,以便在选择分配进行采样时将其重定向到 GWP-ASan。这些存根通常在 malloc()free()realloc() 的实现中实现。存根非常小,这使得在大多数分配器中使用 GWP-ASan 相当简单。存根遵循相同的通用模式(以下为 malloc() 伪代码示例)

#ifdef INSTALL_GWP_ASAN_STUBS
  gwp_asan::GuardedPoolAllocator GWPASanAllocator;
#endif

void* YourAllocator::malloc(..) {
#ifdef INSTALL_GWP_ASAN_STUBS
  if (GWPASanAllocator.shouldSample(..))
    return GWPASanAllocator.allocate(..);
#endif

  // ... the rest of your allocator code here.
}

然后,所有支持的分配器需要做的就是使用 -DINSTALL_GWP_ASAN_STUBS 进行编译并链接到 GWP-ASan 库!出于性能原因,我们强烈建议静态链接 GWP-ASan 库。

受保护的分配池

GWP-ASan 的核心是受保护的分配池。每个采样分配都使用其自己的受保护的槽进行备份,该槽可能包含一个或多个可访问页面。每个受保护的槽都由两个保护页包围,这些页被映射为不可访问。所有受保护槽的集合构成了受保护的分配池

缓冲区下溢/溢出检测

我们通过这些保护页获得缓冲区溢出和缓冲区下溢检测。当内存访问超出分配的缓冲区时,它将接触不可访问的保护页,从而导致内存异常。此异常由内部崩溃处理程序捕获并处理。由于每个分配都记录了有关其分配和释放位置(以及由哪个线程分配和释放)的元数据,因此我们可以提供有助于识别错误根本原因的信息。

分配被随机选择为左对齐或右对齐,以提供对下溢和溢出的相同检测。

释放后使用检测

受保护的分配池还提供释放后使用检测。每当采样分配被释放时,我们都会将其受保护的槽映射为不可访问。因此,释放后任何内存访问都将触发崩溃处理程序,并且我们可以提供有关错误来源的有用信息。

请注意,采样分配的释放后使用检测是短暂的。为了在保持内存开销固定的同时仍然检测错误,已释放的槽将被随机重用以保护将来的分配。

用法

GWP-ASan 已经在 Scudo 加固分配器 中默认提供,因此使用 -fsanitize=scudo 构建是尝试 GWP-ASan 的最快捷和最简单的方法。

选项

GWP-ASan 的配置由支持的分配器管理。我们提供了一个通用的配置管理库,由 Scudo 使用。它允许通过以下方法配置 GWP-ASan 的几个方面

  • 在编译 GWP-ASan 库时,通过将 -DGWP_ASAN_DEFAULT_OPTIONS 设置为您希望默认设置的选项字符串。如果您正在将 GWP-ASan 作为 compiler-rt/LLVM 构建的一部分进行构建,请在 cmake 配置时添加它(例如 cmake ... -DGWP_ASAN_DEFAULT_OPTIONS="...")。如果您在 compiler-rt 之外构建 GWP-ASan,只需确保在构建 optional/options_parser.cpp 时指定 -DGWP_ASAN_DEFAULT_OPTIONS="..."

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

  • 取决于分配器支持(Scudo 支持此机制):通过环境变量,包含要解析的选项字符串。在 Scudo 中,这是通过 SCUDO_OPTIONS=GWP_ASAN_${OPTION_NAME}=${VALUE}(例如 SCUDO_OPTIONS=GWP_ASAN_SampleRate=100)实现的。以这种方式定义的选项将覆盖通过 __gwp_asan_default_options 进行的任何定义。

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

例如,使用环境变量

GWP_ASAN_OPTIONS="MaxSimultaneousAllocations=16:SampleRate=5000" ./a.out

或使用函数

extern "C" const char *__gwp_asan_default_options() {
  return "MaxSimultaneousAllocations=16:SampleRate=5000";
}

以下选项可用

选项

默认值

描述

已启用

true

GWP-ASan 是否已启用?

PerfectlyRightAlign

false

当分配右对齐时,我们是否应该将其完美地对齐到页面边界?默认情况下(false),出于性能原因,我们将分配大小向上舍入到最接近的 2 的幂(2、4、8、16),最大对齐到 16 字节。将其设置为 true 可以找到单个字节的缓冲区溢出,但会以性能为代价,并且可能与某些架构不兼容。

MaxSimultaneousAllocations

16

池中可用的同时受保护分配的数量。

SampleRate

5000

页面被选中进行 GWP-ASan 采样的概率 (1 / SampleRate)。支持高达 (2^31 - 1) 的采样率。

InstallSignalHandlers

true

在动态加载期间为 SIGSEGV 安装 GWP-ASan 信号处理程序。这允许通过在报告内存错误时提供分配和释放的堆栈跟踪来提供更好的错误报告。GWP-ASan 的信号处理程序会将信号转发到任何先前安装的处理程序,并且安装更多信号处理程序的用户程序应确保它们也这样做。注意,如果先前安装的 SIGSEGV 处理程序是 SIG_IGN,我们将在转储错误报告后终止进程。

示例

以下代码存在释放后使用错误,其中 string_view 被创建为 string+ 运算符的临时结果的引用。释放后使用发生在第 8 行对 sv 进行解引用时。

1: #include <iostream>
2: #include <string>
3: #include <string_view>
4:
5: int main() {
6:   std::string s = "Hellooooooooooooooo ";
7:   std::string_view sv = s + "World\n";
8:   std::cout << sv;
9: }

使用 Scudo+GWP-ASan 编译此代码将概率性地捕获此错误并为我们提供详细的错误报告

$ clang++ -fsanitize=scudo -g buggy_code.cpp
$ for i in `seq 1 500`; do
    SCUDO_OPTIONS="GWP_ASAN_SampleRate=100" ./a.out > /dev/null;
  done
|
| *** GWP-ASan detected a memory error ***
| Use after free at 0x7feccab26000 (0 bytes into a 41-byte allocation at 0x7feccab26000) by thread 31027 here:
|   ...
|   #9 ./a.out(_ZStlsIcSt11char_traitsIcEERSt13basic_ostreamIT_T0_ES6_St17basic_string_viewIS3_S4_E+0x45) [0x55585c0afa55]
|   #10 ./a.out(main+0x9f) [0x55585c0af7cf]
|   #11 /lib/x86_64-linux-gnu/libc.so.6(__libc_start_main+0xeb) [0x7fecc966952b]
|   #12 ./a.out(_start+0x2a) [0x55585c0867ba]
|
| 0x7feccab26000 was deallocated by thread 31027 here:
|   ...
|   #7 ./a.out(main+0x83) [0x55585c0af7b3]
|   #8 /lib/x86_64-linux-gnu/libc.so.6(__libc_start_main+0xeb) [0x7fecc966952b]
|   #9 ./a.out(_start+0x2a) [0x55585c0867ba]
|
| 0x7feccab26000 was allocated by thread 31027 here:
|   ...
|   #12 ./a.out(main+0x57) [0x55585c0af787]
|   #13 /lib/x86_64-linux-gnu/libc.so.6(__libc_start_main+0xeb) [0x7fecc966952b]
|   #14 ./a.out(_start+0x2a) [0x55585c0867ba]
|
| *** End GWP-ASan report ***
| Segmentation fault

为了符号化这些堆栈跟踪,需要谨慎处理。Scudo 目前使用 GNU 的 backtrace_symbols()(来自 <execinfo.h>)进行展开。展开器以 function+offset 形式提供人类可读的堆栈跟踪,而不是正常的 binary+offset 形式。为了使用 addr2line 或类似工具恢复确切的行号,我们必须将 function+offset 转换为 binary+offset。一个辅助脚本位于 compiler-rt/lib/gwp_asan/scripts/symbolize.sh。使用此脚本将尝试符号化每条可能的行,如果任何操作失败,则回退到先前的输出。这将产生以下输出

$ cat my_gwp_asan_error.txt | symbolize.sh
|
| *** GWP-ASan detected a memory error ***
| Use after free at 0x7feccab26000 (0 bytes into a 41-byte allocation at 0x7feccab26000) by thread 31027 here:
| ...
| #9 /usr/lib/gcc/x86_64-linux-gnu/8.0.1/../../../../include/c++/8.0.1/string_view:547
| #10 /tmp/buggy_code.cpp:8
|
| 0x7feccab26000 was deallocated by thread 31027 here:
| ...
| #7 /tmp/buggy_code.cpp:8
| #8 /lib/x86_64-linux-gnu/libc.so.6(__libc_start_main+0xeb) [0x7fecc966952b]
| #9 ./a.out(_start+0x2a) [0x55585c0867ba]
|
| 0x7feccab26000 was allocated by thread 31027 here:
| ...
| #12 /tmp/buggy_code.cpp:7
| #13 /lib/x86_64-linux-gnu/libc.so.6(__libc_start_main+0xeb) [0x7fecc966952b]
| #14 ./a.out(_start+0x2a) [0x55585c0867ba]
|
| *** End GWP-ASan report ***
| Segmentation fault