C++容器ASan注解实战指南:从基础到高级优化

本文详细讲解如何使用AddressSanitizer为C++容器添加内存安全注解,包括std::vector、std::deque和std::string的实践案例,以及在LLVM和GCC中的具体实现与优化技巧。

净化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
2
3
4
5
6
#include <vector>
int main() {
    std::vector<char> v;
    v.reserve(8); // 容量设为8,大小仍为0
    return *(v.data()); // 访问向量超出其大小但在容量内(8)
}

图1:容器溢出检测示例(注意:CompilerExplorer上未显示MSVC,因为它尚未安装ASan)

图2显示了运行此代码的结果,我们可以看到无效内存访问被检测为容器溢出错误(因为影子内存被"fc"字节污染)。

1
2
3
4
5
6
7
8
==1==ERROR: AddressSanitizer: container-overflow on address 0x502000000010
READ of size 1 at 0x502000000010 thread T0
    #0 0x401314 in main /app/example.cpp:10
...
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:
  Container overflow:      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宏的更多细节。

1
2
3
4
5
6
7
/// 将内存区域([addr, addr+size))标记为不可寻址。
///
/// 此内存必须由程序先前分配。在被解除污染之前,检测代码禁止访问此区域中的地址。
/// 此函数不保证污染整个区域 - 由于ASan对齐限制,可能只污染[addr, addr+size)的子区域。
///
/// \note 此函数非线程安全,因为不能有两个线程同时污染或解除污染同一内存区域。
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的类以外的任何东西。
///
/// \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"错误报告对相应内存地址的内存访问。

注意在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
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
// 注解新缓冲区
 void annotate_new() {
     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
// 增加容量但不修改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
clang++ -fsanitize=address listing-x-src.cpp -o program
./program
=================================================================
==38540==ERROR: AddressSanitizer: container-overflow on address 0x60c00000004c
READ of size 4 at 0x60c00000004c thread T0
    #0 0x559a9925a93b in main
...

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:
...
=>0x0c187fff8000: fa fa fa fa fa fa fa fa 00[04]fc fc fc fc fc fc
...

图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关闭容器注解的示例

在大多数情况下,您不会使用该部分的信息,因为容器注解通常对分配器透明;ASan解除污染发生在析构函数中。我们添加此定制点是为了响应需要一种机制来关闭区域分配器的注解。

Deque注解

虽然添加对所有分配器的支持不是我们的初始目标,但机会随之而来。然而,从一开始,我们就希望注解更多容器——因此我们还在LLVM16中扩展了compiler-rt ASan API。我们实现了__sanitizer_annotate_double_ended_contiguous_container函数,该函数专为类似deque的容器设计,这些容器的缓冲区不要求内容从这些缓冲区的very beginning开始,而是在内部连续缓冲区中存储其元素。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
/// 参数要求:
/// 在解除空容器内存污染时(在添加第一个元素之前):
/// - old_container_beg_p == old_container_end_p
/// 在移除最后一个元素后污染时:
/// - new_container_beg_p == new_container_end_p
/// \param storage_beg 内存区域开始
/// \param storage_end 内存区域结束
/// \param old_container_beg 旧的使用区域开始
/// \param old_container_end 旧的使用区域结束
/// \param new_container_beg 新的使用区域开始
/// \param new_container_end 新的使用区域结束
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及更高版本,每个人都可以

comments powered by Disqus
使用 Hugo 构建
主题 StackJimmy 设计