Skip to content
C++
Sanitizer
Updated 2025-01-01T00:00:00.000Z

AddressSanitizer (ASan) In Depth

Deep dive into AddressSanitizer — use-after-free, heap buffer overflow, stack overflow, global overflow, use-after-return, leak sanitizer, and reading reports.

Quick start

bash
# Compile with ASan
clang++ -fsanitize=address -fno-omit-frame-pointer -g -O1 -o myapp myapp.cpp
# or GCC:
g++ -fsanitize=address -fno-omit-frame-pointer -g -O1 -o myapp myapp.cpp

# Run — crash on first error
./myapp

# With leak detection
ASAN_OPTIONS=detect_leaks=1 ./myapp

What ASan detects

Bug typeDetection
Heap buffer overflow✅
Stack buffer overflow✅
Global buffer overflow✅
Use-after-free✅
Use-after-return✅ (with ASAN_OPTIONS=detect_stack_use_after_return=1)
Use-after-scope✅
Memory leaks✅ (via LeakSanitizer, default on Linux)
Double-free✅
Invalid free (unallocated ptr)✅
Uninitialized reads❌ (use MemorySanitizer instead)
Data races❌ (use ThreadSanitizer)

Reading ASan reports

Heap buffer overflow

cpp
==12345==ERROR: AddressSanitizer: heap-buffer-overflow on address 0x602000000054
READ of size 4 at 0x602000000054 thread T0
    #0 0x401234 in main (/tmp/example+0x401234)
       (/tmp/example.cpp:10:18)

0x602000000054 is located 4 bytes to the right of 16-byte region [0x602000000040,0x602000000050)
allocated by thread T0 here:
    #0 0x7f... in operator new[](unsigned long) ...
    #1 0x401200 in main (/tmp/example.cpp:8:16)

Reading the report:

  1. Error type — heap-buffer-overflow, heap-use-after-free, etc.
  2. Access — READ or WRITE, size, address
  3. Stack trace — where the bad access happened
  4. Allocation info — where the memory was allocated, and often freed

Use-after-free

cpp
==12345==ERROR: AddressSanitizer: heap-use-after-free on address 0x602000000010
READ of size 4 at 0x602000000010 thread T0
    #0 0x401310 in read_value (example.cpp:15)

0x602000000010 is located 0 bytes inside of 4-byte region [0x602000000010,0x602000000014)
freed by thread T0 here:
    #0 0x7f... in operator delete(void*)...
    #1 0x401270 in main (example.cpp:12)

previously allocated by thread T0 here:
    #0 0x7f... in operator new(unsigned long)...
    #1 0x401240 in main (example.cpp:9)

Common bugs ASan catches

Heap buffer overflow

cpp
int* arr = new int[5];
arr[5] = 42;    // → heap-buffer-overflow: 4 bytes after [0..4]
delete[] arr;

Use-after-free

cpp
int* p = new int(42);
delete p;
int x = *p;    // → heap-use-after-free

Stack buffer overflow

cpp
int buf[10];
buf[10] = 0;   // → stack-buffer-overflow

Use-after-scope

cpp
int* p;
{
    int x = 42;
    p = &x;
}
*p = 100;  // → stack-use-after-scope (with -fsanitize-address-use-after-scope)

Double-free

cpp
int* p = new int(1);
delete p;
delete p;  // → attempting double-free

ASAN_OPTIONS tuning

bash
# Full list of options
ASAN_OPTIONS=help=1 ./myapp

# Common options
ASAN_OPTIONS=detect_leaks=1           # enable LeakSanitizer (default on Linux)
ASAN_OPTIONS=detect_leaks=0           # disable leak detection
ASAN_OPTIONS=abort_on_error=1         # call abort() instead of _exit()
ASAN_OPTIONS=halt_on_error=0          # continue after error (not recommended)
ASAN_OPTIONS=print_stats=1            # print shadow memory stats on exit
ASAN_OPTIONS=detect_stack_use_after_return=1  # more expensive, finds UAR bugs
ASAN_OPTIONS=check_initialization_order=1     # detect init order fiasco

# Multiple options (colon-separated)
ASAN_OPTIONS=detect_leaks=1:abort_on_error=1 ./myapp

CMake integration

cmake
option(ENABLE_ASAN "Enable AddressSanitizer" OFF)

if(ENABLE_ASAN)
    add_compile_options(-fsanitize=address -fno-omit-frame-pointer)
    add_link_options(-fsanitize=address)
endif()
bash
cmake -DENABLE_ASAN=ON -DCMAKE_BUILD_TYPE=Debug -B build
cmake --build build
ASAN_OPTIONS=detect_leaks=1 ./build/myapp

CI integration

yaml
# .github/workflows/asan.yml
name: AddressSanitizer

on: [push, pull_request]

jobs:
  asan:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Build with ASan
        run: |
          cmake -DENABLE_ASAN=ON -DCMAKE_BUILD_TYPE=RelWithDebInfo -B build
          cmake --build build -j$(nproc)
      - name: Run tests
        run: |
          cd build && ctest --output-on-failure
        env:
          ASAN_OPTIONS: detect_leaks=1:abort_on_error=1
          LSAN_OPTIONS: suppressions=${{ github.workspace }}/lsan.supp

LeakSanitizer suppressions

Not all leaks are bugs (e.g., one-time allocations, third-party library internals). Suppress known ones:

cpp
# lsan.supp
leak:libssl.so
leak:some_external_library
leak:one_time_init_function
bash
LSAN_OPTIONS=suppressions=lsan.supp ./myapp

Combining with other sanitizers

bash
# ASan + UBSan (common combination)
clang++ -fsanitize=address,undefined -g -O1 -o myapp myapp.cpp

# ASan + LeakSan (automatic on Linux)
ASAN_OPTIONS=detect_leaks=1 ./myapp

# NOTE: ASan and TSan (ThreadSanitizer) are INCOMPATIBLE — can't combine
# NOTE: ASan and MSan (MemorySanitizer) are INCOMPATIBLE

Performance overhead

  • Memory: 2× typical (shadow memory for every byte)
  • CPU: 2–3× typical
  • Compile time: slight increase (instrumentation)

Not suitable for production — use in development, CI, and fuzzing. For release, remove -fsanitize=address.


When ASan misses bugs

ASan uses shadow memory to track allocations — it can miss:

  • Bugs within allocated objects (intra-object overflow) — use -fsanitize-address-field-padding
  • Wild pointer reads that happen to land in mapped memory
  • Logic errors that don't cause memory violations
  • Uninitialized reads — use MemorySanitizer instead
  • Data races — use ThreadSanitizer instead

Reading ASan Output — Annotated Heap-Use-After-Free Report

Every field in an ASan report tells you exactly where to look. The report has three stacked traces: the access site, the free site, and the allocation site. Together they give you a complete timeline of the object's life.

Below is a full, realistic heap-use-after-free report with each part labelled:

cpp
==38921==ERROR: AddressSanitizer: heap-use-after-free on address 0x602000000110 thread T0
WRITE of size 4 at 0x602000000110 thread T0         ← (A) what happened
    #0 0x40173c in Widget::update() widget.cpp:47   ← (B) access site
    #1 0x401891 in EventLoop::tick() loop.cpp:112
    #2 0x401234 in main main.cpp:23

0x602000000110 is located 16 bytes inside of 32-byte region   ← (C) object geometry
[0x602000000100,0x602000000120)
freed by thread T0 here:                                       ← (D) free site
    #0 0x7f3a1b in operator delete(void*) (libasan.so)
    #1 0x4016c0 in EventLoop::remove() loop.cpp:88

previously allocated by thread T0 here:                       ← (E) allocation site
    #0 0x7f3a2c in operator new(unsigned long) (libasan.so)
    #1 0x401601 in EventLoop::add() loop.cpp:55

SUMMARY: AddressSanitizer: heap-use-after-free widget.cpp:47 in Widget::update()

Navigating to the bug in three steps:

  1. Find the access site (B). widget.cpp:47 is where the corrupted memory was touched — open that line first. This is almost always inside a function that received a pointer it no longer owns.

  2. Find the free site (D). loop.cpp:88 freed the object. Cross-reference with step 1 to understand ownership: who called remove() before update() still held a reference?

  3. Find the allocation site (E). loop.cpp:55 created the object. Check what stores the returned pointer and who is responsible for its lifetime. The bug is usually a missing ownership transfer or an incorrect assumption about lifetime.

The object geometry line (C) is also useful: 16 bytes inside of 32-byte region tells you this is not a one-byte past-the-end access — the pointer points into the middle of the old object, confirming a stale (dangling) pointer rather than an arithmetic error.


ASan Runtime Options (ASAN_OPTIONS)

ASAN_OPTIONS is a colon-separated string of key=value pairs that controls runtime behaviour without recompilation. Set it as an environment variable before running your binary.

OptionDefaultEffect
abort_on_error=10Call abort() on first error — triggers core dumps and debuggers
halt_on_error=11Stop after the first error (set to 0 to see all errors, use with care)
detect_leaks=11 on LinuxEnable LeakSanitizer at process exit
log_path=/tmp/asan.logstderrWrite reports to /tmp/asan.log.<pid> instead of stderr
print_stats=10Print shadow-memory statistics on exit — useful for understanding overhead
detect_odr_violation=22Detect One Definition Rule violations (1=weak, 2=strict)
redzone=6416Shadow redzone size in bytes — enlarge to 64 or 128 when hunting subtle array overflows
check_initialization_order=10Detect static-initialization-order fiasco
detect_stack_use_after_return=10Detect stack-use-after-return (expensive; see dedicated section)
strict_string_checks=10Validate C-string function arguments
bash
# Crash with a core dump, log to file, strict redzone
ASAN_OPTIONS=abort_on_error=1:log_path=/tmp/asan.log:redzone=64 ./myapp

# Collect all errors instead of stopping at the first
ASAN_OPTIONS=halt_on_error=0:log_path=/tmp/asan.log ./myapp

# See shadow memory usage after a long run
ASAN_OPTIONS=print_stats=1 ./myapp

redzone=64 is particularly useful for C-style arrays. The default 16-byte redzone may be smaller than a single cache line, so an off-by-one that stays within the same cache line can slip through; a 64-byte redzone catches such accesses reliably.


Leak Detection (LSan)

On Linux, LeakSanitizer is automatically activated when you compile with -fsanitize=address. It runs at process exit and reports all heap memory that was never freed, grouped by allocation stack trace.

bash
# Linux default: leaks already reported; macOS needs the explicit flag
ASAN_OPTIONS=detect_leaks=1 ./myapp

A typical LSan report looks like this:

cpp
=================================================================
==38921==ERROR: LeakSanitizer: detected memory leaks

Direct leak of 256 byte(s) in 1 object(s) allocated from:
    #0 0x7f... in operator new(unsigned long) (libasan.so)
    #1 0x4015a3 in Config::load() config.cpp:34
    #2 0x401234 in main main.cpp:10

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

Intentional leaks — singleton registries, one-shot caches, and some third-party library internals — should be suppressed rather than worked around with ugly static hacks:

cpp
#include <sanitizer/lsan_interface.h>

void register_global_cache() {
    __lsan_disable();          // stop tracking allocations
    g_cache = new LargeCache;  // intentional, program-lifetime object
    __lsan_enable();           // resume tracking
}

For third-party code you cannot modify, use a suppressions file:

cpp
# lsan.supp — one pattern per line, matched against the full symbol or library name
leak:libssl
leak:libdbus
leak:GlobalRegistrar::init
leak:one_time_setup
bash
LSAN_OPTIONS=suppressions=/path/to/lsan.supp ./myapp

LSan suppression patterns are substring-matched against the function name or shared-library path appearing in the allocation stack trace — no regex syntax needed.


Stack-Use-After-Return

Returning a pointer or reference to a local variable is undefined behaviour that ASan can catch, but only when you opt in. The feature is off by default because it requires every function call to allocate its locals on the heap, adding roughly 10–40 % overhead on top of baseline ASan cost.

bash
ASAN_OPTIONS=detect_stack_use_after_return=1 ./myapp

A minimal reproducer:

cpp
int* dangerous() {
    int local = 42;
    return &local;   // local dies when dangerous() returns
}

int main() {
    int* p = dangerous();
    return *p;       // → stack-use-after-return with the option enabled
}

The report names the error stack-use-after-return and shows both the access site in main and the return site inside dangerous, making the ownership mistake obvious.

When to enable it:

  • When a code review or fuzzer hints at a potential dangling-stack-pointer bug but the default run does not trigger
  • As a one-off pass during development of an API that returns raw pointers or iterators into local containers
  • During fuzzing campaigns where thorough coverage matters more than throughput

Do not leave it on permanently in CI unless your test suite is small — the throughput impact is significant enough to break wall-clock time budgets on large test suites.


ASan with CMake — Preset Pattern

Both compile flags and link flags are required; omitting one or the other produces a binary that either fails to link or silently skips instrumentation. The cleanest approach is a CMake preset so that a single --preset switch toggles the entire toolchain.

cmake
# CMakeLists.txt
option(ENABLE_ASAN "Build with AddressSanitizer" OFF)

if(ENABLE_ASAN)
    # Apply to every target defined after this point
    add_compile_options(
        -fsanitize=address
        -fno-omit-frame-pointer   # required for readable stack traces
        -fno-optimize-sibling-calls  # prevents tail-call elision hiding frames
    )
    add_link_options(-fsanitize=address)
endif()

Or, to limit the flags to a specific target rather than the whole project:

cmake
target_compile_options(mylib PRIVATE
    $<$<BOOL:${ENABLE_ASAN}>:
        -fsanitize=address
        -fno-omit-frame-pointer
        -fno-optimize-sibling-calls
    >
)
target_link_options(mylib PRIVATE
    $<$<BOOL:${ENABLE_ASAN}>:-fsanitize=address>
)

Declare a preset in CMakePresets.json so developers and CI share identical flags:

json
{
  "version": 6,
  "configurePresets": [
    {
      "name": "asan-debug",
      "displayName": "Debug + ASan",
      "binaryDir": "${sourceDir}/build/asan",
      "cacheVariables": {
        "CMAKE_BUILD_TYPE": "Debug",
        "ENABLE_ASAN": "ON"
      }
    },
    {
      "name": "asan-relwithdebinfo",
      "displayName": "RelWithDebInfo + ASan",
      "binaryDir": "${sourceDir}/build/asan-rel",
      "cacheVariables": {
        "CMAKE_BUILD_TYPE": "RelWithDebInfo",
        "ENABLE_ASAN": "ON"
      }
    }
  ]
}
bash
cmake --preset asan-debug
cmake --build build/asan
ASAN_OPTIONS=detect_leaks=1 ./build/asan/myapp

RelWithDebInfo is the recommended build type for ASan in CI. It retains debug symbols (-g) and adds moderate optimisation (-O2), which exercises more realistic code paths than a Debug build while still producing readable stack traces. Pure Debug (-O0) misses bugs that optimisation introduces and runs substantially slower.


ASan + UBSan Combined

Running ASan and UBSan together catches a wider class of bugs in a single pass with no compatibility conflicts. They instrument different aspects of the program and their shadow-memory schemes do not interfere.

bash
clang++ -fsanitize=address,undefined \
        -fno-sanitize-recover=all \
        -fno-omit-frame-pointer \
        -g -O1 \
        -o myapp myapp.cpp myapp

-fno-sanitize-recover=all is critical: without it, UBSan prints a warning and continues execution after undefined behaviour, which can mask subsequent memory errors. With =all, the first UB is fatal and the error is unambiguous.

Enable UBSan stack traces at runtime:

bash
ASAN_OPTIONS=detect_leaks=1 \
UBSAN_OPTIONS=print_stacktrace=1:halt_on_error=1 \
./myapp

What each sanitizer adds:

CheckASanUBSan
Heap/stack/global buffer overflow✅❌
Use-after-free / use-after-scope✅❌
Signed integer overflow❌✅
Null pointer dereferencepartial✅ (-fsanitize=null)
Misaligned pointer access❌✅ (-fsanitize=alignment)
Invalid bool / enum values❌✅
Divide by zero❌✅
VLA bounds❌✅

ASan and UBSan are additive — every check from both lists fires. The only sanitizers incompatible with ASan are ThreadSanitizer and MemorySanitizer, which use conflicting shadow-memory layouts.

bash
# GCC supports the same combination
g++ -fsanitize=address,undefined -fno-sanitize-recover=all \
    -fno-omit-frame-pointer -g -O1 -o myapp myapp.cpp

Platform Notes

Linux

Linux is ASan's primary platform and has the fewest caveats. LeakSanitizer is enabled by default, symbolisation works out of the box when the binary has debug symbols, and ASAN_OPTIONS controls everything you need.

bash
# Ensure llvm-symbolizer is on PATH for human-readable traces
export ASAN_SYMBOLIZER_PATH=$(which llvm-symbolizer)

macOS

Since Xcode 12 (Apple Clang 12), you no longer need DYLD_INSERT_LIBRARIES — ASan is linked statically and activates automatically when the binary is compiled with -fsanitize=address.

If you see spurious leak reports from system frameworks on macOS, set:

bash
MallocNanoZone=0 ASAN_OPTIONS=detect_leaks=1 ./myapp

MallocNanoZone=0 disables Apple's nano allocator, which can interfere with ASan's shadow memory and produce false positives for leaks inside system libraries.

LeakSanitizer is disabled by default on macOS; you must opt in explicitly with detect_leaks=1. Not all LSan features (e.g., __lsan_disable) are supported on older Apple toolchains.

Windows (MSVC)

MSVC supports ASan via the /fsanitize=address flag starting with Visual Studio 2019 version 16.9, but the implementation differs from Clang/GCC in several ways:

cmake
# Windows-specific CMake integration
if(MSVC)
    target_compile_options(myapp PRIVATE /fsanitize=address)
    # No separate link flag required — MSVC links the runtime automatically
endif()

Key differences from Clang ASan on MSVC:

  • No LeakSanitizer support (LSan is Linux/macOS only)
  • ASAN_OPTIONS environment variable is honoured, but fewer options are implemented
  • /fsanitize=address cannot be combined with /RTC (runtime checks) — they are mutually exclusive
  • Edit-and-continue (/ZI) is incompatible; use /Zi instead
  • UBSan is not available in MSVC; use Clang-cl for combined ASan + UBSan on Windows

Docker / Containers

Containers often drop the SYS_PTRACE capability, which ASan needs to unwind stack frames. Add it explicitly when running sanitized binaries in Docker:

bash
docker run --cap-add SYS_PTRACE myimage ./myapp

Or in a docker-compose.yml:

yaml
services:
  test:
    image: myimage
    cap_add:
      - SYS_PTRACE
    environment:
      ASAN_OPTIONS: detect_leaks=1:abort_on_error=1
      LSAN_OPTIONS: suppressions=/app/lsan.supp
    command: ./build/myapp

Without SYS_PTRACE, stack traces in ASan reports will be empty or show only hex addresses, making it impossible to navigate to the bug.

Edit on GitHubUpdated 2025-01-01T00:00:00.000Z