Undefined Behaviour & Sanitizers
C++ trusts you. When you break certain rules, the standard does not say what happens: the program might crash, print nonsense, work perfectly on your laptop and fail on the server, or quietly give an attacker control. This is called undefined behaviour (UB), and it is behind many of the worst bugs and security holes in C and C++ software.
In this lesson you will learn the UB cases you are most likely to write, why the compiler can make them worse, and how the sanitizers built into GCC, Clang and MSVC find them for you while your program runs.
What undefined behaviour means
The C++ standard sorts behaviour into a few kinds:
- Defined: the result is fixed, for example
7 / 2is3. - Implementation-defined: each compiler must choose and document a result, for example the size of
int. - Unspecified: one of several results is allowed, for example the order in which the arguments of
f(a(), b())are evaluated. - Undefined: the standard sets no requirements at all on the whole program.
Why would a language allow that? Checking every array index and every addition would make every program slower. C++ lets the compiler assume your program never does these things, and in return it can generate very fast code. The catch is that the compiler really does assume it. Optimisers remove checks and reorder code on that assumption, so UB often causes bugs far away from the line that caused them, and they can appear or vanish when you change -O0 to -O2.
The undefined behaviour you will meet
| Mistake | Example | Safer choice |
|---|---|---|
| Reading outside an array | marks[3] when marks has 3 items | .at(i), range-for, std::span |
| Signed integer overflow | INT_MAX + 1 | Check first, use a wider type |
| Using memory after it is freed | Pointer into a vector that grew | Re-read after changes, use indexes |
| Dangling reference | Returning a reference to a local variable | Return by value |
| Reading an uninitialised variable | int total; total += 5; | Always initialise: int total = 0; |
Dereferencing nullptr | p->name when p is null | Check, or use references |
| Data race | Two threads write one int without a lock | std::mutex, std::atomic |
| Deleting twice or with the wrong form | delete on memory from new[] | Smart pointers and containers |
Unsigned integers are different: unsigned arithmetic wraps around by definition (0u - 1 is the largest unsigned), so it is not UB, although it is often still a bug.
A classic: the check the compiler deletes
Suppose you want to detect overflow when adding runs to a total:
bool will_overflow(int total, int runs) {
return total + runs < total; // wrong: the addition itself may be UB
}If total + runs overflows, the behaviour is already undefined, so the compiler may reason "signed overflow cannot happen, therefore adding a positive number never gives a smaller one" and simplify the function to return false; when runs is known to be positive. Optimising compilers really do this. The correct way is to check before doing the operation:
#include <limits>
#include <print>
bool will_overflow(int total, int runs) {
constexpr int max = std::numeric_limits<int>::max();
constexpr int min = std::numeric_limits<int>::min();
if (runs > 0) return total > max - runs;
return total < min - runs;
}
int main() {
std::println("{}", will_overflow(2'147'483'000, 600));
std::println("{}", will_overflow(2'147'483'000, 700));
std::println("{}", will_overflow(-10, -5));
}false true false
max - runs cannot overflow when runs is positive, so this check is always well defined. The largest int on common systems is 2,147,483,647, which leaves room for 647 more after 2,147,483,000.
AddressSanitizer: memory bugs
A sanitizer is a compiler option that adds checking code to your program. The program runs slower but stops with a detailed report the moment a bug happens. AddressSanitizer (ASan) finds out-of-bounds reads and writes, use after free, use after return, double free and memory leaks. The Clang documentation describes a typical slowdown of about two times, which is fine for testing.
#include <print>
#include <vector>
int main() {
std::vector<int> runs = {45, 12, 88};
int* first = &runs[0];
runs.push_back(101); // the vector moves to a bigger block of memory
std::println("{}", *first); // first still points at the old, freed block
}Without a sanitizer this may print 45, a random number, or crash. With ASan the result is always the same:
g++ -std=c++23 -g -O1 -fsanitize=address -fno-omit-frame-pointer dangle.cpp -o dangle
./dangle==4721==ERROR: AddressSanitizer: heap-use-after-free on address 0x502000000010 at pc 0x... bp 0x... sp 0x...
READ of size 4 at 0x502000000010 thread T0
#0 0x... in main dangle.cpp:8
...
freed by thread T0 here:
...
previously allocated by thread T0 here:
...
SUMMARY: AddressSanitizer: heap-use-after-free dangle.cpp:8 in mainThe report is shortened here; addresses and the number after == differ on every run. Read it from the top: the kind of bug, whether it was a read or a write, and the line (dangle.cpp:8). The "freed by" and "previously allocated by" stacks show where the memory was released (inside push_back) and where it came from. -g gives file and line numbers, and -fno-omit-frame-pointer gives better stack traces.
UndefinedBehaviorSanitizer and ThreadSanitizer
UndefinedBehaviorSanitizer (UBSan) catches signed overflow, invalid shifts, division by zero, null pointer use, misaligned pointers and more. Unlike ASan it prints a message and, by default, lets the program continue.
#include <climits>
#include <print>
int main() {
int balance = INT_MAX;
int deposit = 1;
int total = balance + deposit;
std::println("{}", total);
}g++ -std=c++23 -g -fsanitize=undefined overflow.cpp -o overflow
./overflowoverflow.cpp:7:25: runtime error: signed integer overflow: 2147483647 + 1 cannot be represented in type 'int' -2147483648
The column number can differ slightly between compilers. The second line is what the hardware happened to produce; the program is still wrong. Add -fno-sanitize-recover=all to stop at the first error, which is what you want in automated tests.
ThreadSanitizer (TSan, -fsanitize=thread) finds data races such as the unprotected counter in Threads, jthread & async. It reports the two conflicting accesses and the threads that made them. TSan cannot be combined with ASan in the same build, so teams usually run their tests twice: once with address,undefined and once with thread.
| Sanitizer | GCC/Clang flag | Finds |
|---|---|---|
| ASan | -fsanitize=address | Out of bounds, use after free, double free, leaks |
| UBSan | -fsanitize=undefined | Overflow, bad shifts, null use, misalignment |
| TSan | -fsanitize=thread | Data races |
| MSan (Clang only) | -fsanitize=memory | Reads of uninitialised memory |
Cheaper checks that you can leave on
Sanitizers are for testing, not for shipped programs. Standard libraries also offer lightweight checks:
- GCC's library: compile with
-D_GLIBCXX_ASSERTIONSto checkoperator[]bounds on vectors and strings,front()on empty containers, and similar preconditions. - LLVM's libc++: choose a hardening mode such as
-D_LIBCPP_HARDENING_MODE=_LIBCPP_HARDENING_MODE_FAST. - MSVC: debug builds check iterators and indexes automatically.
Compiler warnings catch some UB before you even run the program, for example -Wall warns about many uninitialised variables. C++26 also turns reading an uninitialised local variable into erroneous behaviour instead of UB; see C++26 Preview.
Common mistakes
- Keeping pointers or iterators into a vector while adding to it. Growth can move every element; store an index instead.
- Returning
std::string_viewor a reference to a local string. The text is destroyed when the function returns. - Testing only optimised or only unoptimised builds. Run tests in both, and with sanitizers.
- Writing overflow checks after the overflow. Check the limits before the operation.
- Combining ASan and TSan. They cannot be used together; build twice.
Key points
- Undefined behaviour means the standard gives no guarantees for the whole program; optimisers assume it never happens.
- Common UB: out-of-bounds access, signed overflow, use after free, dangling references, uninitialised reads, null dereference and data races.
- Check limits before arithmetic; prefer
.at(), range-for, smart pointers and containers. -fsanitize=address,undefinedfinds memory and UB bugs at run time;-fsanitize=threadfinds data races.- MSVC offers ASan with
/fsanitize=address; library assertions such as-D_GLIBCXX_ASSERTIONSare a cheaper extra check.
Exercise
The function below should return the highest score in a list of match scores, but it has two cases of undefined behaviour. Find both, explain them, and fix the function so it is always safe.
int best(const std::vector<int>& scores) { int top; for (std::size_t i = 0; i <= scores.size(); ++i) { if (scores[i] > top) top = scores[i]; } return top; }
Show solution
#include <algorithm>
#include <optional>
#include <print>
#include <vector>
std::optional<int> best(const std::vector<int>& scores) {
if (scores.empty()) {
return std::nullopt;
}
return std::ranges::max(scores);
}
int main() {
std::vector scores = {45, 112, 88};
if (auto top = best(scores)) {
std::println("Best: {}", *top);
}
std::println("Empty list has a best: {}", best({}).has_value());
}Best: 112 Empty list has a best: false
The original reads top before giving it a value (uninitialised read), and i <= scores.size() reads one element past the end (out of bounds). The fix handles an empty list explicitly and lets std::ranges::max, which never leaves the range, find the largest score.
Quick Quiz
1. Which of these is undefined behaviour in C++?
Signed overflow is UB; unsigned arithmetic wraps by definition.
2. Which sanitizer finds a use-after-free bug?
ASan tracks freed memory and reports any later access to it.
3. Why can an overflow check written as a + b < a fail?
The compiler may assume signed overflow never happens and simplify the expression.
4. Which pair of sanitizers cannot be used in the same build?
AddressSanitizer and ThreadSanitizer are incompatible; run them in separate builds.
Learn C++ with a mentor
Free lessons get you started. Our Java, C & C++ Programming training adds real projects and code reviews from working developers.
- Project-based learning
- Mentor code reviews
- Free counselling call
