EthicsComputerCourses
Join training +91 90532 10052

Undefined Behaviour & Sanitizers

C++ · Lesson 29 of 46 Intermediate 8 min read Updated

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 / 2 is 3.
  • 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

MistakeExampleSafer choice
Reading outside an arraymarks[3] when marks has 3 items.at(i), range-for, std::span
Signed integer overflowINT_MAX + 1Check first, use a wider type
Using memory after it is freedPointer into a vector that grewRe-read after changes, use indexes
Dangling referenceReturning a reference to a local variableReturn by value
Reading an uninitialised variableint total; total += 5;Always initialise: int total = 0;
Dereferencing nullptrp->name when p is nullCheck, or use references
Data raceTwo threads write one int without a lockstd::mutex, std::atomic
Deleting twice or with the wrong formdelete 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:

C++
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:

C++
#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));
}
Output
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.

C++
#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:

Terminal
g++ -std=c++23 -g -O1 -fsanitize=address -fno-omit-frame-pointer dangle.cpp -o dangle
./dangle
Output
==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 main

The 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.

C++
#include <climits>
#include <print>

int main() {
    int balance = INT_MAX;
    int deposit = 1;
    int total = balance + deposit;
    std::println("{}", total);
}
Terminal
g++ -std=c++23 -g -fsanitize=undefined overflow.cpp -o overflow
./overflow
Output
overflow.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.

SanitizerGCC/Clang flagFinds
ASan-fsanitize=addressOut of bounds, use after free, double free, leaks
UBSan-fsanitize=undefinedOverflow, bad shifts, null use, misalignment
TSan-fsanitize=threadData races
MSan (Clang only)-fsanitize=memoryReads 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_ASSERTIONS to check operator[] 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_view or 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,undefined finds memory and UB bugs at run time; -fsanitize=thread finds data races.
  • MSVC offers ASan with /fsanitize=address; library assertions such as -D_GLIBCXX_ASSERTIONS are 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
C++
#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());
}
Output
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++?

2. Which sanitizer finds a use-after-free bug?

3. Why can an overflow check written as a + b < a fail?

4. Which pair of sanitizers cannot be used in the same build?

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