演练:使用“地址擦除器出错时继续”查找内存安全问题

在本演练中,创建用于查找并报告内存安全错误的检查版本。

内存安全错误(例如越界内存读取和写入、释放内存后使用内存、NULL 指针取消引用等)是 C/C++ 代码最关心的问题。 地址擦除器 (ASAN) 是一种编译器和运行时技术,可暴露此类难以发现的错误,并实现零误报。 有关 ASAN 的概述,请参阅 AddressSanitizer

出错时继续 (COE) 是一项新的 ASAN 功能,可在应用程序运行时自动诊断并报告内存安全错误。 程序退出时,系统会将独特内存安全错误的摘要输出到 stdoutstderr 或你选择的日志文件。 使用 -fsanitizer=address 创建标准 C++ 检查构建时,对分配器、解除分配器(例如 freememcpymemset 等)的调用将转发到 ASAN 运行时。 ASAN 运行时将为这些函数提供相同的语义,但监视内存发生的情况。 应用程序运行时,ASAN 会诊断并报告隐藏的内存安全错误,且无误报。

COE 的一个显著优点是,与之前的 ASAN 行为不同,当发现第一个内存错误时,程序不会停止运行。 相反,ASAN 会记录该错误,并且应用程序会继续运行。 应用程序退出后,系统会输出所有内存问题的摘要。

最好是在打开 ASAN 的情况下创建 C 或 C++ 应用程序的检查版本,然后在测试工具中运行应用程序。 当测试在应用程序中执行代码路径以查找错误时,你还可以在不干扰测试的情况下了解这些代码路径是否存在内存安全问题。

应用程序完成时,你将得到内存问题的摘要。 借助 COE,可以编译现有应用程序并将其部署到有限生产中,以发现内存安全问题。 可以运行经过检查的构建几天来充分运用代码,尽管应用程序会由于 ASAN 检测导致运行速度变慢。

可以使用此功能创建新的传送门。 如果所有现有测试都通过,但 COE 报告内存安全错误或泄漏,请不要交付新代码或将其集成到父分支中。

不要将启用了 COE 的构建部署到生产环境中! COE 仅用于测试和开发环境。 不应在生产中使用启用了 ASAN 的生成,因为为检测内存错误而添加的工具会影响性能,报告错误时存在暴露内部实施的风险,并且要避免通过传送 ASAN替代内存分配、释放等的库函数来增加可能的安全漏洞的外围应用。

在以下示例中,将创建经过检查的版本并设置环境变量以将地址清理程序信息输出到 stdout,用于查看 ASAN 报告的内存安全错误。

先决条件

要完成本演练,需要安装了使用 C++ 工作负载进行桌面开发的 Visual Studio 2022 17.6 或更高版本。

Double free 示例

在此示例中,创建一个启用了 ASAN 的构建,以测试内存双重释放时会发生什么情况。 ASAN 检测到此错误并报告它。 在此示例中,程序在检测到错误后继续运行,这导致了第二个错误 - 使用已释放的内存。 当程序退出时,错误摘要将输出到 stdout

创建示例:

  1. 打开开发人员命令提示:打开“开始”菜单,键入 Developer,然后从匹配项列表中选择最新的命令提示符,例如 VS 2022 的开发人员命令提示符

  2. 在计算机上创建一个目录来运行此示例。 例如 %USERPROFILE%\Desktop\COE

  3. 在该目录中,创建一个空的源文件。 例如: doublefree.cpp

  4. 将以下代码粘贴到文件中:

    #include <stdio.h>
    #include <stdlib.h>
    
    void BadFunction(int *pointer)
    {
        free(pointer);
        free(pointer); // double-free!
    }
    
    int main(int argc, const char *argv[])
    {
        int *pointer = static_cast<int *>(malloc(4));
        BadFunction(pointer);
    
        // Normally we'd crash before this, but with COE we can see heap-use-after-free error as well
        printf("\n\n******* Pointer value: %d\n", *pointer);
    
        return 1;
    }
    

在前面的代码中,pointer 被释放了两次。 这是一个人为设计的示例,但在更复杂的 C++ 代码中,双重释放是一个很容易犯的错误。

通过以下步骤创建打开 COE 的上述代码的版本:

  1. 在之前打开的开发人员命令提示符中编译代码:cl -fsanitize=address -Zi doublefree.cpp-fsanitize=address 开关将打开 ASAN,并且 -Zi 将创建一个单独的 PDB 文件,地址清理程序将使用该文件来显示内存错误位置信息。
  2. 通过在开发人员命令提示符中设置 stdout 环境变量,将 ASAN 输出发送到 ASAN_OPTIONS,如下所示:set ASAN_OPTIONS=continue_on_error=1
  3. 使用以下命令来运行测试代码:doublefree.exe

输出显示存在双重释放错误以及发生该错误的调用堆栈。 该报告首先显示一个调用堆栈,显示 BadFunction 中发生的错误:

==22976==ERROR: AddressSanitizer: attempting double-free on 0x01e03550 in thread T0:
    #0  free                           D:\a\_work\1\s\src\vctools\asan\llvm\compiler-rt\lib\asan\asan_malloc_win_thunk.cpp(69)
    #1  BadFunction                    C:\Users\xxx\Desktop\COE\doublefree.cpp(8)
    #2  main                           C:\Users\xxx\Desktop\COE\doublefree.cpp(14)
    #3  __scrt_common_main_seh         D:\a\_work\1\s\src\vctools\crt\vcstartup\src\startup\exe_common.inl(288)
    #4  BaseThreadInitThunk            Windows
    #5  RtlInitializeExceptionChain    Windows

接下来,将显示有关已释放内存的信息以及分配内存的调用堆栈:

0x01e03550 is located 0 bytes inside of 4-byte region [0x01e03550,0x01e03554)
freed by thread T0 here:
    #0  free                           D:\a\_work\1\s\src\vctools\asan\llvm\compiler-rt\lib\asan\asan_malloc_win_thunk.cpp(69)
    #1  BadFunction                    C:\Users\xxx\Desktop\COE\doublefree.cpp(7)
    #2  main                           C:\Users\xxx\Desktop\COE\doublefree.cpp(14)
    #3  __scrt_common_main_seh         D:\a\_work\1\s\src\vctools\crt\vcstartup\src\startup\exe_common.inl(288)
    #4  BaseThreadInitThunk            Windows
    #5  RtlInitializeExceptionChain    Windows

previously allocated by thread T0 here:
    #0  malloc                         D:\a\_work\1\s\src\vctools\asan\llvm\compiler-rt\lib\asan\asan_malloc_win_thunk.cpp(85)
    #1  main                           C:\Users\xxx\Desktop\COE\doublefree.cpp(13)
    #2  __scrt_common_main_seh         D:\a\_work\1\s\src\vctools\crt\vcstartup\src\startup\exe_common.inl(288)
    #3  BaseThreadInitThunk            Windows
    #4  RtlInitializeExceptionChain    Windows

然后是有关堆释放后使用错误的信息。 这是指在 *pointer 调用中使用 printf(),因为 pointer 引用的内存已提前释放。 列出了发生错误的调用堆栈,以及分配和释放该内存的调用堆栈:

==35680==ERROR: AddressSanitizer: heap-use-after-free on address 0x02a03550 at pc 0x00e91097 bp 0x012ffc64 sp 0x012ffc58READ of size 4 at 0x02a03550 thread T0
         #0  main                           C:\Users\xxx\Desktop\Projects\ASAN\doublefree.cpp(18)
         #1  __scrt_common_main_seh         D:\a\_work\1\s\src\vctools\crt\vcstartup\src\startup\exe_common.inl(288)
         #2  BaseThreadInitThunk            Windows
         #3  RtlInitializeExceptionChain    Windows

0x02a03550 is located 0 bytes inside of 4-byte region [0x02a03550,0x02a03554)
freed by thread T0 here:
         #0  free                           D:\a\_work\1\s\src\vctools\asan\llvm\compiler-rt\lib\asan\asan_malloc_win_thunk.cpp(69)
         #1  BadFunction                    C:\Users\xxx\Desktop\Projects\ASAN\doublefree.cpp(7)
         #2  main                           C:\Users\xxx\Desktop\Projects\ASAN\doublefree.cpp(14)
         #3  __scrt_common_main_seh         D:\a\_work\1\s\src\vctools\crt\vcstartup\src\startup\exe_common.inl(288)
         #4  BaseThreadInitThunk            Windows
         #5  RtlInitializeExceptionChain    Windows

previously allocated by thread T0 here:
         #0  malloc                         D:\a\_work\1\s\src\vctools\asan\llvm\compiler-rt\lib\asan\asan_malloc_win_thunk.cpp(85)
         #1  main                           C:\Users\xxx\Desktop\Projects\ASAN\doublefree.cpp(13)
         #2  __scrt_common_main_seh         D:\a\_work\1\s\src\vctools\crt\vcstartup\src\startup\exe_common.inl(288)
         #3  BaseThreadInitThunk            Windows
         #4  RtlInitializeExceptionChain    Windows

接下来,将显示有关缓冲区溢出附近的阴影字节的信息。 有关阴影字节的详细信息,请参阅 AddressSanitizer 影子字节

在阴影字节信息之后,你将看到程序的输出,这表明它在 ASAN 检测到错误后继续运行:

******* Pointer value: xxx

然后是发生内存错误的源文件的摘要。 它按该文件中内存错误的唯一调用堆栈排序。 唯一的调用堆栈由错误类型和发生错误的调用堆栈决定。

这种排序优先考虑可能是最令人担忧的内存安全问题。 例如,五个独特的调用堆栈导致同一文件中出现不同的内存安全错误,这可能比一个多次出现的错误更令人担忧。 摘要如下:

=== Files in priority order ===

File: D:\a\_work\1\s\src\vctools\asan\llvm\compiler-rt\lib\asan\asan_malloc_win_thunk.cpp Unique call stacks: 1
File: C:\Users\xxx\Desktop\COE\doublefree.cpp Unique call stacks: 1

最后,报告包含发生内存错误的摘要:

=== Source Code Details: Unique errors caught at instruction offset from source line number, in functions, in the same file. ===

File: D:\a\_work\1\s\src\vctools\asan\llvm\compiler-rt\lib\asan\asan_malloc_win_thunk.cpp
        Func: free()
                Line: 69 Unique call stacks (paths) leading to error at line 69 : 1
                        Bug: double-free at instr 19 bytes from start of line
File: C:\Users\xxx\Desktop\COE\doublefree.cpp
        Func: main()
                Line: 18 Unique call stacks (paths) leading to error at line 18 : 1
                        Bug: heap-use-after-free at instr 55 bytes from start of line

>>>Total: 2 Unique Memory Safety Issues (based on call stacks not source position) <<<

#0 C:\Users\xxx\Desktop\COE\doublefree.cpp Function: main(Line:18)
        Raw HitCnt: 1  On Reference: 4-byte-read-heap-use-after-free
#1 D:\a\_work\1\s\src\vctools\asan\llvm\compiler-rt\lib\asan\asan_malloc_win_thunk.cpp Function: free(Line:69)
        Raw HitCnt: 1

超出边界的内存访问示例

在此示例中,磨创建一个启用了 ASAN 的构建,以测试当应用程序访问超出边界的内存时会发生什么情况。 ASAN 检测到此错误,并在程序退出时向 stdout 报告错误摘要。

创建示例:

  1. 打开开发人员命令提示:打开“开始”菜单,键入 Developer,然后从匹配项列表中选择最新的命令提示符,例如 VS 2022 的开发人员命令提示符

  2. 在计算机上创建一个目录来运行此示例。 例如 %USERPROFILE%\Desktop\COE

  3. 在该目录中,创建一个源文件,例如 coe.cpp,并粘贴以下代码:

    #include <stdlib.h> 
    
    char* func(char* buf, size_t sz)
    { 
        char* local = (char*)malloc(sz); 
        for (auto ii = 0; ii <= sz; ii++) // bad loop exit test 
        {
            local[ii] = ~buf[ii]; // Two memory safety errors 
        }
    
        return local; 
    } 
    
    char buffer[10] = {0,1,2,3,4,5,6,7,8,9}; 
    
    int main()
    {   
        char* inverted_buf= func(buffer, 10); 
    }
    

上面的代码中,参数 sz 为 10,原始缓冲区为 10 字节。 有两个内存安全错误:

  • buf 循环中 for 的超出边界的加载
  • local 循环中 for 的超出边界的存储

缓冲区溢出是由于循环退出测试 <=sz 造成的。 当这个示例运行时,它碰巧是安全的。 这是因为大多数 C++ 运行时实施都进行了过度分配和对齐。 当 sz % 16 == 0 时,对 local[ii] 的最终写入会损坏内存。 其他情况仅读取/写入“malloc slop”,这是由于 C 运行时 (CRT) 将分配填充到 0 mod 16 边界的方式而分配的额外内存。

仅当分配后的页面未映射或使用损坏的数据时,才会观察到错误。 在此示例中,所有其他情况都是无提示的。 使用“出现错误时继续”,程序运行完成后,错误将在摘要中可见。

在打开 COE 的情况下创建上述代码的版本:

  1. 使用 cl -fsanitize=address -Zi coe.cpp 编译代码。 -fsanitize=address 开关将打开 ASAN,并且 -Zi 将创建一个单独的 PDB 文件,地址清理程序将使用该文件来显示内存错误位置信息。
  2. 通过在开发人员命令提示符中设置 stdout 环境变量,将 ASAN 输出发送到 ASAN_OPTIONS,如下所示:set ASAN_OPTIONS=continue_on_error=1
  3. 使用以下命令来运行测试代码:coe.exe

输出显示存在两个内存缓冲区溢出错误,并提供了发生这些错误的调用堆栈。 报告的开头如下所示:

==9776==ERROR: AddressSanitizer: global-buffer-overflow on address 0x0047b08a at pc 0x003c121b bp 0x012ffaec sp 0x012ffae0
READ of size 1 at 0x0047b08a thread T0
	 #0  func                           C:\Users\xxx\Desktop\COE\coe.cpp(8)
	 #1  main                           C:\Users\xxx\Desktop\COE\coe.cpp(18)
	 #2  __scrt_common_main_seh         D:\a\_work\1\s\src\vctools\crt\vcstartup\src\startup\exe_common.inl(288)
	 #3  BaseThreadInitThunk            Windows
	 #4  RtlInitializeExceptionChain    Windows

接下来,将显示有关缓冲区溢出附近的阴影字节的信息。 有关阴影字节的详细信息,请参阅 AddressSanitizer 影子字节

阴影字节报告之后是发生内存错误的源文件的摘要。 它按该文件中内存错误的唯一调用堆栈排序。 唯一的调用堆栈由错误类型和发生错误的调用堆栈决定。

这种排序优先考虑可能是最令人担忧的内存安全问题。 例如,五个独特的调用堆栈导致同一文件中出现不同的内存安全错误,这可能比一个多次出现的错误更令人担忧。

摘要如下:

=== Files in priority order ===

File: C:\Users\xxx\Desktop\COE\coe.cpp Unique call stacks: 2

最后,报告包含发生内存错误的摘要。 “出错时继续”报告同一源代码行上发生的两个不同错误。 第一个错误读取 .data 部分中全局地址处的内存,另一个错误写入从堆分配的内存。

报告如下所示:

=== Source Code Details: Unique errors caught at instruction offset from source line number, in functions, in the same file. === 

File: C:\Users\xxx\Desktop\COE\coe.cpp 
	Func: func()
		Line: 8 Unique call stacks (paths) leading to error at line 8 : 2
			Bug: heap-buffer-overflow at instr 124 bytes from start of line

>>>Total: 2 Unique Memory Safety Issues (based on call stacks not source position) <<<

#0 C:\Users\xxx\Desktop\COE\coe.cpp Function: func(Line:8) 
	Raw HitCnt: 1  On Reference: 1-byte-read-global-buffer-overflow 
#1 C:\Users\xxx\Desktop\COE\coe.cpp Function: func(Line:8) 
	Raw HitCnt: 1  On Reference: 1-byte-write-heap-buffer-overflow 

默认的地址擦除器运行时行为会在报告发现的第一个错误后终止应用程序。 它不允许执行“错误的”计算机指令。 新的地址擦除器运行时会诊断并报告错误,然后执行后续指令。

在报告每个内存安全错误后,COE 会尝试自动将控制权返回给应用程序。 在某些情况下,它无法执行此操作,例如当存在内存访问冲突 (AV) 或内存分配失败时。 在程序的结构化异常处理无法捕获的访问冲突之后,COE 不会继续。 如果 COE 无法将执行返回到应用,则会输出 CONTINUE CANCELLED - Deadly Signal. Shutting down. 消息。

选择要将 ASAN 输出发送到的位置

使用 ASAN_OPTIONS 环境变量确定将 ASAN 输出发送到何处,如下所示:

  • 输出到 stdout:set ASAN_OPTIONS=continue_on_error=1
  • 输出到 stderr:set ASAN_OPTIONS=continue_on_error=2
  • 输出到所选日志文件:set COE_LOG_FILE=yourfile.log

处理未定义的行为

ASAN 运行时不会模拟 C 和 C++ 分配/解除分配函数的所有未定义行为。 以下示例演示了 _alloca 的 ASAN 版本与 C 运行时版本的差异:

#include <cstdio>
#include <cstring>
#include <malloc.h>
#include <excpt.h>
#include <windows.h>

#define RET_FINISH 0
#define RET_STACK_EXCEPTION 1
#define RET_OTHER_EXCEPTION 2

int foo_redundant(unsigned long arg_var)
{
    char *a;
    int ret = -1;

    __try
    {
        if ((arg_var+3) > arg_var)
        {
            // Call to _alloca using parameter from main
            a = (char *) _alloca(arg_var);
            memset(a, 0, 10);
        }
        ret = RET_FINISH;
    }
    __except(1)
    {
        ret = RET_OTHER_EXCEPTION;
        int i = GetExceptionCode();
        if (i == EXCEPTION_STACK_OVERFLOW)
        {
            ret = RET_STACK_EXCEPTION;
        }
    }
    return ret;
}

int main()
{
    int cnt = 0;

    if (foo_redundant(0xfffffff0) == RET_STACK_EXCEPTION)
    {
        cnt++;
    }

    if (cnt == 1)
    {
        printf("pass\n");
    }
    else
    {
        printf("fail\n");
    }
}

main() 中,大量数字被传递到 foo_redundant,该数字最终又被传递到 _alloca(),这会导致 _alloca() 失败。

此示例在没有 ASAN(即没有 pass 开关)的情况下编译时输出 -fsanitize=address,但在打开 ASAN(即使用 fail 开关)的情况下编译时输出 -fsanitize=address。 这是因为如果没有 ASAN,异常代码将与 RET_STACK_EXCEPTION 匹配,因此 cnt 将设置为 1。 当使用 ASAN 进行编译时,它的行为有所不同,因为引发的异常是地址擦除器错误:动态堆栈缓冲区溢出。 这意味着代码返回 RET_OTHER_EXCEPTION 而不是 RET_STACK_EXCEPTION,因此 cnt 不会设置为 1。

其他优点

借助新的 ASAN 运行时,应用程序无需部署额外的二进制文件。 这样就可以更轻松地将 ASAN 与普通测试工具一起使用,因为无需管理额外的二进制文件。

另请参阅

AddressSanitizer 错误时继续博客文章
内存安全错误示例
-Zi 编译器标志
-fsanitize=address 编译器标志
25 个最危险的软件弱点