Skip to content

AddressSanitizer, UBSan and Friends: A Practical Guide

How ASan, UBSan, MSan, TSan and HWASan work, which flags and runtime options to use, how to read an ASan report line by line, and how to run sanitizers in CI.

Published on 6 min read

Sanitizers are compiler instrumentation plus a runtime library that check, as the program runs, for errors that C and C++ otherwise leave silent. They are the most effective single tool for finding memory-safety bugs before release: a heap overflow that would corrupt memory quietly in a normal build becomes an immediate, precise report with stack traces. This guide covers the main sanitizers in GCC and Clang, how to enable them, how to read their output, and how to make them part of everyday testing. Pair it with coverage-guided fuzzing, which feeds sanitized builds the inputs that trigger bugs.

The sanitizer family

SanitizerFlagFindsTypical overheadCompiler
AddressSanitizer (ASan)-fsanitize=addressOut-of-bounds on heap, stack and globals; use-after-free; double free; use-after-return (optional)~2x CPU, 2–3x memoryGCC, Clang, MSVC
LeakSanitizer (LSan)on with ASan, or -fsanitize=leakMemory leaks at exitLowGCC, Clang
UndefinedBehaviorSanitizer (UBSan)-fsanitize=undefinedSigned overflow, invalid shifts, misaligned or null pointer use, bad casts, out-of-bounds array indices (some)Low to moderateGCC, Clang
MemorySanitizer (MSan)-fsanitize=memoryReads of uninitialised memory~3x CPUClang only
ThreadSanitizer (TSan)-fsanitize=threadData races5–15x CPU, 5–10x memoryGCC, Clang
HWASan-fsanitize=hwaddressLike ASan, using pointer tagsLower memory than ASanClang, AArch64 (x86-64 experimental)

ASan and UBSan belong in every C and C++ project's test matrix. MSan and TSan are specialised: add them when uninitialised reads or concurrency bugs are a realistic risk.

How AddressSanitizer works

ASan maps every 8 bytes of application memory to one byte of shadow memory that records how many of those 8 bytes are addressable. The compiler inserts a check before each load and store: compute the shadow address, read the shadow byte, and report if the access touches poisoned memory.

application memory     shadow byte   meaning
[ 8 bytes ]      ->    00            all 8 bytes addressable
[ 8 bytes ]      ->    04            first 4 bytes addressable
[ 8 bytes ]      ->    fa            heap redzone
[ 8 bytes ]      ->    fd            freed heap memory
[ 8 bytes ]      ->    f1 f2 f3      stack redzones (left, mid, right)

Three runtime features make it catch so much:

  • Redzones. Every heap allocation, stack array and global gets poisoned padding around it, so overflows hit poison immediately.
  • Quarantine. Freed memory is poisoned and held back from reuse for a while (256 MB by default on 64-bit Linux), so a use-after-free hits poison rather than a new object.
  • Interceptors. Functions such as memcpy, strcpy and free are replaced with checking versions that validate their whole range.

Building with sanitizers

# Test build: ASan + UBSan, good stack traces
clang -O1 -g -fno-omit-frame-pointer \
      -fsanitize=address,undefined \
      -fno-sanitize-recover=undefined \
      -o parser_test parser.c parser_test.c
  • -O1 keeps the build fast enough while leaving code recognisable; -O0 also works but is slower under ASan.
  • -g -fno-omit-frame-pointer produces accurate, symbolised stack traces.
  • -fno-sanitize-recover=undefined makes UBSan abort on the first error instead of printing and continuing, so tests fail.
  • Pass the same -fsanitize= flags to the link step. With CMake, add them to both CMAKE_C_FLAGS and CMAKE_EXE_LINKER_FLAGS, or use add_compile_options and add_link_options.

Runtime options

Sanitizers read options from environment variables. A sensible default for CI:

export ASAN_OPTIONS="abort_on_error=1:detect_leaks=1:strict_string_checks=1:detect_stack_use_after_return=1:check_initialization_order=1"
export UBSAN_OPTIONS="print_stacktrace=1:halt_on_error=1"
OptionEffect
abort_on_error=1Abort (and dump core if enabled) instead of _exit, useful for debuggers and crash collectors
detect_leaks=1Run LeakSanitizer at exit
detect_stack_use_after_return=1Catch pointers to locals used after the function returned (costs memory)
strict_string_checks=1Check that string arguments are properly terminated
quarantine_size_mb=NLarger quarantine catches use-after-free with longer delays
symbolize=1Symbolise stack traces (needs llvm-symbolizer in PATH)

Reading an ASan report

Consider a toy bug: a function that copies a record name into a heap buffer sized one byte too small.

char *dup_name(const char *src, size_t n) {
    char *out = malloc(n);        /* forgot room for the terminator */
    memcpy(out, src, n);
    out[n] = '\0';                /* writes one byte past the allocation */
    return out;
}

The report, annotated:

==31337==ERROR: AddressSanitizer: heap-buffer-overflow on address 0x602000000015 at pc 0x55f1c2a3b4e7 bp 0x7ffe... sp 0x7ffe...
WRITE of size 1 at 0x602000000015 thread T0            <- (1) kind, direction and size
    #0 0x55f1c2a3b4e6 in dup_name records.c:4           <- (2) where the bad access happened
    #1 0x55f1c2a3b61a in parse_record records.c:17
    #2 0x55f1c2a3b7d0 in main records.c:31

0x602000000015 is located 0 bytes after 5-byte region [0x602000000010,0x602000000015)
                                                        <- (3) position relative to the object
allocated by thread T0 here:                            <- (4) where the object came from
    #0 0x7f1a4c8b1c57 in malloc
    #1 0x55f1c2a3b4c1 in dup_name records.c:2
    #2 0x55f1c2a3b61a in parse_record records.c:17

SUMMARY: AddressSanitizer: heap-buffer-overflow records.c:4 in dup_name
Shadow bytes around the buggy address:
  0x0c047fff8000: fa fa 00 05 fa fa ...                 <- (5) 05 = 5 addressable bytes, then redzone

How to read it:

  1. Kind, direction and size. heap-buffer-overflow, a WRITE of 1 byte. Writes are generally more serious than reads, and small overflows are often off-by-one errors.
  2. Access stack. Frame #0 in your code is where to start. Frames inside interceptors (memcpy, strcpy) point to the caller one frame up.
  3. Relative position. "0 bytes after 5-byte region" is the classic off-by-one signature. "Located 4096 bytes to the right" means a much wilder access, often an integer bug in the size or index.
  4. Allocation stack. Shows which object was involved. For use-after-free reports there is also a "freed by thread T0 here" stack, usually the fastest route to the root cause.
  5. Shadow bytes. Confirm the object's size and the redzone type (fa heap redzone, fd freed memory, f1/f2/f3 stack redzones).

The common report kinds map directly onto the bug classes described in heap corruption and use-after-free and stack buffer overflows:

ReportUsual root cause
heap-buffer-overflowMissing or wrong bound, off-by-one, size arithmetic bug
stack-buffer-overflowLocal array overrun
global-buffer-overflowOverrun of a static or global array
heap-use-after-freeLifetime or ownership bug
attempting double-freeTwo owners, error path frees twice
stack-use-after-returnPointer to a local escapes its function
SEGV on unknown addressWild or NULL pointer; look at the address to tell which

UndefinedBehaviorSanitizer

UBSan checks for undefined behaviour that often precedes memory corruption, particularly integer bugs (see integer overflows and format strings). Its messages are one line with file, line and column:

decode.c:58:19: runtime error: signed integer overflow: 2147483600 + 100 cannot be represented in type 'int'
decode.c:73:9: runtime error: load of misaligned address 0x55d3e1c0a2b3 for type 'uint32_t', which requires 4 byte alignment

-fsanitize=undefined enables a default group. Useful additions in Clang: -fsanitize=integer (includes unsigned overflow and implicit conversions, which are not UB but often bugs) and -fsanitize=bounds (array index checks where the size is known). UBSan can also run in minimal or trap mode (-fsanitize-trap=undefined) with no runtime library, which some projects use in hardened production builds for a handful of cheap checks.

Sanitizers in CI

  • Build a sanitizer variant of your test suite and run it on every pull request. Treat any report as a test failure.
  • Keep suppressions minimal and reviewed. LSan suppressions for known third-party leaks are acceptable; suppressing ASan errors in your own code is not.
  • Instrument dependencies where it matters. ASan tolerates uninstrumented libraries (it intercepts common functions), but bugs inside those libraries are only caught if they are instrumented too. MSan requires full instrumentation.
  • Run fuzzers against sanitized builds. Sanitizers need inputs that exercise the bug; fuzzers are the most efficient way to produce them.
  • Record the toolchain version. Report formats and default options change between releases.

What sanitizers do not do

Sanitizers only find bugs on code paths that tests actually execute, and only for the bug types they instrument. ASan does not detect reads of uninitialised memory (MSan does), data races (TSan does) or intra-object overflows within a struct. None of them replace mitigations in production. For production-time detection, look at GWP-ASan, a sampling allocator used in Chrome, Android and elsewhere that places a small random fraction of allocations on guarded pages at negligible cost, and at hardware memory tagging (Arm MTE) where available.

Checklist

  • Add an ASan+UBSan build with -O1 -g -fno-omit-frame-pointer to CI and fail on any report.
  • Set ASAN_OPTIONS and UBSAN_OPTIONS explicitly so behaviour does not depend on defaults.
  • Read reports from the top: kind and size, access stack, relative position, allocation and free stacks.
  • Add MSan and TSan builds where uninitialised memory or concurrency are real risks.
  • Feed the sanitized build with a fuzzer, and use crash triage techniques to prioritise the results.

Related guides

0x3000 · Finding Bugs

Coverage-Guided Fuzzing with libFuzzer and AFL++

Write a fuzz harness, build it with sanitizers, run libFuzzer and AFL++, manage corpora and dictionaries, and run continuous fuzzing in CI with OSS-Fuzz or ClusterFuzzLite.