Sanitizers

A sanitizer is a compiler mode that adds checks to the program and reports the bugs it finds while it runs. AddressSanitizer watches every memory access, UndefinedBehaviorSanitizer watches the operations the standard leaves undefined, and LeakSanitizer reports memory that was never freed.

The cost is speed: an instrumented program runs several times slower and uses more memory. That is why a program is built with sanitizers for its tests and without them for release, which is what lesson 102 sets up in a build system.

AddressSanitizer

The flag -fsanitize=address adds a red zone around every allocation and records what each byte belongs to. A write past the end of an array is caught at the instruction that does it, not later when the heap is corrupted.

The program allocates three ints and reads index 3, which is one past the end. The report names the read, the size, the allocation site and the address, and it ends the process with a non-zero status.

The addresses and the process id in ==...== change per run; the file, the line and the size do not. The page shows the first lines of the report; the rest is a dump of the shadow memory that the sanitizer keeps around every allocation. Build the program with -g as well, or the report has no line numbers.

#include <iostream> int main() { int* values = new int[3]{1, 2, 3}; std::cout << values[3] << '\n'; delete[] values; }

g++ -g -fdebug-prefix-map=$PWD=. -fsanitize=address -std=c++23 -Wall -Wextra asan.cpp -o asan
ASAN_OPTIONS=print_legend=0 ./asan 2>&1 | head -12

=================================================================
==1660==ERROR: AddressSanitizer: heap-buffer-overflow on address 0x79200ad2001c at pc 0x5e274890f329 bp 0x7fffcdef2ad0 sp 0x7fffcdef2ac0
READ of size 4 at 0x79200ad2001c thread T0
#0 0x5e274890f328 in main asan.cpp:7
#1 0x7d000baad153 in libc_start_main_stage2 src/env/__libc_start_main.c:95

0x79200ad2001c is located 0 bytes after 12-byte region [0x79200ad20010,0x79200ad2001c)
allocated by thread T0 here:
#0 0x7d000b49674d in operator new[](unsigned long) (/usr/lib/libasan.so.8+0xee74d) (BuildId: 49cdec61ea4a7e68d1b3321b11e733d1f2124ac9)
#1 0x5e274890f206 in main asan.cpp:5
#2 0x7d000baad153 in libc_start_main_stage2 src/env/__libc_start_main.c:95

UndefinedBehaviorSanitizer

The flag -fsanitize=undefined instruments the operations whose result the standard does not define: signed overflow, a division by zero, a shift by the width of the type, a null dereference that the optimiser has not removed.

The program adds one to the largest int. The sanitizer prints a runtime error with the expression and the type, and the program continues with the wrapped value, which is why the second line still appears.

The report is a warning and the program keeps running, unless -fno-sanitize-recover is given. For a test suite the stopping form is usually what you want.

#include <iostream> int main() { int value = 2147483647; value += 1; std::cout << value << '\n'; }

g++ -g -fsanitize=undefined -std=c++23 -Wall -Wextra ubsan.cpp -o ubsan
./ubsan

ubsan.cpp:6:11: runtime error: signed integer overflow: 2147483647 + 1 cannot be represented in type 'int'
-2147483648

Use after free

A read after delete is the bug that the ordinary build hides: the memory is still mapped, the value is often still there, and the program works until the allocator reuses the block.

The program deletes an array and then reads from it. AddressSanitizer marks the block as freed and reports the read as a use-after-free, and the page shows the first lines of that report.

This is the class of bug that a sanitizer catches better than a review, because the code looks correct: the pointer is valid, only the object is gone. Lesson 031 showed the same mistake with a double free.

#include <iostream> int main() { int* values = new int[3]{1, 2, 3}; delete[] values; std::cout << values[0] << '\n'; }

g++ -g -fdebug-prefix-map=$PWD=. -fsanitize=address -std=c++23 -Wall -Wextra use_after_free.cpp -o use_after_free
ASAN_OPTIONS=print_legend=0 ./use_after_free 2>&1 | head -12

=================================================================
==1677==ERROR: AddressSanitizer: heap-use-after-free on address 0x6d0c6a660010 at pc 0x5a464ce80334 bp 0x7ffe6eb84450 sp 0x7ffe6eb84440
READ of size 4 at 0x6d0c6a660010 thread T0
#0 0x5a464ce80333 in main use_after_free.cpp:9
#1 0x70ec6b40a153 in libc_start_main_stage2 src/env/__libc_start_main.c:95

0x6d0c6a660010 is located 0 bytes inside of 12-byte region [0x6d0c6a660010,0x6d0c6a66001c)
freed by thread T0 here:
#0 0x70ec6adf42b5 in operator delete[](void*) (/usr/lib/libasan.so.8+0xef2b5) (BuildId: 49cdec61ea4a7e68d1b3321b11e733d1f2124ac9)
#1 0x5a464ce802fc in main use_after_free.cpp:7
#2 0x70ec6b40a153 in libc_start_main_stage2 src/env/__libc_start_main.c:95

LeakSanitizer

LeakSanitizer is part of AddressSanitizer on Linux. At exit it reports every allocation that was not freed, with the stack that allocated it.

The program allocates four ints and returns without deleting them. The report lists the leak, its size and the line of the new, and the exit status of the process stays 0 unless the leak is made fatal.

A leak is not a crash, which is why it survives so long in a program that runs short jobs. A test run under the sanitizer sees it immediately, and that is the cheapest place to find it.

int main() { int* values = new int[4]; values[0] = 1; return values[0] - 1; }

g++ -g -fdebug-prefix-map=$PWD=. -fsanitize=address -std=c++23 -Wall -Wextra leak.cpp -o leak
ASAN_OPTIONS=print_legend=0:exitcode=0 ./leak


=================================================================
==1684==ERROR: LeakSanitizer: detected memory leaks

Direct leak of 16 byte(s) in 1 object(s) allocated from:
#0 0x7c705278374d in operator new[](unsigned long) (/usr/lib/libasan.so.8+0xee74d) (BuildId: 49cdec61ea4a7e68d1b3321b11e733d1f2124ac9)
#1 0x58b72ee7a1c6 in main leak.cpp:3
#2 0x7c7052d9a153 in libc_start_main_stage2 src/env/__libc_start_main.c:95

SUMMARY: AddressSanitizer: 16 byte(s) leaked in 1 allocation(s).

A clean run

A program without a bug produces no report at all. That silence is the useful signal: the same test suite that printed a report for the previous section prints nothing here.

The program sums a vector and prints the result under AddressSanitizer. The only output is the sum, and the exit status is 0.

A project usually has a sanitizer configuration of its build with the tests attached to it, so that the silence is checked on every change. Lesson 101 shows where that configuration goes.

#include <iostream> #include <numeric> #include <vector> int main() { std::vector<int> values{1, 2, 3, 4}; std::cout << std::accumulate(values.begin(), values.end(), 0) << '\n'; }

g++ -g -fsanitize=address -std=c++23 -Wall -Wextra clean.cpp -o clean
./clean

10

Exercises

  1. Compile asan.cpp without the sanitizer and run it. What does the program print, and why is that worse than the report?
  2. Add -fsanitize=address,undefined to a program of your own and run its tests under it.
  3. Write a program that leaks a std::string's buffer by allocating with new and check the LeakSanitizer report.

Questions

A new set of questions in a new order every time. Choose one answer for each, then check.

  1. What does AddressSanitizer check?

    Answer

    Memory accesses and allocations

    ASan instruments loads and stores and reports out-of-bounds and use-after-free.

  2. What does UndefinedBehaviorSanitizer report?

    Answer

    Operations the standard leaves undefined

    UBSan instruments the operations such as signed overflow and bad shifts.

  3. What does LeakSanitizer report?

    Answer

    Memory that was allocated and never freed

    The report lists the allocation sites of the leaked blocks.

  4. What is the cost of a sanitizer build?

    Answer

    The program is slower and larger

    The checks run with the program, so the instrumented binary pays for them.

  5. This program is built with -fsanitize=address and run. What does the sanitizer report? int* values = new int[3]{1, 2, 3}; std::cout << values[3];

    Answer

    heap-buffer-overflow on the read of values[3]

    The array has indices 0 to 2, and the read of index 3 is past the end of the heap block.

  6. This program is built with -fsanitize=undefined and run. What does the sanitizer report? int value = 2147483647; value += 1;

    Answer

    signed integer overflow: 2147483647 + 1 cannot be represented

    The signed addition overflows, which UBSan reports at the operation.

  7. This program is built with -fsanitize=address and run. What does the sanitizer report? int* value = new int{7}; delete value; std::cout << *value;

    Answer

    heap-use-after-free on the read after delete

    The block was returned to the allocator, and the read accesses freed memory.

  8. This program is built with -fsanitize=address and run. What does the leak checker report? int* block = new int[4]; block[0] = 1;

    Answer

    LeakSanitizer: detected memory leaks, 16 bytes in one block

    The array of four ints is never freed, and the checker reports the sixteen bytes.

  9. What does -fno-omit-frame-pointer add to an ASan build?

    Answer

    A frame pointer that makes the backtrace reliable

    The sanitizer walks the stack, and the frame pointer keeps that walk accurate.

  10. When should a project run the sanitizer build?

    Answer

    In the test job, on every change

    The checks catch bugs that the normal build hides, and the test job has the time.

The code on this page was built with g++ 15.2.0 and run on Alpine Linux 3.24.3 (x86_64) in a Docker container.

Article author: Arthur Isaev