净化C++容器:ASan注解逐步指南
容器溢出检测
AddressSanitizer(ASan)是一个编译器插件,用于检测缓冲区溢出和使用后释放等内存错误。本文介绍如何为C++代码添加ASan注解以发现更多缺陷,并展示我们在GCC和LLVM中的ASan工作。在LLVM中,我们为libc++的std::string和std::deque容器添加了注解,启用了容器注解的自定义分配器,并修复了libc++中的bug!
如我们在"理解AddressSanitizer"博文中提到的,ASan无法自动检测对已分配内存的无效访问。相反,它提供了一个API供用户将内存区域标记为可访问或不可访问。C++标准库利用这些API来注解STL容器,帮助ASan发现容器溢出错误。
图1展示了这一功能的实际应用,我们使用ASan编译且无优化(-O0 -fsanitize=address -D_GLIBCXX_SANITIZE_VECTOR标志)。clang++和g++都支持此功能。此外,如果使用libc++(-stdlib=libc++),可以省略GLIBCXX宏,因为libc++(LLVM的C++标准库)默认启用容器注解。
|
|
图1:容器溢出检测示例(注意:CompilerExplorer上未显示MSVC,因为它尚未安装ASan)
图2显示了运行此代码的结果,我们可以看到无效内存访问被检测为容器溢出错误(因为影子内存被"fc"字节污染)。
|
|
图2:ASan检测图1中的错误。输出已截断仅显示相关信息
然而,C++标准库对检测容器溢出的支持程度各不相同。下表总结了当前的支持情况。
库 | 已注解容器 | 注释 |
---|---|---|
libstdc++ (GCC) | std::vector (GCC 8) | 编译时需要-D_GLIBCXX_SANITIZE_VECTOR宏 |
libc++ (LLVM) | std::vector (LLVM 3.5), std::deque (LLVM17), long std::string (LLVM18) | 默认启用容器注解。可通过ASAN_OPTIONS=detect_container_overflow=0禁用 |
msvc++ | std::vector和std::string (VS 2022 17.2和17.6) | 默认启用。可通过-D_DISABLE_VECTOR_ANNOTATION -D_DISABLE_STRING_ANNOTATION禁用 |
AddressSanitizer API
推荐使用ASAN_POISON_MEMORY_REGION(addr, size)和ASAN_UNPOISON_MEMORY_REGION(addr, size)宏来注解内存,这些宏在影子内存中设置适当的值。(如果编译时未启用ASan,这些宏仅计算其参数而不调用注解函数)。
如图3所示,我们可以通过阅读底层_asan_poison_memory_region函数的文档字符串来了解使用ASAN_POISON_MEMORY_REGION宏的更多细节。
|
|
图3:描述__asan_poison_memory_region的注释
除了这些宏,asan_interface.h文件提供了允许自定义影子内存中设置的值并帮助注解某些容器的函数,例如__sanitizer_annotate_contiguous_container和__sanitizer_annotate_double_ended_contiguous_container函数。前者的文档如图4所示。
|
|
图4:描述__sanitizer_annotate_contiguous_container的注释
例如,在std::vector::pop_back操作期间使用此函数,将已移除元素的内存标记为不可访问,如图5所示。在底层,它用"fc"值污染影子内存,以"container-overflow"错误报告对相应内存地址的内存访问。
注意在pop_back中,必须在销毁元素后调用该函数,因为该内存变得不可访问。
逐步示例
在这里,我们基于一个具有有限接口的示例stack类,说明向容器添加ASan注解的正确方法。stack数据存储在连续缓冲区中,实现了图6所示的功能。stack的完整代码可在此处找到。
|
|
图6:简单stack类的声明
容器注解包装器
添加ASan注解的第一步是确定ASan API是否可用。如果不可用,在没有ASan的情况下编译时使用ASan函数会导致未定义引用链接器错误。为此,我们可以使用__has_feature预处理器宏为注解容器创建包装函数,如果在没有ASan的情况下编译,该函数将不执行任何操作。由于我们的stack数据保存在连续缓冲区中,我们将使用__sanitizer_annotate_contiguous_container函数进行注解。
|
|
图7:注解包装函数,将在我们的实现中使用
接下来,我们添加annotate_new和annotate_delete函数——前者在分配容器缓冲区后对其进行污染,后者在释放前解除污染。
|
|
图8:在新缓冲区分配后和缓冲区释放前更新容器注解的函数
接下来,我们需要创建辅助函数,在向容器添加或移除项时更新注解,如图10所示。
注意这些函数的具体细节取决于容器存储数据的方式。在具有一个移动端的容器(如vector或我们的stack)中,这些函数将简单地处理在添加对象之前和移除对象之后污染或解除污染内存。在其他情况下,此类辅助函数可能需要参数,例如旧大小或将要添加的对象数量。
|
|
图9:更新容器注解的辅助函数
注解容器
最后,我们在容器构造函数、析构函数和更新其基础大小或容量的方法中使用辅助函数。注意操作顺序非常重要。如果我们的代码在解除污染之前访问内存,ASan将检测到违规并崩溃。同样重要的是记住在释放之前解除污染内存,因为不同的内存分配器可能需要访问底层内存(因为它可能在分配缓冲区之前或内部存储一些元数据)。
缩小大小通常比增加大小更简单,因为缓冲区可以在增大大小时移动到新的内存区域。在我们的stack类中,我们在pop函数中污染一个已移除对象的内存,如图14所示。annotate_shrink函数必须在最后调用,在容器完全修改之后。
|
|
图10:默认构造函数和析构函数的实现;使用辅助函数更新ASan注解
为了管理push期间的缓冲区重新分配,我们使用图15所示的grow_buffer函数。此函数维护缓冲区的大小并确保正确注解新缓冲区和容量。因此,在函数执行结束时,对象被准确更新。这种方法简化了push操作,因为我们不再需要考虑多个缓冲区。无论容量是否改变,都足以解除新元素的内存污染,如图11所示。最后一点很重要;例如,我们在libc++的std::basic_string类的ABI函数中发现了一个问题,导致字符串大小不正确。这个问题被忽略了,因为该函数在相关上下文中从未使用,直到我们开始集成注解。然而,尽管我们创建了替代方案,这个问题将永远留在libc++ ABIv1中。虽然不太可能,但依赖该函数正确结果的字符串实现更改可能导致严重问题。
|
|
图11:将缓冲区更改为更大缓冲区的辅助函数的实现
实践中测试我们的注解
就这样,我们完成了!整个stack容器实现后,(几乎)每次对已分配内存的无效访问都会触发错误。我们可以使用main函数进行测试,如图18所示(完整源代码在此);在Clang++15上运行时,此函数给出图13所示的输出。
|
|
图12:访问已移除元素的程序实现
|
|
图13:ASan检测到的错误,针对图12中使用Clang++15编译的程序的容器溢出注解。因为容器中剩下3个元素(每个4字节),fa 00[04]fc在影子内存中,00描述前两个对象(8字节),04描述最后一个
我们如何改进容器注解
如前所述,我们对libc++中的C++容器注解进行了许多改进。我们详细介绍了在此过程中学到的一些经验教训,这些经验应有助于未来开发人员在其自定义容器和自定义分配器中实现注解。
Vector注解
通过我们的改进,当使用自定义内存分配器时,libc++中的std::vector容器由ASan注解,而以前它仅支持默认内存分配器。这是因为vector注解内部使用的__sanitizer_annotate_contiguous_container函数有限制,可能导致自定义内存分配器出现误报。我们在LLVM16中移除了这些限制,并在LLVM17中为自定义分配器启用了vector注解。
这些限制涉及缓冲区开始地址的对齐和要注解的最后一个粒度的排他性。前一个错误在此显示。由于ASan只能污染后缀,使用返回未对齐地址的分配器可能导致无法检测到对非污染前缀字节的无效访问实例。后一个排他性限制涉及消毒缓冲区结束于未对齐地址的情况,其中另一个对象开始;在这种情况下,ASan不会污染另一个对象的内存。
注意,虽然函数名为__sanitizer_annotate_contiguous_container,但它操作单个缓冲区。因此,命名起初可能有些令人困惑。如果容器有许多内存缓冲区,但每个缓冲区必须为空或其内容从一开始就存在,该函数仍可用于所有被视为单独容器的缓冲区。
控制容器注解
在极少数情况下,注解由自定义分配器分配的内存可能产生意外结果,例如不需要的ASan错误。此类错误可能包括区域分配器既不通过调用其析构函数也不手动解除已释放对象的内存污染。
有两种方法处理此类问题。理想情况下,分配器应更改为在再次分配之前解除整个内存的污染。或者,如果不可行,可以使用我们在LLVM17中添加的__asan_annotate_container_with_allocator定制点,为有问题的分配器或其特化关闭ASan容器注解。
例如,要对user_allocator执行此操作,必须特化继承自std::false_type的定制点,如图14所示。
|
|
图14:为user_allocator关闭容器注解的示例
在大多数情况下,您不会使用该部分的信息,因为容器注解通常对分配器透明;ASan解除污染发生在析构函数中。我们添加此定制点是为了响应需要一种机制来关闭区域分配器的注解。
Deque注解
虽然添加对所有分配器的支持不是我们的初始目标,但机会随之而来。然而,从一开始,我们就希望注解更多容器——因此我们还在LLVM16中扩展了compiler-rt ASan API。我们实现了__sanitizer_annotate_double_ended_contiguous_container函数,该函数专为类似deque的容器设计,这些容器的缓冲区不要求内容从这些缓冲区的very beginning开始,而是在内部连续缓冲区中存储其元素。
|
|
图15:描述__sanitizer_annotate_double_ended_contiguous_container的注释
该函数直到LLVM17才被使用,在那里我们上游化了std::deque注解。与std::vector注解相比,添加到std::deque的代码相当复杂,因为ASan容器注解接口函数操作一个连续缓冲区,但std::deque有许多缓冲区。
得益于我们的更改,使用libc++17及更高版本,每个人都可以