Sanitize your C++ containers: ASan annotations step-by-step
Container overflows
AddressSanitizer (ASan) 是一个编译器插件,可帮助检测内存错误,如缓冲区溢出或释放后使用。在本文中,我们解释了如何为 C++ 代码配备 ASan 注解以发现更多错误。我们还展示了我们在 GCC 和 LLVM 中的 ASan 工作。在 LLVM 中,Trail of Bits 为 libc++ std::string 和 std::deque 容器添加了注解,为容器注解启用了自定义分配器,并修复了 libc++ 中的错误!
如我们“理解 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++ 标准库)默认启用容器注解。
图 2 显示了运行此代码的结果,我们可以看到无效内存访问被检测为容器溢出错误(因为影子内存被“fc”字节毒化)。
#include <vector>
int main() {std::vector<char> v;// Set capacity to 8, the size remains 0v.reserve(8);// Access vector past its size, but before its capacity (8)return *(v.data());
}
图 1:容器溢出检测示例(注意:由于 CompilerExplorer 上未安装 ASan,我们未显示 MSVC)
==1==ERROR: AddressSanitizer: container-overflow on address 0x502000000010 at pc
0x000000401315 bp 0x7ffdd7e0c670 sp 0x7ffdd7e0c668
READ of size 1 at 0x502000000010 thread T0#0 0x401314 in main /app/example.cpp:10#1 0x7a47d5229d8f (/lib/x86_64-linux-gnu/libc.so.6+0x29d8f)#2 0x7a47d5229e3f in __libc_start_main (/lib/x86_64-linux-gnu/libc.so.6+0x29e3f)#3 0x401174 in _start (/app/output.s+0x401174)
…Shadow bytes around the buggy address:
=>0x502000000000: fa fa[fc]fa fa fa fa fa fa fa fa fa fa fa fa faShadow byte legend (one shadow byte represents 8 application bytes):Container overflow: fc
图 2:ASan 检测图 1 中的错误。输出经过截断,仅显示相关信息。
然而,C++ 标准库对检测容器溢出的支持程度各不相同。下表总结了当前对此检测的支持情况。
库 | 注解的容器 | 注释 |
---|---|---|
libstdc++ (GCC) | std::vector (GCC 8) | 编译时需要 -D_GLIBCXX_SANITIZE_VECTOR 宏。对于 std::string 和 std::deque,请参阅下面的“GCC / libstdc++ 注解”部分。 |
libc++ (LLVM) | std::vector (LLVM 3.5), std::deque (LLVM17), long std::string (LLVM18), short std::string (尚未发布) | 默认启用容器注解。可通过环境变量 ASAN_OPTIONS=detect_container_overflow=0 禁用(无需重新编译)。 |
msvc++ | std::vector 和 std::string (Visual Studio 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
宏的更多详细信息。
/// Marks a memory region ([addr, addr+size)) as unaddressable.
///
/// This memory must be previously allocated by your program. Instrumented
/// code is forbidden from accessing addresses in this region until it is
/// unpoisoned. This function is not guaranteed to poison the entire region -
/// it could poison only a subregion of [addr, addr+size) due to ASan
/// alignment restrictions.
///
/// \note This function is not thread-safe because no two threads can poison or
/// unpoison memory in the same memory region simultaneously.
///
/// \param addr Start of memory region.
/// \param size Size of memory region.
void __asan_poison_memory_region(void const volatile *addr, size_t size);
图 3:描述 __asan_poison_memory_region
的注释
除了这些宏之外,asan_interface.h
文件提供了允许自定义影子内存中设置的值并帮助注解某些容器的函数,例如 __sanitizer_annotate_contiguous_container
和 __sanitizer_annotate_double_ended_contiguous_container
函数。前一个函数的文档如图 4 所示。
/// \note Use this function with caution and do not use for anything other
/// than vector-like classes.
///
/// \param beg Beginning of memory region.
/// \param end End of memory region.
/// \param old_mid Old middle of memory region.
/// \param new_mid New middle of memory region.
void __sanitizer_annotate_contiguous_container(const void *beg,const void *end,const void *old_mid,const void *new_mid);
图 4:描述 __sanitizer_annotate_contiguous_container
的注释
例如,此函数在 std::vector::pop_back
操作期间使用,以将已移除元素的内存标记为不可访问,如图 5 所示。在底层,它使用“fc”值毒化影子内存,以通过“容器溢出”错误报告对相应内存地址的内存访问。
图 5:在五个元素的 std::vector 上调用 pop_back 后的内存毒化示意图
请注意,在 pop_back
中,必须在销毁元素后调用该函数,因为该内存变得不可访问。
A step-by-step example
在这里,我们基于一个具有有限接口的示例堆栈类,说明了向容器添加 ASan 注解的正确方法。堆栈数据存储在连续缓冲区中,并实现了图 6 所示的功能。堆栈的完整代码可以在此处找到。
class stack {
public:using T = int;stack();stack(const stack&) = delete;~stack();bool empty() { return size == 0; }void push(T const &v);void pop();T& top() {if(empty())throw std::runtime_error("Stack is empty");return buffer[size - 1];}private:T* buffer;size_t size = 0;size_t capacity = 32;// Returns next capacity, used only when buffer growssize_t next_capacity() { return 2 * capacity; }void grow_buffer();
};
图 6:简单堆栈类的声明
Container annotation wrappers
添加 ASan 注解的第一步是确定 ASan API 是否可用。如果不可用,则在未使用 ASan 编译时使用 ASan 的函数将导致未定义的引用链接器错误。为此,我们可以使用 __has_feature
预处理器宏为注解我们的容器创建一个包装函数,如果在没有 ASan 的情况下编译,该函数将不执行任何操作。由于我们的堆栈数据保存在连续缓冲区中,我们将使用 __sanitizer_annotate_contiguous_container
函数对其进行注解。
#if __has_feature(address_sanitizer)void annotate_contiguous_container(void *container_beg,
void *container_end, void *old_mid, void *new_mid) {if(container_beg != nullptr)__sanitizer_annotate_contiguous_container(container_beg,
container_end, old_mid, new_mid);}
#elsevoid annotate_contiguous_container(void *, void *, void *, void *) { }
#endif
图 7:将在我们实现中使用的注解包装函数
接下来,我们添加 annotate_new
和 annotate_delete
函数——前者用于在分配容器缓冲区后毒化它,后者用于在释放缓冲区之前取消毒化它。
// Annotates a new buffer.void annotate_new() {// buffer points to the new memory buffer// capacity and size have value of the size of new bufferannotate_contiguous_container(buffer, buffer + capacity,buffer + capacity, buffer + size);}// Annotates (unpoisons) buffer before deallocationvoid annotate_delete() {// should be called before deallocationannotate_contiguous_container(buffer, buffer + capacity,buffer + size, buffer + capacity);}
图 8:在分配新缓冲区后和释放缓冲区之前更新容器注解的函数
接下来,我们需要创建辅助函数,以便在向容器添加或移除项时更新注解,如图 10 所示。
请注意,这些函数的具体细节取决于容器存储数据的方式。在具有一个移动端的容器中(如向量或我们的堆栈),这些函数将简单地处理在添加对象之前和移除对象之后毒化或取消毒化内存。在其他情况下,此类辅助函数可能需要一个参数,例如旧大小或将要添加的对象数量。
// Unpoisones memory for a new element, *before* adding it
void annotate_increase() {annotate_contiguous_container(buffer, buffer + capacity,buffer + size, buffer + size + 1);
}// Poison memory *after* removing an element
void annotate_shrink() {annotate_contiguous_container(buffer, buffer + capacity,buffer + size + 1, buffer + size);
}
图 9:更新容器注解的辅助函数
Annotating the container
最后,我们在容器构造函数、析构函数以及更新其基础大小或容量的方法中使用辅助函数。请注意,操作顺序在这里非常重要。如果我们的代码在取消毒化之前访问内存,ASan 将检测到违规并崩溃。同样重要的是记住在释放之前取消毒化内存,因为不同的内存分配器可能需要访问底层内存(因为它可能在分配缓冲区之前或内部存储一些元数据)。
缩小大小通常比增加大小更简单,因为缓冲区可以在增大其大小时移动到新的内存区域。在我们的堆栈类中,我们在 pop 函数中毒化一个已移除对象的内存,如图 14 所示。必须在完全修改容器后最后调用 annotate_shrink
函数。
stack() {annotate_new();
}~stack() {annotate_delete();free(buffer);
}void pop() {if(empty()) {throw std::runtime_error("Stack is empty");}size -= 1;annotate_shrink();
}void push(T const &v) {if(size == capacity)grow_buffer();annotate_increase();buffer[size] = v;size += 1;
}
图 10:默认构造函数和析构函数的实现;使用辅助函数更新 ASan 注解
为了管理 push 期间的缓冲区重新分配,我们使用图 15 所示的 grow_buffer
函数。此函数维护缓冲区的大小,并确保正确注解新缓冲区和容量。因此,在函数执行结束时,对象被准确更新。这种方法简化了 push 操作,因为我们不再需要考虑多个缓冲区。无论容量是否改变,为新元素取消毒化内存就足够了,如图 11 所示。最后一点很重要,需要记住;例如,我们在 libc++ 的 std::basic_string
类的 ABI 函数中发现了一个问题,该问题导致字符串的大小不正确。这个问题被忽略了,因为该函数在相关上下文中从未使用,直到我们开始集成注解。然而,尽管我们创建了替代方案,但该问题将永远存在于 libc++ ABIv1 中。虽然不太可能,但依赖于该函数正确结果的字符串实现的更改可能会导致严重问题。
// A function increasing capacity, but not modifying stacks content
void grow_buffer() {size_t new_capacity = next_capacity(); // Get a size of the new (bigger) bufferT *new_buffer = (T *)calloc(new_capacity, sizeof(T));// Allocate a new bufferfor(size_t i = 0; i < size; ++i) {new_buffer[i] = std::move(buffer[i]);// Move all elements from the previous container into the new one.}annotate_delete(); // Unpoison old buffer (prepares for deallocation)free(buffer); // Free the buffer.buffer = new_buffer; // Assign new buffer.capacity = new_capacity; // Update capacity.annotate_new(); // Annotate (poison) new buffer. AT THE VERY END
}
图 11:将缓冲区更改为更大缓冲区的辅助函数的实现
Testing our annotations in practice
就这样,我们完成了!随着整个堆栈容器的实现,(几乎)每次对已分配内存的无效访问都会触发错误。我们可以使用一个 main 函数来测试它,如图 18 所示(完整源代码在此处);当在 Clang++15 上运行时,此函数给出图 13 所示的输出。
int main() {stack s;stack::T* ptr;s.push(0);s.push(1);s.push(2);s.push(3);// 4 elementsptr = &s.top(); // Save address of the top element in ptrs.pop(); // Remove the top elements (ptr does not change)std::cout << *ptr << std::endl; // ERROR: access to already removed element
}
图 12:访问已移除元素的程序实现
clang++ -fsanitize=address listing-x-src.cpp -o program
./program
=================================================================
==38540==ERROR: AddressSanitizer: container-overflow on address 0x60c00000004cat pc 0x559a9925a93c bp 0x7fffc06038d0 sp 0x7fffc06038c8
READ of size 4 at 0x60c00000004c thread T0#0 0x559a9925a93b in main (/home/username/CLionProjects/
simple-annotations/a.out+0xde93b) (BuildId: b4b3601668152bb18905aec484b9234f2fabd710)
[...]0x60c00000004c is located 12 bytes inside of 128-byte region
[0x60c000000040,0x60c0000000c0)
allocated by thread T0 here:
[...]#1 0x559a9925b2bd in stack::grow_buffer()
[...]Shadow bytes around the buggy address:
[...]0x0c187fff7ff0: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
=>0x0c187fff8000: fa fa fa fa fa fa fa fa 00[04]fc fc fc fc fc fc0x0c187fff8010: fc fc fc fc fc fc fc fc fa fa fa fa fa fa fa fa0x0c187fff8020: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
[...]
图 13:ASan 为我们的容器溢出注解检测到的错误,针对使用 Clang++ 15 编译的图 12 中的程序。由于容器中剩下 3 个元素(每个 4 字节),影子内存中有 fa 00[04]fc,因为 00 描述前两个对象(8 字节),04 描述最后一个。
How we improved container annotations
如前所述,我们对 libc++ 中的 C++ 容器注解进行了许多改进。我们详细介绍了在该过程中学到的一些经验教训,这些经验教训应有助于未来的开发人员在其自定义容器和自定义分配器中实现注解。
Vector annotations
通过我们的改进,当使用自定义内存分配器时,libc++ 中的 std::vector
容器由 ASan 注解,而以前它仅支持默认内存分配器。这是因为内部由向量注解使用的 __sanitizer_annotate_contiguous_container
函数存在限制,可能导致使用自定义内存分配器时出现误报。我们在 LLVM16 中移除了这些限制,并在 LLVM17 中为自定义分配器启用了向量注解。
这些限制涉及缓冲区起始地址的对齐以及要注解的最后一个粒度的排他性。前一个错误在此处显示。由于 ASan 只能毒化后缀,使用返回未对齐地址的分配器可能导致无法检测到对非毒化前缀字节的无效访问实例。后一个排他性限制涉及消毒缓冲区在未对齐地址结束而另一个对象开始的情况;在这种情况下,ASan 不会毒化另一个对象的内存。
请注意,虽然该函数名为 __sanitizer_annotate_contiguous_container
,但它操作的是单个缓冲区。因此,命名起初可能有些令人困惑。如果一个容器有许多内存缓冲区,但每个缓冲区必须为空,或其内容从一开始就开始,则该函数仍可用于所有被视为单独容器的缓冲区。
Control over container annotations
在极少数情况下,注解由自定义分配器分配的内存可能会产生意外结果,例如不需要的 ASan 错误。此类错误可能包括区域分配器既不通过调用其析构函数来取消毒化已释放对象的内存,也不手动取消毒化。
有两种方法可以处理此类问题。理想情况下,应更改分配器以在再次分配内存之前取消毒化整个内存。或者,如果不可行,可以通过使用我们在 LLVM17 中添加的 __asan_annotate_container_with_allocator
定制点,为有问题的分配器或其特化关闭 ASan 容器注解。
例如,要为 user_allocator
执行此操作,必须特化继承自 std::false_type
的定制点,如图 14 所示。
#ifdef _LIBCPP_HAS_ASAN_CONTAINER_ANNOTATIONS_FOR_ALL_ALLOCATORS
template <class T>
struct std::__asan_annotate_container_with_allocator<user_allocator> : std::false_type {};
#endif
图 14:为 user_allocator 关闭容器注解的示例
在大多数情况下,您不会使用该部分中的信息,因为容器注解通常对分配器是透明的;ASan 取消毒化发生在析构函数中。我们添加此定制点是为了响应需要一种机制来关闭区域分配器的注解。
Deque annotations
虽然添加对所有分配器的支持并不是我们最初的目标,但机会随之而来。然而,从一开始,我们就希望注解更多容器——因此我们还在 LLVM16 中扩展了 compiler-rt ASan API。我们实现了 __sanitizer_annotate_double_ended_contiguous_container
函数,该函数专为类似 deque 的容器量身定制,这些容器的缓冲区不要求内容从这些缓冲区的非常开始处开始,而是将其元素存储在内部连续缓冲区中。
/// Argument requirements:
/// During unpoisoning memory of empty container (before first element is
/// added):
/// - old_container_beg_p == old_container_end_p
/// During poisoning after last element was removed:
/// - new_container_beg_p == new_container_end_p
/// \param storage_beg Beginning of memory region.
/// \param storage_end End of memory region.
/// \param old_container_beg Old beginning of used region.
/// \param old_container_end End of used region.
/// \param new_container_beg New beginning of used region.
/// \param new_container_end New end of used region.
void __sanitizer_annotate_double_ended_contiguous_container(const void *storage_beg, const void *storage_end,const void *old_container_beg, const void *old_container_end,const void *new_container_beg, const void *new_container_end);
图 15:描述 __sanitizer_annotate_double_ended_contiguous_container
的注释
该函数直到 LLVM17 才被使用,当时我们上游化了 std::deque
注解。与 std::vector
注解相比,添加到 std::deque
的代码相当复杂,因为 ASan 容器注解接口函数操作一个连续缓冲区,但 std::deque
有许多缓冲区。
得益于我们的更改,使用 libc++17 及更高版本,每个人都可以轻松检测 deque 对象中的容器溢出。假阴性是可能的,但不太可能,因为内容之前最多只有 7 个未使用的字节可能未被毒化。
多亏了 vitalybuka 在 LLVM17 发布前的评估,我们了解到我们的 deque 注解与 libc++ 缓冲区硬化(目前)相比,检测到的错误大约多 10%:
根据我在相同代码库上启用 https://libcxx.llvm.org/UsingLibcxx.html#enabling-the-safe-libc-mode 的经验,我的非常粗略的估计是,你的补丁至少为“安全 libc++ 模式”带来了 10% 的额外错误。
String annotations
ASan 未能检测到 std::string
错误是我们采取行动的动机,但实现此检测结果是最具挑战性的部分,并且尚未最终完成。
我们设计了对 __sanitizer_annotate_contiguous_container
函数的更新,以方便字符串注解,因为字符串在概念上与向量非常相似:它有一个连续缓冲区,并且内容总是从其最开始处开始。然而,这些集合之间有一个关键区别:字符串启用短字符串优化 (SSO),这是 libc++ std::basic_string
类使用的一种技术,用于将短字符串直接存储在对象本身中,避免在堆上分配内存。实际上,字符串实际上是“短字符串”和“长字符串”的联合体,当字符串不适合“短”变体时,“长”变体启动,在堆上分配内存。
长字符串情况与向量情况基本相同,我们将长字符串注解添加到 LLVM18。我们将短字符串注解添加到 git 主分支,希望将在 LLVM19 中发布。如果你想测试它,请使用提交 fed94502e5 的 libc++。
此外,std::basic_string
注解与 std::vector
和 std::deque
注解不同,需要构建带有 ASan 的 libc++,因为字符串成员函数是 libc++ ABI 的一部分。换句话说,在本文发布时(LLVM18 是最新版本),它默认不适用于大多数附带的 libc++ 版本,因为它们通常是在没有 ASan 的情况下构建的。要使用它,你必须使用 AddressSanitizer (LLVM_USE_SANITIZER=Address
) 构建 LLVM 并与之链接。请记住,ASan 是不稳定的,因此你应该使用同一版本 LLVM 的所有内容(compiler-rt、libc++、libc++abi、clang),否则很可能会遇到带有神秘错误的不兼容错误。
为了确保当且仅当 libc++ 使用 ASan 编译时才使用字符串注解,在添加注解的 PR 中,我们调整了编译过程,以便每当使用 ASan 构建 libc++ 时,将 _LIBCPP_INSTRUMENTED_WITH_ASAN
宏附加到 __config_site
。如果未定义此宏,则不会启用字符串注解。
请注意,这并不能防止如果一个目标文件(或库)使用字符串注解而另一个不使用时的链接错误。再次强调,以这种方式构建的二进制文件将导致难以理解的不兼容错误。
我们希望在下一次 LLVM 版本中看到短字符串注解。我们已经上游化了 PR,但如果检测到错误,其发布可能会延迟。
之前恢复短字符串注解的最有趣的原因之一是由编译器优化引起的错误。具体来说,发现编译器从独占条件(如 if/else 或三元运算符)的两个分支预加载值。然而,逻辑上,只有其中一个分支会执行;为此,编译器必须识别两个值都在堆栈上,并假设预加载两个值比稍后仅加载其中一个更有效。这种先发制人的加载导致了错误,这些错误由于其非直觉性而难以理解。
此问题的复杂性凸显了编译器优化和检测之间的微妙相互作用,使得检测和解决此类问题成为一项复杂的任务,需要对检测和编译器行为有扎实的理解。我们想向 vitalybuka 大声疾呼,感谢他深入挖掘了这个问题的根源。
Testing annotations
如果你想测试你的容器注解,请使用 __sanitizer_verify_contiguous_container
函数;此外,vector、deque 或 basic_string 容器的包装器可以作为灵感来源。
libc++ 库本身有许多容器实现的测试,我们扩展了这些测试,为添加的注解添加了额外的断言(参见此处的示例)。
Thanks
我们要感谢整个 LLVM 社区在我们开发 ASan 注解改进过程中给予的支持;他们帮助进行了从审查代码补丁和集思广益实现想法到识别问题和分享知识等活动。我们特别要感谢 vitalybuka、ldionne 和 philnik777 的持续支持!
Sanitize your allocators, too!
这篇文章侧重于容器注解,但注解自定义分配器同样简单(如果不是更简单的话)且同样强大。分配器清理涉及在释放结束时毒化整个缓冲区,并在分配开始时取消毒化。你可以使用前面提到的 ASAN_*_MEMORY_REGION
宏或其他 AddressSanitizer 函数来执行此操作。
GCC / libstdc++ annotations
我们的研究和改进并非从 LLVM 和 libc++ 开始。我们最初是通过在 GCC 11.1 中黑客攻击 libstdc++ 中 std::string
和 std::deque
集合的容器注解检测来开始这条道路的。我们为此开发的代码尚未准备好投入生产:它不使用最新的 compiler-rt API 函数,并且容器注解测试应纳入标准容器测试。我们在 trailofbits/gcc-asan-container-overflows 存储库(及其 container-overflow 分支)中发布了此代码,希望它可以用于未来的工作。如果有资源支持,我们很乐意为其最新的 libstdc++ 版本开展工作。
Are your containers annotated?
清理你的容器和分配器是构建健壮和安全软件的一步。通过利用 ASan 的力量来检测容器中的内存错误,你可以最小化缓冲区溢出、释放后使用和其他漏洞的风险。
这项有价值的技术很简单,因为 ASan API 几乎处理了一切。然而,它需要良好的代码库理解能力和关于内存是否可访问的正确推理。有时,它还需要对编译器优化有良好的理解。幸运的是,维护注解很容易,而且好处远大于实现它们所花费的时间。
如果你需要有关 ASan 注解、模糊测试或与 LLVM 相关的任何帮助,请联系我们!我们很乐意帮助定制清理器或其他 LLVM 工具以满足你的特定需求。如果你想了解更多关于我们在编译器上的工作,请查看我们关于 VAST(GitHub 存储库)和 Macroni(GitHub 存储库)的帖子。
更多精彩内容 请关注我的个人公众号 公众号(办公AI智能小助手)
公众号二维码