AI InterviewSearch…Search problems, topics, or pages…Ctrl KGet started
Skip to content

07 Memory, ownership and RAII / Lesson 6 of 6

Memory errors and the tools that find them

15 min · +10 XP · 2 exercises · 7 questions

Read the text15 min
Exercises0/2
Quiz7 q

Quick answer

How do you find memory errors in C++?

Compile with AddressSanitizer (-fsanitize=address -g) and run the program: it stops at the first use-after-free, buffer overflow or double free and prints where the bad access happened, where the memory was freed and where it was allocated; on Linux it also reports leaks at exit. Add UBSan (-fsanitize=undefined) for signed overflow and null dereferences, or run Valgrind's memcheck on the unmodified binary to find uninitialised reads.

Every memory error in C++ is the same event in different clothing: the program reads or writes storage it does not own at that moment. The compiler cannot see it, the language calls it undefined behaviour, and the optimiser assumes it never happens, so the symptom — a crash, a wrong number, a test that passes on one machine — appears somewhere far from the cause. This lesson catalogues the seven errors, shows what each looks like in source and in the report of the tool that catches it, and closes with the ownership rules from this module that make each one impossible to write.

The catalogue

ErrorThe codeThe symptom
leaknew without delete; a shared_ptr cyclememory grows; destructors never run
dangling pointer or reference&local returned; a pointer into a vector after push_backreads garbage or another object's value
use after freedelete p; *pwrong values now, corruption later
double freedelete p; delete p; — two ownersan allocator crash, often much later
buffer overflowa[n] on an array of n; strcpy into a short bufferoverwrites a neighbour; a security hole
uninitialised readint x; if (x > 0); new int read before writtendiffers between runs and optimisation levels
invalidated iteratorv.erase(it) in a loop over v; it held across push_backskipped elements, crashes

Each is undefined behaviour: the standard places no requirement on what happens next. In practice the optimiser reasons from "this cannot happen" — it may delete a null check that follows a dereference, keep in a register a value that memory says has changed, or fold a read of uninitialised storage into a constant. The judge's -O2 build of a program with a dangling read once printed 0 and passed; the same program under another compiler printed a neighbouring variable. Neither is right; the program has no meaning.

Reading the source

std::vector<int> v{1, 2, 3};
int& first = v[0];
v.push_back(4);              // may reallocate: first now refers to freed storage
std::cout << first << '\n';  // use after free through a dangling reference

int* make() {
    int local = 5;
    return &local;           // dangling: the frame is gone when the caller reads it
}

char name[8];
std::strcpy(name, "a long name");   // stack buffer overflow: 12 bytes into 8

The common feature is a pointer or reference that outlived what it pointed at, or a count that was not checked. None of them is caught by the compiler, though -Wall reports return &local and -Wextra some uninitialised reads. Finding the rest is the job of run-time tools.

AddressSanitizer

ASan instruments every load and store with a check against a shadow map of which bytes are valid. Compile with -fsanitize=address -g (add -fno-omit-frame-pointer for better stack traces), run normally, and it aborts at the first bad access with a report naming the kind of error, the access, and where the memory came from:

$ g++ -std=c++20 -g -fsanitize=address main.cpp -o main && ./main
==4242==ERROR: AddressSanitizer: heap-use-after-free on address 0x602000000010
READ of size 4 at 0x602000000010 thread T0
    #0 0x55d0c4 in main main.cpp:7
0x602000000010 is located 0 bytes inside of 12-byte region
freed by thread T0 here:
    #0 0x7f3a12 in operator delete(void*)
    #1 0x55d0a1 in std::vector<int>::push_back main.cpp:6
previously allocated by thread T0 here:
    #1 0x55d033 in main main.cpp:4

Three stacks: where the bad access is, where the block was freed, where it was allocated — which is the whole story of a use-after-free. ASan also catches heap and stack buffer overflows, double frees and (through the LeakSanitizer it includes on Linux) leaks, reported at exit as Direct leak of 400 byte(s) in 1 object(s) allocated from: with the allocating stack. The cost is around 2× run time and 2–3× memory: a development flag, never a release one.

UndefinedBehaviorSanitizer

UBSan checks for undefined behaviour that is not a memory access: signed overflow, shifts by too much, null dereference, misaligned access, out-of-range enum values, and out-of-bounds indexing into arrays whose bound the compiler knows. Compile with -fsanitize=undefined, usually together with ASan, and it prints one line per violation and continues:

main.cpp:12:15: runtime error: signed integer overflow: 2147483647 + 1 cannot be represented in type 'int'
main.cpp:20:9: runtime error: index 8 out of bounds for type 'int [8]'

ASan and UBSan together are the standard local test configuration for any program that uses pointers; Module 19 returns to them for the wider undefined-behaviour catalogue.

Valgrind

Valgrind's memcheck runs the unmodified binary on a synthetic CPU and tracks every byte's validity and every allocation. No recompilation, so it works on code you cannot rebuild, and it catches uninitialised reads that ASan does not. The price is a 20–50× slowdown.

$ valgrind --leak-check=full ./main
==5151== Invalid read of size 4
==5151==    at 0x1091A3: main (main.cpp:7)
==5151==  Address 0x4dbec80 is 0 bytes inside a block of size 12 free'd
==5151== Conditional jump or move depends on uninitialised value(s)
==5151==    at 0x1091C8: main (main.cpp:15)
==5151== LEAK SUMMARY:
==5151==    definitely lost: 400 bytes in 1 blocks
==5151==    indirectly lost: 0 bytes in 0 blocks

"Definitely lost" is a block nothing points at any more; "indirectly lost" is a block reachable only from a lost block (the children of a leaked tree); "still reachable" is memory a global still points at when the program exits, which is usually not a bug. Compile with -g for line numbers and -O0 or -O1 so that the lines mean what they say.

Cheaper checks

-D_GLIBCXX_ASSERTIONS makes libstdc++'s operator[] on std::vector and std::string check bounds and abort with a message — nearly free, and a good default for every debug build. -fanalyzer (GCC) is a static analysis that finds some leaks, double frees and null dereferences at compile time. -Wall -Wextra remain the first line. And the judge has none of these: a submission that passes has not been shown correct, only not shown wrong.

The rules that prevent them

Each error in the table maps to a rule from the earlier lessons:

  1. No naked new/delete outside a class that owns the result. Leaks and double frees come from manual ownership; std::vector, std::string, std::make_unique and std::make_shared do it correctly.
  2. One owner; everyone else borrows, and a borrow never outlives the owner. Raw pointers and references are non-owning: never store one past the scope that guarantees the owner is alive.
  3. A container may move its elements. Do not keep pointers, references or iterators into a std::vector or std::string across an insertion; keep the index instead.
  4. Initialise everything. int x{};, new int[n]{}, member initialisers. There is no reason to declare an uninitialised variable in C++20.
  5. Bounds come from the container. Loop to v.size(), use .at() when the index comes from outside, and use std::array or std::vector rather than a raw array and a separate count.
  6. Break every cycle with weak_ptr.
  7. Test with ASan and UBSan before you trust a passing run.

The two exercises are detectors written in well-defined code: a leak detector that reads a ledger of allocations and frees and reports leaks, double frees and invalid frees, and a bounds-checked buffer that answers out of range where an unchecked one would corrupt its neighbour — the tools' logic, modelled, since a real memory error cannot be judged.

Pitfalls

MistakeConsequence
Trusting a test that passes without sanitizersUndefined behaviour that happened to be benign on one build.
Running ASan at -O3 without frame pointersStack traces lose frames; use -O1 -g -fno-omit-frame-pointer.
Ignoring "indirectly lost"The leaked children of the block you did see.
Silencing a warning about a returned local's addressThe bug is real.
Fixing a double free with p = nullptrHides the second owner; find it.

Key takeaways

  • Seven errors, one cause: accessing storage the program does not own at that moment; all are undefined behaviour the optimiser may hide.
  • AddressSanitizer (-fsanitize=address -g) reports use-after-free, overflows, double frees and leaks with three stacks: access, free, allocation.
  • UBSan (-fsanitize=undefined) adds overflow, bad shifts, null dereference and known-bound indexing; Valgrind needs no recompile and finds uninitialised reads.
  • -D_GLIBCXX_ASSERTIONS checks [] on standard containers almost for free.
  • The ownership rules prevent the errors: no naked new, one owner, no pointers across container growth, initialise everything, weak_ptr for cycles.

Common questions

What is a use-after-free error?

Use after free is reading or writing an object through a pointer or reference after its storage has been freed — delete p; *p = 2;, or a reference into a std::vector after push_back reallocated it. It is undefined behaviour: the storage may already belong to another object, so it shows as wrong values now and corruption later.

What is the difference between AddressSanitizer and Valgrind?

AddressSanitizer is compiled into the program, costs around 2× run time, and catches use-after-free, overflows, double frees and leaks. Valgrind's memcheck runs the unmodified binary on a synthetic CPU, needs no recompilation and catches uninitialised reads that ASan does not, but slows the program 20–50×.

What does "definitely lost" mean in Valgrind?

"Definitely lost" is a leaked block that nothing points at any more. "Indirectly lost" is a block reachable only from a lost block, such as the children of a leaked tree, and "still reachable" is memory a global still points at when the program exits, which is usually not a bug.

Why does a program with undefined behaviour sometimes work?

Undefined behaviour places no requirement on what happens, so the result depends on the compiler, the optimisation level and the memory layout. A dangling read may print a plausible value on one build and a neighbouring variable on another; a passing run proves nothing, which is why you test with sanitizers.

Mark as readSign in to keep your place.

C++ Memory Errors and Leaks: AddressSanitizer and Valgrind

Use after free, double free, buffer overflow, dangling pointers and leaks in C++, and how AddressSanitizer, UBSan and Valgrind report each one.

  • Course: C++ study plan
  • Module: Memory, ownership and RAII
  • Kind: Lesson
  • Reading time: 15 min
  • Runtime: Clang 18 · C++20

How do you find memory errors in C++?

Compile with AddressSanitizer (-fsanitize=address -g) and run the program: it stops at the first use-after-free, buffer overflow or double free and prints where the bad access happened, where the memory was freed and where it was allocated; on Linux it also reports leaks at exit. Add UBSan (-fsanitize=undefined) for signed overflow and null dereferences, or run Valgrind's memcheck on the unmodified binary to find uninitialised reads.

Lesson

Every memory error in C++ is the same event in different clothing: the program reads or writes storage it does not own at that moment. The compiler cannot see it, the language calls it undefined behaviour, and the optimiser assumes it never happens, so the symptom — a crash, a wrong number, a test that passes on one machine — appears somewhere far from the cause. This lesson catalogues the seven errors, shows what each looks like in source and in the report of the tool that catches it, and closes with the ownership rules from this module that make each one impossible to write.

The catalogue

ErrorThe codeThe symptom
leaknew without delete; a shared_ptr cyclememory grows; destructors never run
dangling pointer or reference&local returned; a pointer into a vector after push_backreads garbage or another object's value
use after freedelete p; *pwrong values now, corruption later
double freedelete p; delete p; — two ownersan allocator crash, often much later
buffer overflowa[n] on an array of n; strcpy into a short bufferoverwrites a neighbour; a security hole
uninitialised readint x; if (x > 0); new int read before writtendiffers between runs and optimisation levels
invalidated iteratorv.erase(it) in a loop over v; it held across push_backskipped elements, crashes

Each is undefined behaviour: the standard places no requirement on what happens next. In practice the optimiser reasons from "this cannot happen" — it may delete a null check that follows a dereference, keep in a register a value that memory says has changed, or fold a read of uninitialised storage into a constant. The judge's -O2 build of a program with a dangling read once printed 0 and passed; the same program under another compiler printed a neighbouring variable. Neither is right; the program has no meaning.

Reading the source

std::vector<int> v{1, 2, 3};
int& first = v[0];
v.push_back(4);              // may reallocate: first now refers to freed storage
std::cout << first << '\n';  // use after free through a dangling reference

int* make() {
    int local = 5;
    return &local;           // dangling: the frame is gone when the caller reads it
}

char name[8];
std::strcpy(name, "a long name");   // stack buffer overflow: 12 bytes into 8

The common feature is a pointer or reference that outlived what it pointed at, or a count that was not checked. None of them is caught by the compiler, though -Wall reports return &local and -Wextra some uninitialised reads. Finding the rest is the job of run-time tools.

AddressSanitizer

ASan instruments every load and store with a check against a shadow map of which bytes are valid. Compile with -fsanitize=address -g (add -fno-omit-frame-pointer for better stack traces), run normally, and it aborts at the first bad access with a report naming the kind of error, the access, and where the memory came from:

$ g++ -std=c++20 -g -fsanitize=address main.cpp -o main && ./main
==4242==ERROR: AddressSanitizer: heap-use-after-free on address 0x602000000010
READ of size 4 at 0x602000000010 thread T0
    #0 0x55d0c4 in main main.cpp:7
0x602000000010 is located 0 bytes inside of 12-byte region
freed by thread T0 here:
    #0 0x7f3a12 in operator delete(void*)
    #1 0x55d0a1 in std::vector<int>::push_back main.cpp:6
previously allocated by thread T0 here:
    #1 0x55d033 in main main.cpp:4

Three stacks: where the bad access is, where the block was freed, where it was allocated — which is the whole story of a use-after-free. ASan also catches heap and stack buffer overflows, double frees and (through the LeakSanitizer it includes on Linux) leaks, reported at exit as Direct leak of 400 byte(s) in 1 object(s) allocated from: with the allocating stack. The cost is around 2× run time and 2–3× memory: a development flag, never a release one.

UndefinedBehaviorSanitizer

UBSan checks for undefined behaviour that is not a memory access: signed overflow, shifts by too much, null dereference, misaligned access, out-of-range enum values, and out-of-bounds indexing into arrays whose bound the compiler knows. Compile with -fsanitize=undefined, usually together with ASan, and it prints one line per violation and continues:

main.cpp:12:15: runtime error: signed integer overflow: 2147483647 + 1 cannot be represented in type 'int'
main.cpp:20:9: runtime error: index 8 out of bounds for type 'int [8]'

ASan and UBSan together are the standard local test configuration for any program that uses pointers; Module 19 returns to them for the wider undefined-behaviour catalogue.

Valgrind

Valgrind's memcheck runs the unmodified binary on a synthetic CPU and tracks every byte's validity and every allocation. No recompilation, so it works on code you cannot rebuild, and it catches uninitialised reads that ASan does not. The price is a 20–50× slowdown.

$ valgrind --leak-check=full ./main
==5151== Invalid read of size 4
==5151==    at 0x1091A3: main (main.cpp:7)
==5151==  Address 0x4dbec80 is 0 bytes inside a block of size 12 free'd
==5151== Conditional jump or move depends on uninitialised value(s)
==5151==    at 0x1091C8: main (main.cpp:15)
==5151== LEAK SUMMARY:
==5151==    definitely lost: 400 bytes in 1 blocks
==5151==    indirectly lost: 0 bytes in 0 blocks

"Definitely lost" is a block nothing points at any more; "indirectly lost" is a block reachable only from a lost block (the children of a leaked tree); "still reachable" is memory a global still points at when the program exits, which is usually not a bug. Compile with -g for line numbers and -O0 or -O1 so that the lines mean what they say.

Cheaper checks

-D_GLIBCXX_ASSERTIONS makes libstdc++'s operator[] on std::vector and std::string check bounds and abort with a message — nearly free, and a good default for every debug build. -fanalyzer (GCC) is a static analysis that finds some leaks, double frees and null dereferences at compile time. -Wall -Wextra remain the first line. And the judge has none of these: a submission that passes has not been shown correct, only not shown wrong.

The rules that prevent them

Each error in the table maps to a rule from the earlier lessons:

  1. No naked new/delete outside a class that owns the result. Leaks and double frees come from manual ownership; std::vector, std::string, std::make_unique and std::make_shared do it correctly.
  2. One owner; everyone else borrows, and a borrow never outlives the owner. Raw pointers and references are non-owning: never store one past the scope that guarantees the owner is alive.
  3. A container may move its elements. Do not keep pointers, references or iterators into a std::vector or std::string across an insertion; keep the index instead.
  4. Initialise everything. int x{};, new int[n]{}, member initialisers. There is no reason to declare an uninitialised variable in C++20.
  5. Bounds come from the container. Loop to v.size(), use .at() when the index comes from outside, and use std::array or std::vector rather than a raw array and a separate count.
  6. Break every cycle with weak_ptr.
  7. Test with ASan and UBSan before you trust a passing run.

The two exercises are detectors written in well-defined code: a leak detector that reads a ledger of allocations and frees and reports leaks, double frees and invalid frees, and a bounds-checked buffer that answers out of range where an unchecked one would corrupt its neighbour — the tools' logic, modelled, since a real memory error cannot be judged.

Pitfalls

MistakeConsequence
Trusting a test that passes without sanitizersUndefined behaviour that happened to be benign on one build.
Running ASan at -O3 without frame pointersStack traces lose frames; use -O1 -g -fno-omit-frame-pointer.
Ignoring "indirectly lost"The leaked children of the block you did see.
Silencing a warning about a returned local's addressThe bug is real.
Fixing a double free with p = nullptrHides the second owner; find it.

Key takeaways

  • Seven errors, one cause: accessing storage the program does not own at that moment; all are undefined behaviour the optimiser may hide.
  • AddressSanitizer (-fsanitize=address -g) reports use-after-free, overflows, double frees and leaks with three stacks: access, free, allocation.
  • UBSan (-fsanitize=undefined) adds overflow, bad shifts, null dereference and known-bound indexing; Valgrind needs no recompile and finds uninitialised reads.
  • -D_GLIBCXX_ASSERTIONS checks [] on standard containers almost for free.
  • The ownership rules prevent the errors: no naked new, one owner, no pointers across container growth, initialise everything, weak_ptr for cycles.

Common questions

What is a use-after-free error?

Use after free is reading or writing an object through a pointer or reference after its storage has been freed — delete p; *p = 2;, or a reference into a std::vector after push_back reallocated it. It is undefined behaviour: the storage may already belong to another object, so it shows as wrong values now and corruption later.

What is the difference between AddressSanitizer and Valgrind?

AddressSanitizer is compiled into the program, costs around 2× run time, and catches use-after-free, overflows, double frees and leaks. Valgrind's memcheck runs the unmodified binary on a synthetic CPU, needs no recompilation and catches uninitialised reads that ASan does not, but slows the program 20–50×.

What does "definitely lost" mean in Valgrind?

"Definitely lost" is a leaked block that nothing points at any more. "Indirectly lost" is a block reachable only from a lost block, such as the children of a leaked tree, and "still reachable" is memory a global still points at when the program exits, which is usually not a bug.

Why does a program with undefined behaviour sometimes work?

Undefined behaviour places no requirement on what happens, so the result depends on the compiler, the optimisation level and the memory layout. A dangling read may print a plausible value on one build and a neighbouring variable on another; a passing run proves nothing, which is why you test with sanitizers.

Exercises

A leak detector

A real leak detector watches every allocation and free; yours audits a ledger of them, since the judge cannot observe real memory errors. Read commands until the input ends:

  • alloc <id> <bytes> — a new block. If id is currently live, print already allocated: <id> and ignore the command.
  • free <id> — release the block. If id was never allocated print invalid free: <id>; if it was allocated but is already freed print double free: <id>.

An id may be allocated again after it has been freed; that is a new block. When the input ends, list every block still live in order of allocation as leaked: <id> (<bytes> bytes) — or a single no leaks line — followed by summary: allocs=<a> frees=<f> leaked=<k> blocks / <total> bytes, errors=<e>, where allocs and frees count successful commands and errors counts the three error kinds. Bytes are summed in long long.

Input: commands. Output: error lines as they occur, then the leak list and the summary.

alloc a 64
alloc b 32
free b
free b
free z
alloc c 16

prints

double free: b
invalid free: z
leaked: a (64 bytes)
leaked: c (16 bytes)
summary: allocs=3 frees=1 leaked=2 blocks / 80 bytes, errors=2

A buffer that refuses to overflow

An unchecked write past the end of a buffer silently corrupts whatever lives next to it; a checked one reports. Read the buffer size n and create a std::vector<int> of n zeros, then process commands:

  • set <i> <v> — store v at index i through .at();
  • get <i> — print buf[<i>]=<value>;
  • sum — print sum=<total> (long long).

Read i as a long long and index with static_cast<std::size_t>(i); a negative i becomes a huge unsigned value, which .at() rejects like any other out-of-range index. Catch the std::out_of_range that .at() throws and print out of range: <i> with the index as it was read. Never let the exception escape main.

Input: n, then commands. Output: one line per get/sum, and one per rejected index.

5
set 0 10
set 4 20
set 5 30
get 4
get -1
sum

prints

out of range: 5
buf[4]=20
out of range: -1
sum=30

In this module: Memory, ownership and RAII

← std::shared_ptr and std::weak_ptr — counted ownership and its cycles · Checkpoint — Memory, ownership and RAII →