净化C++容器:ASan注解逐步指南
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”字节污染)。
1
2
3
4
5
6
7
8
9
10
|
#include <vector>
int main() {
std::vector<char> v;
// 设置容量为8,大小保持为0
v.reserve(8);
// 访问向量超出其大小但未超出容量(8)的内存
return *(v.data());
}
|
图1:容器溢出检测示例(注意:由于CompilerExplorer上未安装ASan,未显示MSVC)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
|
==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 fa
Shadow 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 and std::string (Visual Studio 2022 17.2 and 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宏的更多详细信息。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
|
/// 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所示。
1
2
3
4
5
6
7
8
9
10
11
|
/// \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中,必须在销毁元素后调用该函数,因为该内存变得不可访问。
逐步示例
在这里,我们基于一个具有有限接口的示例堆栈类,说明向容器添加ASan注解的正确方法。堆栈数据存储在连续缓冲区中,并实现图6所示的功能。堆栈的完整代码可以在此处找到。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
|
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;
// 返回下一个容量,仅在缓冲区增长时使用
size_t next_capacity() { return 2 * capacity; }
void grow_buffer();
};
|
图6:简单堆栈类的声明
容器注解包装器
添加ASan注解的第一步是确定ASan API是否可用。如果不可用,在编译时不使用ASan的情况下使用ASan的函数将导致未定义的引用链接器错误。为此,我们可以使用__has_feature预处理器宏为注解我们的容器创建一个包装器函数,如果在没有ASan的情况下编译,该函数将不执行任何操作。由于我们的堆栈数据保存在连续缓冲区中,我们将使用__sanitizer_annotate_contiguous_container函数进行注解。
1
2
3
4
5
6
7
8
9
10
|
#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);
}
#else
void annotate_contiguous_container(void *, void *, void *, void *) { }
#endif
|
图7:注解包装器函数,用于我们的实现
接下来,我们添加annotate_new和annotate_delete函数——前者在分配容器的新缓冲区后污染缓冲区,后者在释放缓冲区之前取消污染。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
|
// 注解新缓冲区。
void annotate_new() {
// buffer指向新内存缓冲区
// capacity和size具有新缓冲区大小的值
annotate_contiguous_container(buffer, buffer + capacity,
buffer + capacity, buffer + size);
}
// 在释放前注解(取消污染)缓冲区
void annotate_delete() {
// 应在释放前调用
annotate_contiguous_container(buffer, buffer + capacity,
buffer + size, buffer + capacity);
}
|
图8:在分配新缓冲区和即将释放缓冲区后更新容器注解的函数
接下来,我们需要创建辅助函数,以便在向容器添加或移除项时更新注解,如图10所示。
请注意,这些函数的具体细节取决于容器存储数据的方式。在具有一个移动端的容器中,如向量或我们的堆栈,这些函数将简单地处理在添加对象之前和移除对象之后污染或取消污染内存。在其他情况下,此类辅助函数可能需要一个参数,例如旧大小或将要添加的对象数量。
1
2
3
4
5
6
7
8
9
10
11
|
// 在添加新元素之前取消污染内存
void annotate_increase() {
annotate_contiguous_container(buffer, buffer + capacity,
buffer + size, buffer + size + 1);
}
// 在移除元素后污染内存
void annotate_shrink() {
annotate_contiguous_container(buffer, buffer + capacity,
buffer + size + 1, buffer + size);
}
|
图9:更新容器注解的辅助函数
注解容器
最后,我们在容器构造函数、析构函数以及更新其基础大小或容量的方法中使用辅助函数。请注意,操作顺序在这里非常重要。如果我们的代码在取消污染内存之前访问内存,ASan将检测到违规并崩溃。同样重要的是,在释放内存之前记住取消污染内存,因为不同的内存分配器可能需要访问底层内存(因为它可能在分配缓冲区之前或内部存储一些元数据)。
缩小大小通常比增加大小更简单,因为缓冲区可以在增长大小时移动到新的内存区域。在我们的堆栈类中,我们在pop函数中污染一个已移除对象的内存,如图14所示。annotate_shrink函数必须在最后调用,在容器完全修改之后。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
|
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中。虽然不太可能,但依赖该函数正确结果的字符串实现的更改可能导致严重问题。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
|
// 增加容量但不修改堆栈内容的函数
void grow_buffer() {
size_t new_capacity = next_capacity(); // 获取新(更大)缓冲区的大小
T *new_buffer = (T *)calloc(new_capacity, sizeof(T));
// 分配新缓冲区
for(size_t i = 0; i < size; ++i) {
new_buffer[i] = std::move(buffer[i]);
// 将先前容器中的所有元素移动到新容器中。
}
annotate_delete(); // 取消污染旧缓冲区(准备释放)
free(buffer); // 释放缓冲区。
buffer = new_buffer; // 分配新缓冲区。
capacity = new_capacity; // 更新容量。
annotate_new(); // 注解(污染)新缓冲区。在最后
}
|
图11:将缓冲区更改为更大缓冲区的辅助函数的实现
在实践中测试我们的注解
就这样,我们完成了!随着整个堆栈容器的实现,(几乎)每次对已分配内存的无效访问都会触发错误。我们可以使用一个main函数进行测试,如图18所示(完整源代码在此处);当在Clang++15上运行时,此函数给出图13所示的输出。
1
2
3
4
5
6
7
8
9
10
11
12
|
int main() {
stack s;
stack::T* ptr;
s.push(0);
s.push(1);
s.push(2);
s.push(3);
// 4个元素
ptr = &s.top(); // 将顶部元素的地址保存在ptr中
s.pop(); // 移除顶部元素(ptr不变)
std::cout << *ptr << std::endl; // 错误:访问已移除的元素
}
|
图12:访问已移除元素的程序实现
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
|
clang++ -fsanitize=address listing-x-src.cpp -o program
./program
=================================================================
==38540==ERROR: AddressSanitizer: container-overflow on address 0x60c00000004c
at 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 fc
0x0c187fff8010: fc fc fc fc fc fc fc fc fa fa fa fa fa fa fa fa
0x0c187fff8020: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
[...]
|
图13:ASan检测到的错误,针对图12中使用Clang++ 15编译的程序的容器溢出注解。因为容器中剩下3个元素(每个4字节),fa 00[04]fc在影子内存中,00描述前两个对象(8字节),04描述最后一个。
我们如何改进容器注解
如前所述,我们对libc++中的C++容器注解进行了许多改进。我们详细介绍了在该过程中学到的一些经验教训,这些经验教训应有助于未来开发人员在其自定义容器和自定义分配器中实现注解。
向量注解
通过我们的改进,当使用自定义内存分配器时,libc++中的std::vector容器由ASan注解,而以前它仅支持默认内存分配器。这是因为向量注解内部使用的__sanitizer_annotate_contiguous_container函数有限制,可能导致自定义内存分配器出现误报。我们在LLVM16中移除了这些限制,并在LLVM17中启用了自定义分配器的向量注解。
这些限制涉及缓冲区起始地址的对齐和要注解的最后一个粒度的排他性。前一个错误在此处显示。由于ASan只能污染后缀,使用返回未对齐地址的分配器可能导致无法检测到对未污染前缀字节的无效访问实例。后一个排他性限制涉及消毒缓冲区在未对齐地址结束而另一个对象开始的情况;在这种情况下,ASan不会污染另一个对象的内存。
请注意,虽然该函数名为__sanitizer_annotate_contiguous_container,但它操作单个缓冲区。因此,命名起初可能有些令人困惑。如果一个容器有许多内存缓冲区,但每个缓冲区必须为空或其内容从最开始开始,则该函数仍可用于所有被视为单独容器的缓冲区。
控制容器注解
在极少数情况下,注解由自定义分配器分配的内存可能产生意外结果,例如不需要的ASan错误。此类错误可能包括区域分配器既不通过调用其析构函数也不手动取消污染已释放对象的内存。
有两种方法处理此类问题。理想情况下,分配器应更改为在再次分配之前取消污染整个内存。或者,如果不可行,可以使用我们在LLVM17中添加的__asan_annotate_container_with_allocator定制点,为有问题的分配器或其特化关闭ASan容器注解。
例如,要对user_allocator执行此操作,必须特化继承自std::false_type的定制点,如图14所示。