C++ Sanitizers: ASan, TSan, UBSan, and MSan Explained

Key takeaways

Clang/GCC sanitizers for C++: AddressSanitizer, ThreadSanitizer, UBSan, and CI examples. Catch buffer overflows, UAF, races, and UB with compile flags and examples.

Introduction

Sanitizers are compiler-assisted tools that detect memory errors, data races, and undefined behavior at runtime. They are faster than Valgrind for many workflows and provide accurate stack traces so you can fix bugs quickly.

The reason they are faster than Valgrind is where the checking happens. Valgrind runs your unmodified binary on a synthetic CPU and inspects every instruction, which typically costs a 20–50× slowdown. A sanitizer is built into your program by the compiler: at compile time it inserts a small check before each relevant operation (each memory access, each arithmetic operation, each atomic), and links a runtime library that tracks the state those checks look at. The compiler knows types, array bounds, and which accesses are to the stack or heap, so the checks can be both precise and cheap.

That design has two consequences you should keep in mind for the rest of this article. First, only code that was compiled with the sanitizer is checked. A prebuilt library, or code in a .so compiled without the flag, is invisible (ASan still intercepts malloc/free and common libc functions, but not arbitrary accesses inside that library). Second, a sanitizer only sees what actually runs. A bug on a code path your tests never execute is not reported, which is why sanitizers are most valuable when combined with a good test suite or a fuzzer.


Sanitizer overview

Main sanitizers

# AddressSanitizer (ASan) — memory errors
g++ -fsanitize=address -g program.cpp -o program
# ThreadSanitizer (TSan) — data races
g++ -fsanitize=thread -g program.cpp -o program
# UndefinedBehaviorSanitizer (UBSan) — undefined behavior
g++ -fsanitize=undefined -g program.cpp -o program
# MemorySanitizer (MSan) — uninitialized memory (Clang only)
clang++ -fsanitize=memory -fsanitize-memory-track-origins -g program.cpp -o program
# LeakSanitizer (LSan) — leaks (often bundled with ASan)
g++ -fsanitize=leak -g program.cpp -o program

Comparison

SanitizerDetectsOverheadPairing
ASanMemory errors~2×ASan + UBSan
TSanData races~5–15×Alone
UBSanUndefined behavior~1.2×ASan + UBSan
MSanUninitialized reads~3×Alone
LSanLeaksLowOften with ASan

The overhead column is CPU time. Memory overhead matters just as much in CI: ASan typically needs 2–3× the memory of a normal build, and TSan 5–10×, which can push test jobs past the limits of a small CI runner and cause out-of-memory kills that look like random crashes.

The “Alone” entries are hard rules, not preferences. ASan, TSan, and MSan each take over the program’s memory layout with their own shadow memory scheme, so no two of them can be combined in one binary. The practical setup is therefore several builds: one ASan+UBSan build that runs the whole test suite, one TSan build for tests that exercise concurrency, and optionally an MSan build (which is the hardest to set up, see below).

MSan is only available in Clang, and it has a strict requirement: every piece of code in the process must be instrumented, including the C++ standard library. An uninstrumented libstdc++ writes memory that MSan does not know was initialized, and you get a flood of false reports. In practice that means building libc++ with MSan yourself, which is why MSan is mostly used by projects with dedicated fuzzing infrastructure. For most teams, ASan+UBSan plus compiler warnings like -Wuninitialized catch enough uninitialized-read bugs.


AddressSanitizer (ASan)

Buffer overflow

// bug1.cpp
#include <iostream>
int main() {
    int arr[10];
    
    // Bug: out of bounds
    for (int i = 0; i <= 10; ++i) {
        arr[i] = i;
    }
    
    return 0;
}

Build and run:

$ g++ -fsanitize=address -g bug1.cpp -o bug1
$ ./bug1
=================================================================
==12345==ERROR: AddressSanitizer: stack-buffer-overflow on address 0x7ffc...
WRITE of size 4 at 0x7ffc... thread T0
    #0 0x... in main bug1.cpp:8
    #1 0x... in __libc_start_main
    
Address 0x7ffc... is located in stack of thread T0 at offset 40 in frame
    #0 0x... in main bug1.cpp:4

How does ASan know that arr[10] is out of bounds? It maintains shadow memory: for every 8 bytes of application memory, one shadow byte records how many of those 8 bytes are addressable. Around every stack array, heap allocation, and global, the compiler adds “redzones”, poisoned bytes that no valid access should touch. Before each load or store, the inserted check looks up the shadow byte for that address, which is a shift and an add, and reports if it is poisoned. That is why the check is cheap, and also why ASan misses one class of bug: an access that jumps over the redzone into another valid object. arr[1000] might land inside a different, perfectly addressable array, and ASan says nothing.

Note that without the sanitizer, this program most likely prints nothing and exits normally. Writing arr[10] usually overwrites a neighboring stack slot, and the result depends on the compiler’s stack layout and optimization level. This is the whole point: undefined behavior that happens to work in testing is the most dangerous kind.

Use-after-free

// bug2.cpp
#include <iostream>
int main() {
    int* ptr = new int(42);
    delete ptr;
    
    // Bug: use after free
    std::cout << *ptr << std::endl;
    
    return 0;
}

ASan output:

$ g++ -fsanitize=address -g bug2.cpp -o bug2
$ ./bug2
=================================================================
==12346==ERROR: AddressSanitizer: heap-use-after-free on address 0x602...
READ of size 4 at 0x602... thread T0
    #0 0x... in main bug2.cpp:7
    
0x602... is located 0 bytes inside of 4-byte region [0x602..., 0x602...)
freed by thread T0 here:
    #0 0x... in operator delete(void*)
    #1 0x... in main bug2.cpp:5

The “freed by” stack is the most valuable part of a use-after-free report. In real code, the free and the later use are usually in different functions, often in different threads, and knowing who freed the object is what leads you to the ownership bug. ASan can report it because it does not reuse freed memory immediately: freed blocks sit in a quarantine (256 MB by default) and stay poisoned while they are there. If a use happens after the block has left quarantine and been reused, ASan cannot detect it any more. For programs that allocate heavily, raising ASAN_OPTIONS=quarantine_size_mb=... can catch use-after-free bugs with a long delay between free and use.

Memory leak

// bug3.cpp
#include <iostream>
int main() {
    // Bug: leak
    int* leak = new int(42);
    
    return 0;  // missing delete
}

ASan output:

$ g++ -fsanitize=address -g bug3.cpp -o bug3
$ ./bug3
=================================================================
==12347==ERROR: LeakSanitizer: detected memory leaks
Direct leak of 4 byte(s) in 1 object(s) allocated from:
    #0 0x... in operator new(unsigned long)
    #1 0x... in main bug3.cpp:5

ThreadSanitizer (TSan)

Data race

// race.cpp
#include <thread>
#include <iostream>
int counter = 0;
void increment() {
    for (int i = 0; i < 100000; ++i) {
        ++counter;  // data race!
    }
}
int main() {
    std::thread t1(increment);
    std::thread t2(increment);
    
    t1.join();
    t2.join();
    
    std::cout << "counter: " << counter << std::endl;
    
    return 0;
}

TSan output:

$ g++ -fsanitize=thread -g race.cpp -o race
$ ./race
==================
WARNING: ThreadSanitizer: data race (pid=12348)
  Write of size 4 at 0x... by thread T1:
    #0 increment() race.cpp:7
  
  Previous write of size 4 at 0x... by thread T2:
    #0 increment() race.cpp:7

TSan does not look for races by timing. It tracks the happens-before relation: every mutex lock and unlock, atomic operation, thread start, and join creates ordering between threads, and TSan records a small vector clock for each memory location. If two accesses to the same location, at least one of them a write, are not ordered by any synchronization, that is a data race, even if in this particular run they happened a millisecond apart. This is why TSan finds races that would take millions of runs to trigger as a visible bug, and why its reports are almost never false positives, as long as all synchronization goes through something TSan understands.

That condition is where TSan runs into trouble in practice. Custom synchronization built on inline assembly or raw futex calls, and libraries compiled without -fsanitize=thread, create synchronization that TSan cannot see, and the result is reports that look like races but are not. On the other side, TSan only reports races on code paths that actually execute concurrently during the run, so a test that happens to run the two threads one after the other proves nothing.

One practical problem is worth knowing before you add TSan to CI: on some newer Linux kernels with high address-space randomization (for example, Ubuntu 22.04+ with vm.mmap_rnd_bits=32), older TSan runtimes fail at startup with FATAL: ThreadSanitizer: unexpected memory mapping. The binary crashes before running any test, which easily looks like a broken build. Newer compiler versions handle this, and the usual workaround on older ones is sudo sysctl vm.mmap_rnd_bits=28 on the runner. This is the kind of issue that makes people give up on TSan on day one, which is a pity, because data races are exactly the bugs that ordinary tests almost never reveal.

Fix:

#include <atomic>
#include <thread>
#include <iostream>
std::atomic<int> counter{0};
void increment() {
    for (int i = 0; i < 100000; ++i) {
        ++counter;  // atomic
    }
}
int main() {
    std::thread t1(increment);
    std::thread t2(increment);
    
    t1.join();
    t2.join();
    
    std::cout << "counter: " << counter << std::endl;  // 200000
    
    return 0;
}

UndefinedBehaviorSanitizer (UBSan)

Undefined behavior

// ub.cpp
#include <iostream>
int main() {
    // Bug 1: division by zero
    int x = 0;
    int y = 5 / x;
    
    // Bug 2: signed overflow
    int max = 2147483647;
    int overflow = max + 1;
    
    // Bug 3: null pointer dereference
    int* ptr = nullptr;
    int value = *ptr;
    
    // Bug 4: out-of-bounds array index
    int arr[10];
    int index = 15;
    int val = arr[index];
    
    return 0;
}

UBSan output:

$ g++ -fsanitize=undefined -g ub.cpp -o ub
$ ./ub
ub.cpp:6:15: runtime error: division by zero
ub.cpp:10:23: runtime error: signed integer overflow: 2147483647 + 1 cannot be represented in type 'int'
ub.cpp:14:17: runtime error: load of null pointer of type 'int'
ub.cpp:19:17: runtime error: index 15 out of bounds for type 'int [10]'

(The line numbers in this sample output are illustrative. Your compiler will report the lines of your own file.)

UBSan’s default behavior is the most important thing to know about it: it prints the error and keeps running. In the example, the program prints each message and continues to the next statement (until the null dereference actually crashes it). With bugs that do not crash, a UBSan build prints its reports and then exits with status 0. In CI, that means UBSan reports scroll past in the log while the job stays green. Add -fno-sanitize-recover=all at compile time (or UBSAN_OPTIONS=halt_on_error=1 for the checks that allow it) so the first report aborts the program and fails the test. The same applies to printing stack traces: set UBSAN_OPTIONS=print_stacktrace=1, otherwise you only get a file and line number.

Why do these bugs need a tool at all? Because the compiler’s optimizer is allowed to assume they never happen. A classic example is an overflow check like if (x + 1 < x): since signed overflow is undefined, the optimizer may conclude the condition is always false and delete the check entirely. The code works at -O0 and silently loses its safety check at -O2. UBSan reports the overflow at the place it happens, regardless of optimization level.


CI/CD integration

CMake

# CMakeLists.txt
cmake_minimum_required(VERSION 3.15)
project(MyProject)
set(CMAKE_CXX_STANDARD 17)
option(ENABLE_ASAN "Enable AddressSanitizer" OFF)
option(ENABLE_TSAN "Enable ThreadSanitizer" OFF)
option(ENABLE_UBSAN "Enable UBSanitizer" OFF)
if(ENABLE_ASAN)
    add_compile_options(-fsanitize=address -fno-omit-frame-pointer)
    add_link_options(-fsanitize=address)
endif()
if(ENABLE_TSAN)
    add_compile_options(-fsanitize=thread)
    add_link_options(-fsanitize=thread)
endif()
if(ENABLE_UBSAN)
    add_compile_options(-fsanitize=undefined -fno-sanitize-recover=all)
    add_link_options(-fsanitize=undefined)
endif()
add_executable(myapp main.cpp)

Build:

# ASan
cmake -DENABLE_ASAN=ON ..
make
# TSan
cmake -DENABLE_TSAN=ON ..
make
# UBSan
cmake -DENABLE_UBSAN=ON ..
make

Two details in this CMake setup matter. The sanitizer flag must be passed to the linker too (add_link_options), because it pulls in the runtime library. Forgetting it produces a flood of undefined-reference errors. And a sanitizer build must use its own build directory: mixing object files compiled with and without -fsanitize=address in one binary leads to confusing crashes or missed reports. Most teams use a CMake preset or a separate build type for each sanitizer instead of options on the main build.

GitHub Actions

# .github/workflows/sanitizers.yml
name: Sanitizers
on: [push, pull_request]
jobs:
  asan:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      
      - name: Build with ASan
        run: |
          g++ -fsanitize=address -g src/*.cpp -o test_asan
      
      - name: Run Tests
        run: ./test_asan
  
  tsan:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      
      - name: Build with TSan
        run: |
          g++ -fsanitize=thread -g src/*.cpp -o test_tsan
      
      - name: Run Tests
        run: ./test_tsan
  
  ubsan:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      
      - name: Build with UBSan
        run: |
          g++ -fsanitize=undefined -fno-sanitize-recover=all -g src/*.cpp -o test_ubsan
      
      - name: Run Tests
        run: ./test_ubsan

This workflow is a minimal sketch. In a real project, the binary being run should be your test suite, not the application, because sanitizers only check code that executes. Also consider merging the ASan and UBSan jobs into one -fsanitize=address,undefined build, which halves the CI time with no loss of coverage. TSan stays separate.


Common issues

Issue 1: Performance overhead

// Typical overhead:
// - ASan: ~2×
// - TSan: ~5–15×
// - UBSan: ~1.2×
// ✅ Dev/test only
// ✅ Strip from release builds

Issue 2: Compatibility

# ❌ ASan + TSan together (not supported this way)
g++ -fsanitize=address,thread program.cpp  # error
# ✅ Separate builds
g++ -fsanitize=address program.cpp  # ASan
g++ -fsanitize=thread program.cpp   # TSan
# ✅ ASan + UBSan is OK
g++ -fsanitize=address,undefined program.cpp

Issue 3: False positives / intentional patterns

// Suppress with attribute
__attribute__((no_sanitize("address")))
void intentionalOverflow() {
    // ...
}
// Or suppression file asan.supp
ASAN_OPTIONS=suppressions=asan.supp ./program

Use suppressions sparingly, and know what each kind can do. ASan suppression files only match interceptor functions and whole libraries, not arbitrary source locations. Leak reports are suppressed separately, through LSAN_OPTIONS=suppressions=lsan.supp, and are the most common legitimate use: a third-party library that allocates a global object once and never frees it. no_sanitize on a function is for code that intentionally reads out of bounds, such as some optimized string routines. Before adding either, make sure the report really is intentional behavior. A suppressed real bug is worse than no sanitizer, because the clean report creates false confidence.

Issue 4: Debug symbols

# ❌ No -g (weaker stacks)
g++ -fsanitize=address program.cpp
# ✅ Add -g
g++ -fsanitize=address -g program.cpp
# ✅ Lower optimization for clearer traces
g++ -fsanitize=address -g -O1 program.cpp

Bug-hunting example

// buggy_program.cpp
#include <iostream>
#include <string>
#include <vector>
#include <thread>
class DataProcessor {
    std::vector<int> data;
    int counter = 0;
    
public:
    void leakyFunction() {
        int* leak = new int(42);
        // missing delete
    }
    
    void bufferOverflow() {
        int arr[10];
        for (int i = 0; i <= 10; ++i) {
            arr[i] = i;
        }
    }
    
    void useAfterFree() {
        int* ptr = new int(42);
        delete ptr;
        std::cout << *ptr << std::endl;
    }
    
    void dataRace() {
        auto increment = [this]() {
            for (int i = 0; i < 100000; ++i) {
                ++counter;  // no synchronization
            }
        };
        
        std::thread t1(increment);
        std::thread t2(increment);
        
        t1.join();
        t2.join();
    }
    
    void integerOverflow() {
        int max = 2147483647;
        int overflow = max + 1;
        std::cout << overflow << std::endl;
    }
};
// Run one scenario per process: ASan stops at the first error it finds
int main(int argc, char** argv) {
    DataProcessor processor;
    std::string mode = argc > 1 ? argv[1] : "";
    if (mode == "leak")     processor.leakyFunction();
    if (mode == "overflow") processor.bufferOverflow();
    if (mode == "uaf")      processor.useAfterFree();
    if (mode == "race")     processor.dataRace();
    if (mode == "int")      processor.integerOverflow();
    return 0;
}

The main function selects one bug per run on purpose. If it called every function in sequence, ASan would abort at the first error (the buffer overflow) and you would never see the use-after-free report. That mirrors a real limitation: fixing sanitizer findings is iterative, one report at a time. It also shows why sanitizers depend on tests. If main did not call any of these functions, all three builds would run cleanly and report nothing, even though the class is full of bugs.

Test script:

#!/bin/bash
echo "=== AddressSanitizer ==="
g++ -fsanitize=address -g buggy_program.cpp -o test_asan
for m in leak overflow uaf; do ./test_asan $m; done
echo "=== ThreadSanitizer ==="
g++ -fsanitize=thread -g buggy_program.cpp -o test_tsan
./test_tsan race
echo "=== UBSanitizer ==="
g++ -fsanitize=undefined -g buggy_program.cpp -o test_ubsan
./test_ubsan int

Sanitizer options

Environment variables

# ASan
export ASAN_OPTIONS=detect_leaks=1:halt_on_error=0
# TSan
export TSAN_OPTIONS=history_size=7:halt_on_error=0
# UBSan
export UBSAN_OPTIONS=print_stacktrace=1:halt_on_error=0
./program

Useful ASan options

ASAN_OPTIONS=detect_leaks=1
ASAN_OPTIONS=detect_stack_use_after_return=1
ASAN_OPTIONS=detect_container_overflow=1
ASAN_OPTIONS=log_path=asan.log
ASAN_OPTIONS=detect_leaks=1:log_path=asan.log:halt_on_error=0 ./program

halt_on_error=0 for ASan only takes effect if the program was also compiled with -fsanitize-recover=address. Without it, ASan always aborts at the first error. Continuing after a memory error is a debugging convenience, not a normal mode, because the program state is already corrupted. Everything reported after the first error may be a consequence of it. detect_leaks=1 is the default on Linux x86_64. On macOS, leak detection is not supported with Apple’s toolchain by default, so a clean leak report there does not mean what it means on Linux.


Choosing a sanitizer and where to start

Bug typeSanitizerWhen
Buffer overflowASanAlways in dev
LeaksASan / LSanAlways
Use-after-freeASanAlways
Data raceTSanMultithreaded code
Integer UBUBSanArithmetic-heavy code
Uninit readsMSanComplex initialization

ASan and TSan cannot be combined in one binary, and MSan needs every linked library instrumented, so plan separate builds rather than one build with every flag.

If a project has no sanitizer builds at all, the order that pays off fastest is: an ASan+UBSan build of the existing test suite in CI with -fno-sanitize-recover=all, then a TSan build limited to the tests that use threads. Expect the first ASan run on an older codebase to find real bugs, and plan time to fix them before making the job required. Turning on a sanitizer job that is red from day one, and then ignoring it, is the most common way these setups fail.

Next steps