净化C++容器:ASan注解逐步指南
容器溢出
如我们在"理解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:容器溢出检测示例(注意:由于MSVC尚未安装ASan,我们在CompilerExplorer上不显示它)
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和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
宏的更多细节。
1
2
3
4
5
6
7
8
9
10
|
/// 将内存区域([addr, addr+size))标记为不可寻址。
///
/// 此内存必须由程序先前分配。在被解除污染之前,检测代码禁止访问此区域中的地址。
/// 此函数不保证污染整个区域 - 由于ASan对齐限制,它可能只污染[addr, addr+size)的子区域。
///
/// \note 此函数不是线程安全的,因为不能有两个线程同时在同一内存区域中污染或解除污染内存。
///
/// \param addr 内存区域的开始。
/// \param size 内存区域的大小。
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
|
/// \note 谨慎使用此函数,不要用于除vector-like类之外的任何东西。
///
/// \param beg 内存区域的开始。
/// \param end 内存区域的结束。
/// \param old_mid 内存区域的旧中间点。
/// \param new_mid 内存区域的新中间点。
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"值污染影子内存,以使用"container-overflow"错误报告对相应内存地址的内存访问。
图5:在五元素std::vector上调用pop_back后的内存污染图示
注意在pop_back中,必须在销毁元素后调用该函数,因为该内存变得不可访问。
逐步示例
在这里,我们基于一个具有有限接口的示例stack类,说明向容器添加ASan注解的正确方法。stack数据存储在连续缓冲区中,并实现图6所示的功能。stack的完整代码可以在此处找到。
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:简单stack类的声明
容器注解包装器
添加ASan注解的第一步是确定ASan API是否可用。如果不可用,在没有ASan的情况下编译时,使用ASan的函数将导致未定义的引用链接器错误。为此,我们可以使用__has_feature
预处理器宏为注解我们的容器创建一个包装函数,如果在没有ASan的情况下编译,它将不执行任何操作。由于我们的stack数据保存在连续缓冲区中,我们将使用__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所示。
注意这些函数的具体细节取决于容器存储数据的方式。在具有一个移动端的容器中,如vector或我们的stack,这些函数将简单地处理在添加对象之前和移除对象之后污染或解除污染内存。在其他情况下,此类辅助函数可能需要参数,例如旧大小或将要添加的对象数量。
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将检测到违规并崩溃。同样重要的是记住在释放之前解除污染内存,因为不同的内存分配器可能需要访问底层内存(因为它可能在分配缓冲区之前或内部存储一些元数据)。
缩小大小通常比增加大小更简单,因为缓冲区可以在增长大小时移动到新的内存区域。在我们的stack类中,我们在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
|
// 增加容量但不修改stack内容的函数
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:将缓冲区更改为更大缓冲区的辅助函数的实现
在实践中测试我们的注解
就这样,我们完成了!随着整个stack容器的实现,(几乎)每次对已分配内存的无效访问都会触发错误。我们可以使用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++容器注解进行了许多改进。我们详细记录了在该过程中学到的一些经验教训,这些应该有助于未来的开发人员在其自定义容器和自定义分配器中实现注解。
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所示。
1
2
3
4
|
#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关闭容器注解的示例
在大多数情况下,您不会