C++ Runtime Checking: AddressSanitizer and ThreadSanitizer

Why Static Analysis Is Not Enough

Memory bugs in C++ are categorized as “undefined behavior” — the program may appear to work correctly for months, then corrupt data or crash under specific allocation patterns, load levels, or OS scheduling decisions. Static analysis catches some patterns, but many bugs only manifest at runtime.

AddressSanitizer (ASan) instruments every memory access. It maintains shadow memory tracking which bytes are valid, and reports the exact location of:

  • Heap buffer overflows
  • Stack buffer overflows
  • Use-after-free
  • Use-after-scope (returning reference to local)
  • Double-free

ThreadSanitizer (TSan) tracks thread operations — reads, writes, locks, and atomics — and reports data races: two threads accessing the same memory without synchronization where at least one thread writes.

Both sanitizers are available in GCC and Clang. MSVC supports /fsanitize=address from Visual Studio 2019 16.9+.


AddressSanitizer (ASan)

Build Flags

# GCC or Clang
#   -fsanitize=address        enable ASan
#   -fsanitize=undefined      also enable UBSan (compatible with ASan)
#   -fno-omit-frame-pointer   better stack traces
#   -g                        source line numbers in reports
#   -O1                       some optimization, but preserve frames
g++ -std=c++20 \
    -fsanitize=address \
    -fsanitize=undefined \
    -fno-omit-frame-pointer \
    -g \
    -O1 \
    -o my_program my_program.cpp

# CMake — add to debug config
cmake -DCMAKE_CXX_FLAGS="-fsanitize=address,undefined -fno-omit-frame-pointer" \
      -DCMAKE_BUILD_TYPE=RelWithDebInfo ..

(The flag explanations sit above the command on purpose: a comment after a trailing \ breaks the line continuation in the shell, and the remaining flags run as a separate, failing command.)

-fsanitize=address must be passed when linking as well as compiling, because it pulls in the ASan runtime library that replaces malloc/free and intercepts functions like memcpy. With CMAKE_CXX_FLAGS, CMake passes the flags to the link step of executables automatically; if you add them with target_compile_options only, the link fails with a wall of undefined reference to '__asan_report_load4' errors, and you also need target_link_options.

The way ASan works explains both its power and its limits. The compiler inserts a check before every load and store that consults shadow memory (one shadow byte per 8 bytes of application memory) to see whether the address is addressable. The allocator surrounds each heap block with poisoned “redzones” and keeps freed memory in a quarantine for a while instead of reusing it immediately. An access that lands in a redzone or in quarantined memory is reported at once. An overflow that jumps far past the redzone into another valid block, or a use-after-free that happens after the quarantine has recycled the memory, can slip through; ASan also does not detect reads of uninitialized memory — that is MemorySanitizer (Clang only), which requires every library, including the standard library, to be instrumented.

Use-After-Free Example

// uaf_example.cpp
#include <iostream>

int main() {
    int* p = new int(42);
    delete p;

    // Access after free — undefined behavior
    std::cout << *p << "\n";  // ASan catches this immediately

    return 0;
}
$ g++ -fsanitize=address -fno-omit-frame-pointer -g -o uaf uaf_example.cpp
$ ./uaf

==12345==ERROR: AddressSanitizer: heap-use-after-free on address 0x602000000010
READ of size 4 at 0x602000000010 thread T0
    #0 0x... in main uaf_example.cpp:8
    ...
0x602000000010 is located 0 bytes inside of 4-byte region [0x602000000010,0x602000000014)
freed by thread T0 here:
    #0 0x... in operator delete(void*)
    #1 0x... in main uaf_example.cpp:5

ASan tells you the exact line of the use-after-free AND the exact line where the memory was freed. Compare this to debugging a crash dump with no sanitizer — you would see a corrupted stack or wrong value with no indication of when the memory was freed.

Read the report from the top down in three parts: the first stack is the bad access, the “freed by thread” stack is where the memory was released, and a third “previously allocated by thread” stack (trimmed above) shows where it came from. In real code the bug is usually in the gap between the second and third stacks — someone kept a raw pointer, reference, or iterator to an object that another part of the code destroyed. The most common version I see in practice is an iterator or reference into a std::vector that is still used after a push_back reallocated the buffer; ASan reports it as a heap-use-after-free whose “freed by” stack points into std::vector::_M_realloc_insert, which is the giveaway.

By default ASan aborts on the first error. That is usually what you want, since memory is already corrupted after the first bad write; ASAN_OPTIONS=halt_on_error=0 (together with compiling with -fsanitize-recover=address) keeps going for triage, but later reports may be consequences of the first.

Heap Buffer Overflow

#include <vector>

int main() {
    std::vector<int> v = {1, 2, 3};

    // Write one element past the end
    v[3] = 99;  // ASan: heap-buffer-overflow on write

    return 0;
}
ERROR: AddressSanitizer: heap-buffer-overflow
WRITE of size 4 at address 0x... thread T0
    #0 0x... in main overflow.cpp:5

Without ASan, this may silently corrupt adjacent heap metadata and crash later — completely unrelated to the actual bug site.

This is detected because the vector’s buffer holds exactly three elements, so index 3 lands in the redzone after the allocation. If the vector had spare capacity (say after reserve(10)), v[3] would be inside the allocated block, and plain ASan would say nothing even though the element does not logically exist. libc++ and recent libstdc++ can annotate std::vector so ASan reports these as container-overflow; with libstdc++ that requires defining _GLIBCXX_SANITIZE_VECTOR. Alternatively, -D_GLIBCXX_ASSERTIONS turns operator[] into a bounds-checked access and aborts with the index, which catches this class of bug even without a sanitizer and is cheap enough to leave on in many test builds.

Stack Buffer Overflow

#include <cstring>

void process(const char* input) {
    char buffer[8];
    strcpy(buffer, input);  // overflow if input > 7 chars + null
}

int main() {
    process("this is way too long for the buffer");
    return 0;
}
ERROR: AddressSanitizer: stack-buffer-overflow
WRITE of size 35 at address 0x... thread T0
    #0 0x... in process stack_overflow.cpp:3

Memory Leak Detection (LSan)

LeakSanitizer is bundled with ASan on most platforms:

#include <memory>

int main() {
    int* leaked = new int[100];  // never deleted
    // program exits without freeing 'leaked'
}
# LSan runs automatically with ASan on Linux
$ ASAN_OPTIONS=detect_leaks=1 ./my_program

ERROR: LeakSanitizer: detected memory leaks
Direct leak of 400 byte(s) in 1 object(s) allocated from:
    #0 0x... in operator new[](unsigned long)
    #1 0x... in main leak.cpp:4

Leak detection runs once, at process exit, by scanning memory for pointers to each live heap block. Direct leaks are blocks nothing points to; indirect leaks are blocks reachable only from other leaked blocks, so fixing the direct leak usually removes the indirect ones too. Two consequences: a process that is killed (SIGKILL, _exit, a crash) never runs the check, and memory that is still referenced from a global at exit — a cache that grows forever — is not a “leak” in LSan’s sense, even if it is one operationally. LSan is on by default with ASan on Linux; on macOS it has to be enabled explicitly and is not supported everywhere. For intentional one-time allocations in third-party code, a suppression such as leak:libfontconfig in a file passed through LSAN_OPTIONS=suppressions=lsan.supp keeps CI green without hiding new leaks in your own code.


ThreadSanitizer (TSan)

Build Flags

# TSan is separate from ASan — do NOT combine them
g++ -std=c++20 \
    -fsanitize=thread \
    -fno-omit-frame-pointer \
    -g \
    -O1 \
    -o my_program my_program.cpp

Data Race Example

#include <thread>
#include <iostream>

int counter = 0;  // shared variable, no synchronization

void increment() {
    for (int i = 0; i < 100000; i++) {
        counter++;  // read-modify-write without lock — data race
    }
}

int main() {
    std::thread t1(increment);
    std::thread t2(increment);

    t1.join();
    t2.join();

    std::cout << counter << '\n';  // might print anything
}
$ g++ -fsanitize=thread -fno-omit-frame-pointer -g -o race race.cpp
$ ./race

WARNING: ThreadSanitizer: data race (pid=12345)
  Write of size 4 at 0x... by thread T2:
    #0 increment() race.cpp:7

  Previous write of size 4 at 0x... by thread T1:
    #0 increment() race.cpp:7

  Thread T1 (tid=..., running):
    #0 increment() race.cpp:7
    #1 main race.cpp:14

  Thread T2 (tid=..., started):
    ...

TSan identifies:

  • Which threads are racing
  • Which memory address they’re racing on
  • Whether each access is a read or write
  • The exact source line of each access

TSan is based on happens-before tracking, not on observing an actual collision. Each thread carries a vector clock, and synchronization operations (mutex lock/unlock, atomic operations with acquire/release semantics, thread creation and join) propagate those clocks. Two accesses race if neither happens-before the other. That is why TSan finds races that did not cause a wrong result in the run you observed — the two increments did not have to overlap in time, they just had no ordering. It is also why TSan has essentially no false positives as long as every synchronization is visible to it.

That condition is where most TSan noise comes from. Synchronization in code that is not compiled with -fsanitize=thread — a prebuilt third-party library, hand-written inline assembly, or custom lock-free code using std::atomic_thread_fence, which TSan models only partially — is invisible, so correctly synchronized accesses look like races. Rebuild dependencies with TSan where you can before reaching for suppressions. TSan only sees races on code paths that actually execute, so it is only as good as the concurrency in your tests: a test suite that runs everything on one thread will never report anything.

Correct Version

#include <thread>
#include <atomic>

std::atomic<int> counter{0};  // atomic — no race

void increment() {
    for (int i = 0; i < 100000; i++) {
        counter.fetch_add(1, std::memory_order_relaxed);
    }
}

Or with a mutex:

#include <thread>
#include <mutex>

int counter = 0;
std::mutex mtx;

void increment() {
    for (int i = 0; i < 100000; i++) {
        std::lock_guard<std::mutex> lock(mtx);
        counter++;
    }
}

memory_order_relaxed is enough for a pure counter because nothing else is ordered relative to it; the final value is read after join(), which synchronizes on its own. If the counter were used as a flag to publish other data (“when ready becomes 1, result is valid”), relaxed ordering would be a real bug and TSan would report the race on result — use release/acquire for that. Declaring the variable volatile does not fix the race in C++; volatile prevents some compiler optimizations but provides neither atomicity nor ordering, and TSan correctly still reports it.


CI Integration

Run separate sanitizer jobs in your CI pipeline:

GitHub Actions

# .github/workflows/sanitizers.yml
name: Sanitizers

on: [push, pull_request]

jobs:
  asan:
    name: AddressSanitizer
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Build with ASan
        run: |
          cmake -B build-asan \
            -DCMAKE_CXX_FLAGS="-fsanitize=address,undefined -fno-omit-frame-pointer" \
            -DCMAKE_BUILD_TYPE=RelWithDebInfo
          cmake --build build-asan
      - name: Run tests
        run: |
          cd build-asan
          ASAN_OPTIONS=detect_leaks=1 ctest --output-on-failure

  tsan:
    name: ThreadSanitizer
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Build with TSan
        run: |
          cmake -B build-tsan \
            -DCMAKE_CXX_FLAGS="-fsanitize=thread -fno-omit-frame-pointer" \
            -DCMAKE_BUILD_TYPE=RelWithDebInfo
          cmake --build build-tsan
      - name: Run tests
        run: ctest --test-dir build-tsan --output-on-failure

Two things commonly go wrong in this setup. First, UBSan by default prints runtime error: signed integer overflow ... and continues, so the test process exits with status 0 and the CI job stays green. Add -fno-sanitize-recover=undefined to the flags (or UBSAN_OPTIONS=halt_on_error=1:print_stacktrace=1) so UBSan findings fail the build. Second, on recent Linux kernels with high ASLR entropy (for example Ubuntu 24.04 runners), older TSan runtimes abort at startup with FATAL: ThreadSanitizer: unexpected memory mapping. Newer compilers handle it; otherwise the known workaround is sudo sysctl vm.mmap_rnd_bits=28 as a CI step before running tests.

CMake Presets

// CMakePresets.json
{
  "configurePresets": [
    {
      "name": "asan",
      "cacheVariables": {
        "CMAKE_BUILD_TYPE": "RelWithDebInfo",
        "CMAKE_CXX_FLAGS": "-fsanitize=address,undefined -fno-omit-frame-pointer"
      }
    },
    {
      "name": "tsan",
      "cacheVariables": {
        "CMAKE_BUILD_TYPE": "RelWithDebInfo",
        "CMAKE_CXX_FLAGS": "-fsanitize=thread -fno-omit-frame-pointer"
      }
    }
  ]
}

Suppression Files

Not every sanitizer report represents a real bug. Third-party libraries with known benign races, or code paths that are logically safe but trigger false positives, can be suppressed:

# tsan.supp — TSan suppression file
race:libcrypto:CRYPTO_atomic_add       # known-safe OpenSSL atomics
race:my_legacy_lib::LegacyGlobal::init # single-init pattern, actually safe
TSAN_OPTIONS="suppressions=tsan.supp" ./my_program

Use suppressions sparingly. Every suppression is a potential real bug being hidden. Prefer fixing the underlying issue.

The second entry deserves skepticism in particular. “Single-init pattern, actually safe” is how a lot of real double-checked-locking bugs are described: a plain bool initialized read outside a lock is a data race even if it “can only go from false to true”, and compilers are allowed to reorder the surrounding writes. std::call_once or a function-local static (thread-safe since C++11) expresses the same pattern without a race. When a suppression is unavoidable, keep it as narrow as possible (a specific function rather than a whole library) and put a comment in the file saying why, so the next person can re-evaluate it.


What Each Sanitizer Finds

SanitizerWhat it catchesOverhead
ASanHeap/stack buffer overflow, use-after-free, double-free, use-after-scope~2x CPU, ~2-3x memory
TSanData races between threads~5-15x CPU, ~5-10x memory
UBSanInteger overflow, null dereference, bad casts, alignment violations~minimal
LSanMemory leaks (bundled with ASan)Minimal extra

Recommended combination: run ASan + UBSan together (they’re compatible), and TSan in a separate job.


Running sanitizers where they pay off

  • ASan catches memory corruption at the exact access site — not where the crash eventually manifests
  • TSan detects data races that may only appear under specific scheduling — not reproducible otherwise
  • Never ship sanitized binaries to production in performance-sensitive code — the overhead is 2-15x
  • CI integration is the highest-value use: fail the build when a sanitizer finds a bug, before it reaches production
  • ASan + UBSan can run together; TSan requires a separate build (don’t mix them)
  • Use suppression files sparingly for known-safe third-party patterns — not as a way to silence real bugs
  • LeakSanitizer (LSan) is bundled with ASan on Linux — catches memory leaks without Valgrind’s overhead

Frequently Asked Questions (FAQ)

Q. Why does my sanitizer report show only raw addresses instead of file and line numbers?

A. The report is symbolized at runtime, so it needs debug info and a symbolizer. Build the sanitized binary with -g and -fno-omit-frame-pointer as shown above, and make sure llvm-symbolizer is on PATH (or point ASAN_SYMBOLIZER_PATH at it). In CI this is the most common reason a sanitizer failure is unreadable: the job installs the compiler runtime but not the symbolizer.