Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

C++  Module 6 – References, pointers and memory

Finding memory bugs with AddressSanitizer and UBSan

Build C++ with AddressSanitizer and UBSan, read a report from the bad access back to the allocation, and see which sanitizers each platform offers.

  • Intermediate
  • 30 minutes
  • Examples run with Clang 21.0.0, GCC 16.2.0 and Python 3.14.8
  • By MySmartCoPilot
Free: 30 minutes

What you will learn

  • Build programs with AddressSanitizer and UndefinedBehaviorSanitizer
  • Read a sanitizer report from the error line to the allocation site
  • Identify which sanitizers Windows, macOS and Linux support

Before you start

What you’ll learn · Before you start

What you will learn

  • Build programs with AddressSanitizer and UndefinedBehaviorSanitizer
  • Read a sanitizer report from the error line to the allocation site
  • Identify which sanitizers Windows, macOS and Linux support

Before you start

On this page

A sanitizer is a compiler option that builds extra checks into your program. AddressSanitizer (ASan) watches every memory access and stops the program at the first one that touches memory it should not: past the end of an array, after a delete, or after a variable’s scope has ended. UndefinedBehaviorSanitizer (UBSan) checks for other undefined behaviour, such as signed overflow, a misaligned pointer or an oversized shift. Build with -fsanitize=address,undefined -g -fno-omit-frame-pointer, run the program as usual, and a bug that used to crash somewhere else, or not at all, produces a report naming the faulty line and where the memory came from.

Sanitizers turn the silent bugs of object lifetime and undefined behaviour into loud ones, and they work with both GCC and Clang. This lesson builds them, reads one report line by line, shows five more memory errors and three UBSan checks, maps which sanitizers exist on Windows, macOS and Linux, and measures what a sanitized build costs.

Building with sanitizers

Every flag of the recommended command has a job:

  • -fsanitize=address adds a check before each load and store, and links a runtime library that replaces new, delete, malloc and free, so that it knows which memory is allocated and which was freed.
  • -fsanitize=undefined adds UBSan’s checks, a group that includes signed overflow, shifts, misaligned pointers, null pointers and array indexes whose bounds the compiler knows.
  • -g keeps the debug information that turns addresses in the report into function names, files and lines.
  • -fno-omit-frame-pointer keeps the frame pointer, so that the stack traces in reports are complete.

Give the flags when compiling and when linking; with one clang++ or g++ command that does both, that is automatic. Clang’s documentation suggests -O1 or higher for reasonable speed; without it the program only runs slower.

How AddressSanitizer knows

ASan keeps a shadow memory: one shadow byte describes 8 bytes of your program’s memory. A shadow byte of 00 means all 8 bytes may be used, a value from 01 to 07 means only that many of the first bytes may, and other values mark memory that must not be touched, each kind with its own code. The runtime surrounds every allocation with redzones, poisoned bytes on both sides, and keeps freed memory poisoned for a while instead of handing it out at once, in a queue called the quarantine. Each instrumented access looks up its shadow byte first. That is why ASan finds every overflow that lands in a redzone and uses of memory that was freed recently, and why it can miss an access that jumps far past the redzone into another live block, or a use of memory freed long ago and handed out again.

Reading a report, line by line

reset sets one element too many, a classic off-by-one:

overflow.cpp C++ · overflow.cpp
#include <print>

// Sets scores[0] to scores[count] to zero: one element more than the caller has.
void reset(int* scores, int count) {
    for (int i = 0; i <= count; ++i) scores[i] = 0;
}

int main() {
    int* scores = new int[5];
    reset(scores, 5);
    std::println("reset done");
    delete[] scores;
}
The whole report for one heap-buffer-overflow Python · overflow_session.py
from session import run

# The whole AddressSanitizer report for one bug, built with the flags this lesson recommends.
run("""
clang++ -std=c++23 -g -fsanitize=address,undefined -fno-omit-frame-pointer overflow.cpp -o overflow
./overflow
""", max_lines=70, full=True)

Output

$ clang++ -std=c++23 -g -fsanitize=address,undefined -fno-omit-frame-pointer overflow.cpp -o overflow
$ ./overflow
=================================================================
==…==ERROR: AddressSanitizer: heap-buffer-overflow on address 0x… at pc 0x… bp 0x… sp 0x…
WRITE of size 4 at 0x… thread T0
    #0 0x… in reset(int*, int) overflow.cpp:5:48
    #1 0x… in main overflow.cpp:10:5
    #2 0x… in start (/usr/lib/dyld:arm64e+0x204e0)

0x… is located 0 bytes after 20-byte region [0x…,0x…)
allocated by thread T0 here:
    #0 0x… in _Znam (/Library/Developer/CommandLineTools/usr/lib/clang/21/lib/darwin/libclang_rt.asan_osx_dynamic.dylib:arm64e+0x50268)
    #1 0x… in main overflow.cpp:9:19
    #2 0x… in start (/usr/lib/dyld:arm64e+0x204e0)

SUMMARY: AddressSanitizer: heap-buffer-overflow overflow.cpp:5:48 in reset(int*, int)
Shadow bytes around the buggy address:
  0x…: fa fa 00 00 00 00 fa fa 00 00 00 00 fa fa 00 00
  0x…: 00 00 fa fa 00 00 00 00 fa fa 00 00 00 00 fa fa
  0x…: 00 00 00 00 fa fa 00 00 00 00 fa fa 00 00 00 00
  0x…: fa fa 00 00 00 00 fa fa 00 00 00 00 fa fa 00 00
  0x…: 00 00 fa fa 00 00 00 00 fa fa 00 00 00 00 fa fa
=>0x…: 00 00[04]fa fa fa fa fa fa fa fa fa fa fa fa fa
  0x…: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
  0x…: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
  0x…: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
  0x…: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
  0x…: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
Shadow byte legend (one shadow byte represents 8 application bytes):
  Addressable:           00
  Partially addressable: 01 02 03 04 05 06 07
  Heap left redzone:       fa
  Freed heap region:       fd
  Stack left redzone:      f1
  Stack mid redzone:       f2
  Stack right redzone:     f3
  Stack after return:      f5
  Stack use after scope:   f8
  Global redzone:          f9
  Global init order:       f6
  Poisoned by user:        f7
  Container overflow:      fc
  Array cookie:            ac
  Intra object redzone:    bb
  ASan internal:           fe
  Left alloca redzone:     ca
  Right alloca redzone:    cb
==…==ABORTING
[exit status 134]

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 overflow_session.py

The report reads from top to bottom like this:

  1. The error. heap-buffer-overflow: an access just outside memory that came from the heap, here from new[]. The process number and addresses, shown as ==…== and 0x… on this page, change on every run.
  2. The access. WRITE of size 4: the program wrote an int. The stack trace below says where: frame #0 is reset at overflow.cpp:5:48, line 5, column 48, called by main at line 10. The first frame in your own code is almost always the line to look at.
  3. The memory. located 0 bytes after 20-byte region: the write landed right after a block of 20 bytes, which is 5 ints, so the index was 5, one past the last valid index 4.
  4. The origin. allocated by thread T0 here: the block came from operator new[] (its mangled name, _Znam) called on line 9. For a use after free, a freed by stack appears here too.
  5. The summary, one line to search for in logs, then the shadow bytes around the address. In the marked row, 00 00 cover the first 16 bytes of the block, [04] says only 4 of the next 8 bytes belong to it, which is exactly where the write went, and fa is the heap redzone. The legend below decodes every value.
  6. ABORTING: ASan stops the program at the first error, by design, with exit status 134 here.
The 20-byte block of overflow.cpp in rows of 8 bytes, each with its ASan shadow byte, and the write to scores[5] landing in the redzone.8 bytes of memoryshadow byte− 8redzonefa+ 0scores[0]scores[1]00+ 8scores[2]scores[3]00+ 16scores[4]redzone04+ 24redzonefareset() writes scores[5] here:offset 20, inside the redzone

How AddressSanitizer describes the block that reset() overflows

Text description of the diagram

The figure shows the memory around the 20-byte block that new int[5] returned in overflow.cpp, in five rows of 8 bytes. Next to each row is the shadow byte that AddressSanitizer keeps for those 8 bytes.

  1. The 8 bytes before the block are a redzone. Shadow byte fa: the heap redzone, which must not be touched.
  2. Bytes 0 to 7 of the block hold scores[0] and scores[1]. Shadow byte 00: all 8 bytes may be used.
  3. Bytes 8 to 15 hold scores[2] and scores[3]. Shadow byte 00.
  4. Bytes 16 to 23 hold scores[4] in the first 4 bytes; the other 4 belong to the redzone. Shadow byte 04: only the first 4 bytes may be used.
  5. The 8 bytes after that are redzone again. Shadow byte fa.

An arrow marks offset 20, the first byte after scores[4]: the place where reset() writes scores[5]. Its row's shadow byte, 04, says only bytes 16 to 19 are usable, so AddressSanitizer reports a heap-buffer-overflow 0 bytes after the 20-byte region.

Most of the time, steps 1 to 4 are all you need: what kind of access, which line, how far outside, and whose memory.

Five more memory errors

bugs.cpp holds four more classic mistakes; the command-line argument chooses which one runs:

bugs.cpp C++ · bugs.cpp
#include <print>
#include <string_view>

void use_after_free() {
    int* marks = new int{90};
    delete marks;
    std::println("marks = {}", *marks);  // the int was deleted on the line above
}

void use_after_scope() {
    int* best = nullptr;
    {
        int score = 87;
        best = &score;
    }                                    // score's lifetime ends here
    std::println("best = {}", *best);
}

void double_free() {
    int* marks = new int{90};
    delete marks;
    delete marks;                        // the same pointer, deleted again
}

void stack_overflow() {
    int last_five[5] = {1, 2, 3, 4, 5};
    int total = 0;
    for (int i = 0; i <= 5; ++i) total += last_five[i];
    std::println("total = {}", total);
}

int main(int argc, char* argv[]) {
    std::string_view bug = argc > 1 ? argv[1] : "";
    if (bug == "use-after-free") use_after_free();
    else if (bug == "use-after-scope") use_after_scope();
    else if (bug == "double-free") double_free();
    else if (bug == "stack-overflow") stack_overflow();
    else std::println("usage: bugs use-after-free | use-after-scope | double-free | stack-overflow");
}
Four runs, four reports Python · bugs_session.py
from session import run

# Four more bugs from bugs.cpp, one per run, with the report shortened to its key lines.
run("""
clang++ -std=c++23 -g -fsanitize=address,undefined -fno-omit-frame-pointer bugs.cpp -o bugs
./bugs use-after-free
./bugs use-after-scope
./bugs double-free
./bugs stack-overflow
""")

Output

$ clang++ -std=c++23 -g -fsanitize=address,undefined -fno-omit-frame-pointer bugs.cpp -o bugs
$ ./bugs use-after-free
==…==ERROR: AddressSanitizer: heap-use-after-free on address 0x…
READ of size 4 at 0x… thread T0
    #8 in use_after_free() bugs.cpp:7
    #9 in main bugs.cpp:34
0x… is located 0 bytes inside of 4-byte region [0x…,0x…)
freed by thread T0 here:
    #1 in use_after_free() bugs.cpp:6
    #2 in main bugs.cpp:34
previously allocated by thread T0 here:
    #1 in use_after_free() bugs.cpp:5
    #2 in main bugs.cpp:34
SUMMARY: AddressSanitizer: heap-use-after-free
[48 more lines of the report left out]
==…==ABORTING
[exit status 134]
$ ./bugs use-after-scope
==…==ERROR: AddressSanitizer: stack-use-after-scope on address 0x…
READ of size 4 at 0x… thread T0
    #8 in use_after_scope() bugs.cpp:16
    #9 in main bugs.cpp:35
Address 0x… is located in stack of thread T0 at offset 32 in frame
    #0 in use_after_scope() bugs.cpp:10
SUMMARY: AddressSanitizer: stack-use-after-scope
[47 more lines of the report left out]
==…==ABORTING
[exit status 134]
$ ./bugs double-free
==…==ERROR: AddressSanitizer: attempting double-free on 0x… in thread T0:
    #1 in double_free() bugs.cpp:22
    #2 in main bugs.cpp:36
0x… is located 0 bytes inside of 4-byte region [0x…,0x…)
freed by thread T0 here:
    #1 in double_free() bugs.cpp:21
    #2 in main bugs.cpp:36
previously allocated by thread T0 here:
    #1 in double_free() bugs.cpp:20
    #2 in main bugs.cpp:36
SUMMARY: AddressSanitizer: double-free
[10 more lines of the report left out]
==…==ABORTING
[exit status 134]
$ ./bugs stack-overflow
bugs.cpp:28:43: runtime error: index 5 out of bounds for type 'int[5]'
SUMMARY: UndefinedBehaviorSanitizer: undefined-behavior bugs.cpp:28:43
==…==ERROR: AddressSanitizer: stack-buffer-overflow on address 0x…
READ of size 4 at 0x… thread T0
    #0 in stack_overflow() bugs.cpp:28
    #1 in main bugs.cpp:37
Address 0x… is located in stack of thread T0 at offset 52 in frame
    #0 in stack_overflow() bugs.cpp:25
SUMMARY: AddressSanitizer: stack-buffer-overflow
[40 more lines of the report left out]
==…==ABORTING
[exit status 134]

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 bugs_session.py

These reports are shortened to their key lines. Reading each one:

  • heap-use-after-free: the read on line 7, the delete on line 6 that freed the memory, and the new on line 5. Frames #0 to #7 are inside std::println, which made the actual read; the first frame in bugs.cpp names your line.
  • stack-use-after-scope: score lived in the inner block, and line 16 reads it through best after the block ended. ASan poisons a local variable’s memory when its scope ends, which both GCC and Clang do by default with -fsanitize=address.
  • double-free: line 22 deletes memory that line 21 had already deleted; the report shows both deletes and the allocation.
  • stack-buffer-overflow: reading last_five[5] from a 5-element local array. Here UBSan spoke first, because the array’s bound is known at compile time (index 5 out of bounds for type 'int[5]'), and then ASan stopped the read.

A fifth kind, container-overflow, appears in the exercise below: libc++, the library of Apple clang, marks the spare capacity of a std::vector as unusable, so reading a reserved but unused element is reported even though the memory is allocated.

The error names you will meet most, what each one means and its usual cause:

  • heap-buffer-overflow: an access just outside a heap block. Usually an off-by-one index, or a wrong size passed to new[].
  • stack-buffer-overflow: an access just outside a local array. Usually an off-by-one, or strcpy into a buffer that is too small.
  • global-buffer-overflow: an access just outside a global array, often through an index read from input that nobody checked.
  • heap-use-after-free: an access to freed heap memory, through a dangling pointer, a reference into a vector that reallocated, or a view of a string that was destroyed.
  • stack-use-after-scope: an access to a local variable after its block ended, through a pointer or reference kept too long.
  • double-free and bad-free: a delete of memory twice, or of memory that new never returned, usually because two owners share one raw pointer.
  • container-overflow: an access to a vector’s capacity beyond its size, typically indexing after reserve instead of adding elements with push_back.

Three UBSan checks

AddressSanitizer only checks memory. This program has two bugs it ignores: it reads a 4-byte Header at an address that is not a multiple of 4, and it shifts a 32-bit value by 32:

ub.cpp C++ · ub.cpp
#include <cstdint>
#include <print>

struct Header {
    std::uint32_t length;
};

// A message as it arrived: a type byte, then a 4-byte length (7), then the rest.
alignas(4) unsigned char packet[8] = {0xFF, 7, 0, 0, 0, 0, 0, 0};

// Treats bytes as a Header: only correct if they start at a multiple of 4.
std::uint32_t length_of(const unsigned char* bytes) {
    return reinterpret_cast<const Header*>(bytes)->length;
}

// A mask with the low `bits` bits set: only correct for bits from 0 to 31.
std::uint32_t mask_of(int bits) {
    return (1u << bits) - 1;
}

int main() {
    std::println("length = {}", length_of(packet + 1));  // packet + 1 is not 4-byte aligned
    std::println("mask   = {:#x}", mask_of(32));
    std::println("main ends");
}
Without UBSan, with it, and with it made fatal Python · ub_session.py
from session import run

# Two kinds of undefined behaviour that AddressSanitizer does not look for, built with UBSan.
run("""
clang++ -std=c++23 -O2 ub.cpp -o ub && ./ub
clang++ -std=c++23 -g -fsanitize=undefined ub.cpp -o ub && ./ub
clang++ -std=c++23 -g -fsanitize=undefined -fno-sanitize-recover=undefined ub.cpp -o ub && ./ub
""")

Output

$ clang++ -std=c++23 -O2 ub.cpp -o ub && ./ub
length = 7
mask   = 0x7
main ends
$ clang++ -std=c++23 -g -fsanitize=undefined ub.cpp -o ub && ./ub
ub.cpp:13:52: runtime error: member access within misaligned address 0x… for type 'const Header', which requires 4 byte alignment
0x…: note: pointer points here
 00 00 00  ff 07 00 00 00 00 00 00  …
              ^
SUMMARY: UndefinedBehaviorSanitizer: undefined-behavior ub.cpp:13:52
ub.cpp:13:52: runtime error: load of misaligned address 0x… for type 'const std::uint32_t' (aka 'const unsigned int'), which requires 4 byte alignment
0x…: note: pointer points here
 00 00 00  ff 07 00 00 00 00 00 00  …
              ^
SUMMARY: UndefinedBehaviorSanitizer: undefined-behavior ub.cpp:13:52
length = 7
ub.cpp:18:16: runtime error: shift exponent 32 is too large for 32-bit type 'unsigned int'
SUMMARY: UndefinedBehaviorSanitizer: undefined-behavior ub.cpp:18:16
mask   = 0x0
main ends
$ clang++ -std=c++23 -g -fsanitize=undefined -fno-sanitize-recover=undefined ub.cpp -o ub && ./ub
ub.cpp:13:52: runtime error: member access within misaligned address 0x… for type 'const Header', which requires 4 byte alignment
0x…: note: pointer points here
 00 00 00  ff 07 00 00 00 00 00 00  …
              ^
SUMMARY: UndefinedBehaviorSanitizer: undefined-behavior ub.cpp:13:52
Abort trap: 6
[exit status 134]

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 ub_session.py

The plain build prints length = 7, because the arm64 processor of this Mac happens to allow the misaligned read, and mask = 0x7, a value that has nothing to do with the shift: the optimiser saw that a shift by 32 is undefined and dropped the computation, so the slot meant for the mask still held the 7 of the line before. The UBSan build, which really shifts, prints 0x0. A processor that faults on misaligned loads would stop the first read too. UBSan names both problems: the member access and the load on line 13, with the bytes around the address and a caret under it, and the shift on line 18. The fix for the first is the one from the memory layout lesson: copy the bytes with std::memcpy, which has no alignment requirement and no aliasing problem; the fix for the second is to treat 32 separately.

Notice what happened after each report: the program went on and printed main ends, with exit status 0. Most UBSan checks recover by default, so a test suite would pass. -fno-sanitize-recover=undefined (or =all) makes the first report fatal, as the last build shows, and belongs in every test or CI build.

Which sanitizers exist where

  • AddressSanitizer (memory errors): Linux with GCC and Clang, macOS arm64 with Apple clang, and Windows with MSVC’s /fsanitize=address on x86 and x64.
  • UndefinedBehaviorSanitizer (other undefined behaviour): Linux and macOS arm64; not MSVC.
  • LeakSanitizer (memory never freed): Linux, where it is on by default in ASan builds; not on macOS arm64 or MSVC.
  • ThreadSanitizer (data races): Linux and macOS arm64; not MSVC.
  • MemorySanitizer (reads of uninitialised memory): Clang only, on Linux, NetBSD and FreeBSD.

The Linux entries and the platform lists come from the Clang and GCC documentation, and GCC does not offer MemorySanitizer at all. Microsoft’s documentation limits MSVC to AddressSanitizer, on x86 and x64. For macOS, the compilers of this Mac answer for themselves:

leak.cpp C++ · leak.cpp
#include <print>

int main() {
    int* scores = new int[100];  // never deleted: a leak
    scores[0] = 42;
    std::println("first score {}", scores[0]);
}
Asking both compilers for each sanitizer Python · platform_session.py
from session import run

# Which sanitizers this Mac's compilers offer: macOS 26 on arm64, Apple clang 21 and Homebrew GCC 16.
run("""
clang++ -std=c++23 -g -fsanitize=address leak.cpp -o leak && ASAN_OPTIONS=detect_leaks=1 ./leak
clang++ -std=c++23 -fsanitize=leak leak.cpp -o leak
clang++ -std=c++23 -fsanitize=memory leak.cpp -o leak
clang++ -std=c++23 -fsanitize=thread leak.cpp -o leak && ./leak
g++-16 -std=c++23 -fsanitize=undefined leak.cpp -o leak && ./leak
g++-16 -std=c++23 -fsanitize=memory leak.cpp -o leak
g++-16 -std=c++23 -fsanitize=thread -c leak.cpp -o leak.o && g++-16 -fsanitize=thread leak.o -o leak
""", max_lines=3)

Output

$ clang++ -std=c++23 -g -fsanitize=address leak.cpp -o leak && ASAN_OPTIONS=detect_leaks=1 ./leak
==…==AddressSanitizer: detect_leaks is not supported on this platform.
Abort trap: 6
[exit status 134]
$ clang++ -std=c++23 -fsanitize=leak leak.cpp -o leak
clang++: error: unsupported option '-fsanitize=leak' for target 'arm64-apple-darwin25.6.0'
[exit status 1]
$ clang++ -std=c++23 -fsanitize=memory leak.cpp -o leak
clang++: error: unsupported option '-fsanitize=memory' for target 'arm64-apple-darwin25.6.0'
[exit status 1]
$ clang++ -std=c++23 -fsanitize=thread leak.cpp -o leak && ./leak
first score 42
$ g++-16 -std=c++23 -fsanitize=undefined leak.cpp -o leak && ./leak
first score 42
$ g++-16 -std=c++23 -fsanitize=memory leak.cpp -o leak
g++-16: error: unrecognized argument to '-fsanitize=' option: 'memory'
[exit status 1]
$ g++-16 -std=c++23 -fsanitize=thread -c leak.cpp -o leak.o && g++-16 -fsanitize=thread leak.o -o leak
Undefined symbols for architecture arm64:
  "___tsan_func_entry", referenced from:
      std::to_chars(char*, char*, unsigned char, int) in leak.o
[152 more lines]
[exit status 1]

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 platform_session.py

Apple clang offers ASan, UBSan and ThreadSanitizer, but refuses -fsanitize=leak and -fsanitize=memory, and its ASan runtime rejects detect_leaks=1, although Clang’s own documentation lists macOS as supported. Homebrew’s GCC builds UBSan programs, and ASan ones, but has no ThreadSanitizer library on macOS, so the link fails. To find leaks, run the tests on Linux, where LeakSanitizer is part of every ASan build; ThreadSanitizer cannot be combined with AddressSanitizer in one build.

Online judges

LeetCode’s help centre says its C++ judge compiles with Clang at -O2, using libstdc++ from GCC, with AddressSanitizer enabled. A judge result that starts with AddressSanitizer: heap-buffer-overflow is therefore exactly the report of this lesson: look for the first frame in your solution, and for how far outside the region the access landed.

What a sanitized build costs

Clang’s documentation puts the typical slowdown of AddressSanitizer at 2x, with more memory used, especially for programs that make many small allocations. Here is one measurement on this Mac, a loop over 20 million ints, built at the same optimisation level without and with both sanitizers:

cost.cpp C++ · cost.cpp
#include <chrono>
#include <numeric>
#include <print>
#include <vector>

int main() {
    std::vector<int> values(20'000'000);
    std::iota(values.begin(), values.end(), 0);
    auto start = std::chrono::steady_clock::now();
    long long total = 0;
    for (int round = 0; round < 10; ++round)
        for (std::size_t i = 0; i < values.size(); ++i) total += values[i] % 7;
    auto ms = std::chrono::duration_cast<std::chrono::milliseconds>(std::chrono::steady_clock::now() - start).count();
    std::println("total {}, {} ms", total, ms);
}
The same loop, plain and sanitized Python · cost_session.py
from session import run

# The same loop, built without and with AddressSanitizer and UBSan, at the same optimisation level.
run("""
clang++ -std=c++23 -O1 cost.cpp -o cost && ./cost
clang++ -std=c++23 -O1 -g -fsanitize=address,undefined -fno-omit-frame-pointer cost.cpp -o cost-checked && ./cost-checked
""")

Output

$ clang++ -std=c++23 -O1 cost.cpp -o cost && ./cost
total 599999970, 261 ms
$ clang++ -std=c++23 -O1 -g -fsanitize=address,undefined -fno-omit-frame-pointer cost.cpp -o cost-checked && ./cost-checked
total 599999970, 767 ms

This output changes from run to run: Run times change from run to run with whatever else the computer is doing, so only the shape of the output is fixed.

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 cost_session.py

The sanitized loop is much slower, because every element access now checks its shadow byte and every addition its overflow. That cost is why sanitizers belong in debug builds, tests and continuous integration, not in timing runs or releases. A sensible setup:

  • A test build with -fsanitize=address,undefined -fno-sanitize-recover=all -g -fno-omit-frame-pointer, run by every test suite.
  • On Linux, a separate ThreadSanitizer build for code with threads, and LeakSanitizer through ASan.
  • For releases, the cheap checks of a hardened standard library, which are designed for production; Clang’s documentation describes its full UBSan runtime as meant for testing, and offers a minimal runtime or trap mode for production builds.
  • Sanitizers only check the code paths that run, so pair them with tests that reach the edge cases.
Text Diff Paste two runs of a sanitizer report, for example before and after a fix or from GCC and Clang, to see which lines changed.

Your turn: name the bug from its report

drill.cpp holds six new bugs. Read the code, then answer the questions below: each one shows the shortened report of one run, and asks what the bug is and where to fix it. The full session follows the questions, so that you can check every report against its run.

drill.cpp C++ · drill.cpp
#include <cstring>
#include <print>
#include <string>
#include <string_view>
#include <vector>

int counts[4];

void save_name() {
    char name[8];
    std::strcpy(name, "Priyanka");
    std::println("saved {}", name);
}

void count_grade(int grade) {
    ++counts[grade];
}

std::string_view welcome() {
    std::string text = "Welcome to the C++ memory lab, batch 7";
    return text;
}

int fifth_reading() {
    std::vector<int> readings;
    readings.reserve(10);
    readings.push_back(42);
    return readings[4];
}

int average(int total, int count) {
    return total / count;
}

int factorial(int n) {
    int result = 1;
    for (int i = 2; i <= n; ++i) result *= i;
    return result;
}

int main(int argc, char* argv[]) {
    std::string_view which = argc > 1 ? argv[1] : "";
    if (which == "1") save_name();
    if (which == "2") count_grade(4);
    if (which == "3") std::println("{}", welcome());
    if (which == "4") std::println("{}", fifth_reading());
    if (which == "5") std::println("{}", average(0, 0));
    if (which == "6") std::println("{}", factorial(13));
}

Read six reports

6 questions about this lesson. Every answer and why it is right is on the page, behind “Show the answer”. Your score stays in this browser.

  1. Question 1 of 6 ./drill 1 printed this report. What is the bug, and which line do you fix?

    Read the code, then choose one answer.

    ==…==ERROR: AddressSanitizer: stack-buffer-overflow on address 0x…
    WRITE of size 9 at 0x… thread T0
        #1 in save_name() drill.cpp:11
        #2 in main drill.cpp:43
    Address 0x… is located in stack of thread T0 at offset 40 in frame
        #0 in save_name() drill.cpp:9
    SUMMARY: AddressSanitizer: stack-buffer-overflow
    Show the answer to question 1

    Answer: Line 11 copies 9 bytes ("Priyanka" and its terminating zero) into the 8-byte local array name

    WRITE of size 9 into a stack frame points at a copy into a local array; frame #1 is line 11, the strcpy (frame #0 is strcpy itself, inside the sanitizer). The name needs 9 bytes with its terminating zero. Use std::string, or a buffer sized from the text.

  2. Question 2 of 6 ./drill 2 printed this report. What is the bug?

    Read the code, then choose one answer.

    ==…==ERROR: AddressSanitizer: global-buffer-overflow on address 0x…
    READ of size 4 at 0x… thread T0
        #0 in count_grade(int) drill.cpp:16
        #1 in main drill.cpp:44
    0x… is located 0 bytes after global variable 'counts' defined in 'drill.cpp:7' (0x…) of size 16
    SUMMARY: AddressSanitizer: global-buffer-overflow
    Show the answer to question 2

    Answer: count_grade indexes the 4-element global array counts with 4, one past its end

    The region is the global counts, 16 bytes or 4 ints, and the access is 0 bytes after it: index 4. A READ of size 4 comes first because ++counts[grade] reads before it writes. Check grade against the size before indexing, or use std::array with at() while debugging. Global ints start at zero, so they are never uninitialised.

  3. Question 3 of 6 ./drill 3 printed this report. What is the bug?

    Read the code, then choose one answer.

    ==…==ERROR: AddressSanitizer: heap-use-after-free on address 0x…
    READ of size 38 at 0x… thread T0
        #28 in main drill.cpp:45
    0x… is located 0 bytes inside of 40-byte region [0x…,0x…)
    freed by thread T0 here:
        #2 in welcome() drill.cpp:22
        #3 in main drill.cpp:45
    previously allocated by thread T0 here:
        #4 in welcome() drill.cpp:20
        #5 in main drill.cpp:45
    SUMMARY: AddressSanitizer: heap-use-after-free
    Show the answer to question 3

    Answer: welcome() returns a string_view into its local std::string, whose buffer is freed when the function returns

    The memory was allocated on line 20, by the std::string text, and freed on line 22, the closing brace of welcome(), where text is destroyed. The returned view still points there, and printing it reads 38 freed bytes. Return std::string instead. Clang also warned while compiling (-Wreturn-stack-address).

  4. Question 4 of 6 ./drill 4 printed this report. What does container-overflow mean here?

    Read the code, then choose one answer.

    ==…==ERROR: AddressSanitizer: container-overflow on address 0x…
    READ of size 4 at 0x… thread T0
        #0 in fifth_reading() drill.cpp:28
        #1 in main drill.cpp:46
    0x… is located 16 bytes inside of 40-byte region [0x…,0x…)
    allocated by thread T0 here:
        #9 in fifth_reading() drill.cpp:26
        #10 in main drill.cpp:46
    SUMMARY: AddressSanitizer: container-overflow
    Show the answer to question 4

    Answer: readings[4] reads reserved capacity beyond the vector's size of 1; the memory exists, but no element lives there

    reserve(10) allocated 40 bytes, so offset 16, element 4, is inside the block, and a plain ASan check would let it pass. libc++ tells ASan which part of a vector's capacity holds elements, so the read of an element that does not exist is reported. Use push_back to add elements, or resize instead of reserve.

  5. Question 5 of 6 ./drill 5 printed these lines. What happened?

    Read the code, then choose one answer.

    drill.cpp:32:18: runtime error: division by zero
    SUMMARY: UndefinedBehaviorSanitizer: undefined-behavior drill.cpp:32:18
    0
    Show the answer to question 5

    Answer: average(0, 0) divided by a count of 0, which is undefined behaviour; UBSan reported it and the program went on to print 0

    Integer division by zero is undefined behaviour. Some processors trap, while the arm64 processor of this Mac returns 0, so without UBSan the bug would look like a quiet average of 0. UBSan recovers by default, which is why 0 was still printed. Decide what the average of no values should be, and handle count == 0 before dividing.

  6. Question 6 of 6 ./drill 6 printed these lines. What is wrong with the result?

    Read the code, then choose one answer.

    drill.cpp:37:41: runtime error: signed integer overflow: 479001600 * 13 cannot be represented in type 'int'
    SUMMARY: UndefinedBehaviorSanitizer: undefined-behavior drill.cpp:37:41
    1932053504
    Show the answer to question 6

    Answer: 13! is 6,227,020,800, which does not fit in a 32-bit int, so the printed number is meaningless

    12! = 479,001,600 still fits, and multiplying by 13 overflows. The printed value is what the instrumented build happened to produce, the true product reduced modulo 2 to the 32nd. Use long long, which holds factorials up to 20!, or check before multiplying.

All six runs Python · drill_session.py
from session import run

# Six bugs from drill.cpp, one per run, built with AddressSanitizer and UBSan.
run("""
clang++ -std=c++23 -g -fsanitize=address,undefined -fno-omit-frame-pointer drill.cpp -o drill
./drill 1
./drill 2
./drill 3
./drill 4
./drill 5
./drill 6
""")

Output

$ clang++ -std=c++23 -g -fsanitize=address,undefined -fno-omit-frame-pointer drill.cpp -o drill
drill.cpp:11:5: warning: 'strcpy' will always overflow; destination buffer has size 8, but the source string has length 9 (including NUL byte) [-Wfortify-source]
   11 |     std::strcpy(name, "Priyanka");
      |     ^
drill.cpp:21:12: warning: address of stack memory associated with local variable 'text' returned [-Wreturn-stack-address]
   21 |     return text;
      |            ^~~~
2 warnings generated.
$ ./drill 1
==…==ERROR: AddressSanitizer: stack-buffer-overflow on address 0x…
WRITE of size 9 at 0x… thread T0
    #1 in save_name() drill.cpp:11
    #2 in main drill.cpp:43
Address 0x… is located in stack of thread T0 at offset 40 in frame
    #0 in save_name() drill.cpp:9
SUMMARY: AddressSanitizer: stack-buffer-overflow
[40 more lines of the report left out]
==…==ABORTING
[exit status 134]
$ ./drill 2
==…==ERROR: AddressSanitizer: global-buffer-overflow on address 0x…
READ of size 4 at 0x… thread T0
    #0 in count_grade(int) drill.cpp:16
    #1 in main drill.cpp:44
0x… is located 0 bytes after global variable 'counts' defined in 'drill.cpp:7' (0x…) of size 16
SUMMARY: AddressSanitizer: global-buffer-overflow
[34 more lines of the report left out]
==…==ABORTING
[exit status 134]
$ ./drill 3
==…==ERROR: AddressSanitizer: heap-use-after-free on address 0x…
READ of size 38 at 0x… thread T0
    #28 in main drill.cpp:45
0x… is located 0 bytes inside of 40-byte region [0x…,0x…)
freed by thread T0 here:
    #2 in welcome() drill.cpp:22
    #3 in main drill.cpp:45
previously allocated by thread T0 here:
    #4 in welcome() drill.cpp:20
    #5 in main drill.cpp:45
SUMMARY: AddressSanitizer: heap-use-after-free
[72 more lines of the report left out]
==…==ABORTING
[exit status 134]
$ ./drill 4
==…==ERROR: AddressSanitizer: container-overflow on address 0x…
READ of size 4 at 0x… thread T0
    #0 in fifth_reading() drill.cpp:28
    #1 in main drill.cpp:46
0x… is located 16 bytes inside of 40-byte region [0x…,0x…)
allocated by thread T0 here:
    #9 in fifth_reading() drill.cpp:26
    #10 in main drill.cpp:46
SUMMARY: AddressSanitizer: container-overflow
[47 more lines of the report left out]
==…==ABORTING
[exit status 134]
$ ./drill 5
drill.cpp:32:18: runtime error: division by zero
SUMMARY: UndefinedBehaviorSanitizer: undefined-behavior drill.cpp:32:18
0
$ ./drill 6
drill.cpp:37:41: runtime error: signed integer overflow: 479001600 * 13 cannot be represented in type 'int'
SUMMARY: UndefinedBehaviorSanitizer: undefined-behavior drill.cpp:37:41
1932053504

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 drill_session.py

Before the runs, notice the build: Clang warned about two of the six bugs while compiling (-Wfortify-source for the strcpy and -Wreturn-stack-address for the returned view). Warnings are the cheapest sanitizer of all; read them first.

Interview questions

Fresher · What does AddressSanitizer do, and what does it cost?

AddressSanitizer is a compiler feature, -fsanitize=address in GCC and Clang and /fsanitize=address in MSVC, that adds a check to every memory access and replaces the allocator. It keeps shadow memory that records which bytes may be used, puts poisoned redzones around each allocation and keeps freed memory poisoned for a while, so the first out-of-bounds access, use after free, use after scope or double free stops the program with a report: the kind of error, the faulty line, and where the memory was allocated and freed. The price is speed and memory. Clang’s documentation gives a typical slowdown of 2x, and memory use grows, most for small allocations. So it belongs in debug and test builds and CI, not in releases or timing runs, and it only finds bugs on the code paths that actually run.

Key takeaways

  • Build with -fsanitize=address,undefined -g -fno-omit-frame-pointer for tests, and add -fno-sanitize-recover=all so that every report fails the run.
  • Read a report in four steps: the error kind, the access and the first frame in your code, how far outside which region it landed, and where that memory was allocated or freed.
  • ASan finds out-of-bounds accesses on the heap, the stack and globals, uses after free and after scope, double frees and, with libc++, container overflows. UBSan finds signed overflow, bad shifts, misaligned pointers, division by zero and known-bound array indexes.
  • MSVC offers only AddressSanitizer, on x86 and x64. On macOS arm64, Apple clang has ASan, UBSan and ThreadSanitizer but no LeakSanitizer or MemorySanitizer, so run leak checks on Linux.
  • Sanitized builds run several times slower and use more memory: use them for debugging and CI, never for timing.

References

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.