C++ Module 6 – References, pointers and memory
C++ memory-bug hunt: ten broken programs
Hunt C++ memory bugs with a method that holds: reproduce under the sanitizers, fix the cause, keep a regression test, then repair ten broken programs.
What you will learn
- Reproduce a memory bug under sanitizers
- Fix the cause rather than the symptom
- Protect the fix with a regression test
Before you start
What you’ll learn · Before you start
What you will learn
- Reproduce a memory bug under sanitizers
- Fix the cause rather than the symptom
- Protect the fix with a regression test
Before you start
On this page
A memory-bug hunt goes faster with a fixed method than with guesswork. Reproduce the bug in a build with AddressSanitizer and UBSan, and read the first report, as the sanitizers lesson showed. Find the cause: ask why the index, the pointer or the size is wrong, which is usually somewhere other than the line that crashed. Write a test that fails because of the bug, fix the cause, then re-run everything on both compilers with the sanitizers on, and keep the test, so the same bug cannot come back unnoticed.
Each step closes a gap that a quick fix leaves open. A bug you cannot reproduce cannot be proved fixed; a check added where the program crashed often hides the bug instead of removing it; and a fix without a test is forgotten at the next refactoring. This lesson walks through one hunt in full, a ring buffer with an off-by-one, and then gives you ten broken programs to repair, each with its own checks and the report you should see.
The method
The steps of a memory-bug hunt
Text description of the diagram
The diagram is a sequence of seven boxes from top to bottom.
- Reproduce: build with -fsanitize=address,undefined and run the case that fails.
- Read the first report: the kind of error, the line, the memory region and where it came from.
- Find the cause: ask why the index, pointer or size is wrong, which is often in a different place from the line that crashed.
- Write a test that fails: it must fail before the fix, so that it proves the fix later.
- Fix the cause, not the line that crashed.
- Re-run everything with both compilers and the sanitizers on.
- Keep the test, in the suite that runs on every change.
From step 6, an arrow labelled "all pass, no reports" leads to step 7. A dashed arrow labelled "a new report or a failing test" leads back to step 2.
- Reproduce. Build with
-fsanitize=address,undefined -g -fno-omit-frame-pointerand run the case that fails. If it only fails sometimes, make the input smaller and fixed until it fails every time. - Read the first report. Later reports may only be consequences of the first one: memory that was corrupted once can make the rest of the program misbehave anywhere.
- Find the cause. The report says where memory was misused, not why. Follow the bad value back: who computed this index, who freed this block, who kept this pointer?
- Write a test that fails. It must fail before the fix; a test that passes either way proves nothing.
- Fix the cause. Change the code that produced the bad value, not the line that used it.
- Re-run everything with both compilers and the sanitizers on, with
-fno-sanitize-recover=allso that any UBSan report fails the run. Each compiler and library can expose a different symptom of the same bug. - Keep the test in the suite that runs on every change.
A worked hunt: a ring buffer that forgets
A ring buffer keeps the last few readings of a sensor in a fixed array. next_ is where the next reading goes; when
it reaches the end of the array it should wrap around to 0, and then each new reading replaces the oldest. The test
program checks the newest reading after two pushes, after the buffer fills and wraps, and after a second lap:
ring_buffer.h
#pragma once
#include <cstddef>
#include <vector>
// Keeps the most recent readings of a sensor. When it is full, a new reading replaces the oldest one.
class RingBuffer {
public:
explicit RingBuffer(std::size_t capacity) : data_(capacity) {}
void push(int reading) {
data_[next_] = reading;
++next_;
if (next_ > data_.size()) next_ = 0; // wrap around to the start
if (count_ < data_.size()) ++count_;
}
// The newest reading: the one written just before next_.
int newest() const { return data_[(next_ + data_.size() - 1) % data_.size()]; }
std::size_t count() const { return count_; }
private:
std::vector<int> data_;
std::size_t next_ = 0; // where the next reading goes
std::size_t count_ = 0; // how many readings are stored
}; ring_test.cpp
#include <print>
#if defined(GUARDED)
#include "ring_buffer_guarded.h"
#elif defined(FIXED)
#include "ring_buffer_fixed.h"
#else
#include "ring_buffer.h"
#endif
int failures = 0;
void check(bool ok, const char* what) {
std::println("{} {}", ok ? "PASS" : "FAIL", what);
if (!ok) ++failures;
}
int main() {
RingBuffer temps(3);
temps.push(21);
temps.push(22);
check(temps.newest() == 22 && temps.count() == 2, "newest of two readings");
temps.push(23);
temps.push(24); // the buffer is full: 24 replaces 21
check(temps.newest() == 24 && temps.count() == 3, "newest after the buffer wraps");
temps.push(25);
check(temps.newest() == 25, "newest after a second lap");
return failures == 0 ? 0 : 1;
} Steps 1 and 2: reproduce, then read the first report
from session import run
# Step 1, reproduce: the test without any checks, then with AddressSanitizer and UBSan.
run("""
clang++ -std=c++23 ring_test.cpp -o ring_test && ./ring_test
clang++ -std=c++23 -g -fsanitize=address,undefined -fno-omit-frame-pointer ring_test.cpp -o ring_test && ./ring_test
""") Output
$ clang++ -std=c++23 ring_test.cpp -o ring_test && ./ring_test
PASS newest of two readings
FAIL newest after the buffer wraps
PASS newest after a second lap
[exit status 1]
$ clang++ -std=c++23 -g -fsanitize=address,undefined -fno-omit-frame-pointer ring_test.cpp -o ring_test && ./ring_test
PASS newest of two readings
==…==ERROR: AddressSanitizer: heap-buffer-overflow on address 0x…
WRITE of size 4 at 0x… thread T0
#0 in RingBuffer::push(int) ring_buffer.h:11
#1 in main ring_test.cpp:25
0x… is located 0 bytes after 12-byte region [0x…,0x…)
allocated by thread T0 here:
#9 in RingBuffer::RingBuffer(unsigned long) ring_buffer.h:8
#10 in RingBuffer::RingBuffer(unsigned long) ring_buffer.h:8
#11 in main ring_test.cpp:19
SUMMARY: AddressSanitizer: heap-buffer-overflow
[45 more lines of the report left out]
==…==ABORTING
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 ring_session.py
The build without checks shows the symptom only: one check fails and nothing crashes. That is the dangerous kind of
memory bug, because the program goes on with corrupted memory. The sanitized build stops at the cause of the
corruption: a WRITE of size 4 in RingBuffer::push at ring_buffer.h:11, called from line 25 of the test, which
is the fourth push, temps.push(24). The write landed 0 bytes after a 12-byte region, the 3 ints that the vector
allocated in the constructor: so next_ was 3 on a buffer of 3.
Step 3: find the cause
Line 11, data_[next_] = reading;, is not wrong; it trusts next_ to be a valid index. The question is how next_
became 3. Line 13 wraps it only when next_ > data_.size(), so after the third push next_ is 3, which is not greater
than 3, and stays. The cause is the comparison: the index must wrap as soon as it reaches the size, with ==.
Every value of next_ from 0 to size() - 1 is valid, and 3 is the first one that is not.
Why a guard at the crash site is not a fix
The tempting change is to protect line 11: if (next_ < data_.size()) data_[next_] = reading;. It makes the
report disappear:
#pragma once
#include <cstddef>
#include <vector>
// The "fix" at the crash site: skip the write when next_ is out of range. ASan has nothing to report any more.
class RingBuffer {
public:
explicit RingBuffer(std::size_t capacity) : data_(capacity) {}
void push(int reading) {
if (next_ < data_.size()) data_[next_] = reading; // guard added where the crash was
++next_;
if (next_ > data_.size()) next_ = 0;
if (count_ < data_.size()) ++count_;
}
int newest() const { return data_[(next_ + data_.size() - 1) % data_.size()]; }
std::size_t count() const { return count_; }
private:
std::vector<int> data_;
std::size_t next_ = 0;
std::size_t count_ = 0;
}; from session import run
# The guard at the crash site, built with the same checks: the report is gone, the test still fails.
run("""
clang++ -std=c++23 -g -fsanitize=address,undefined -fno-omit-frame-pointer -DGUARDED ring_test.cpp -o ring_test && ./ring_test
""") Output
$ clang++ -std=c++23 -g -fsanitize=address,undefined -fno-omit-frame-pointer -DGUARDED ring_test.cpp -o ring_test && ./ring_test PASS newest of two readings FAIL newest after the buffer wraps PASS newest after a second lap [exit status 1]
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 ring_guard_session.py
The sanitizers are silent and the test still fails. The guard skips the write when next_ is 3, so the reading 24 is
lost without a trace, and the same happens on every lap. Memory is safe now, but the program is still wrong, and the
bug has moved somewhere much harder to see: a sensor log that silently drops every fourth reading. This is why the
method fixes causes. A guard at a crash site is only correct when the value really can be out of range there and
skipping is the right behaviour; then say so in a comment and test that case too.
Steps 4 to 7: the test, the fix, both compilers
The test already exists: the check newest after the buffer wraps fails because of exactly this bug, so it is the
regression test. The fix changes one comparison:
#pragma once
#include <cstddef>
#include <vector>
// The fix at the cause: next_ wraps as soon as it reaches the end, so it is always a valid index.
class RingBuffer {
public:
explicit RingBuffer(std::size_t capacity) : data_(capacity) {}
void push(int reading) {
data_[next_] = reading;
++next_;
if (next_ == data_.size()) next_ = 0; // was: next_ > data_.size()
if (count_ < data_.size()) ++count_;
}
int newest() const { return data_[(next_ + data_.size() - 1) % data_.size()]; }
std::size_t count() const { return count_; }
private:
std::vector<int> data_;
std::size_t next_ = 0;
std::size_t count_ = 0;
}; from session import run
# The buggy header with GCC at -O0, which finds the bug its own way, then the fixed header on both compilers.
run("""
g++-16 -std=c++23 -O0 ring_test.cpp -o ring_test && ./ring_test
clang++ -std=c++23 -g -fsanitize=address,undefined -fno-sanitize-recover=all -DFIXED ring_test.cpp -o ring_test && ./ring_test
g++-16 -std=c++23 -O0 -g -fsanitize=address,undefined -fno-sanitize-recover=all -DFIXED ring_test.cpp -o ring_test && ./ring_test
g++-16 -std=c++23 -O2 -g -fsanitize=address,undefined -fno-sanitize-recover=all -DFIXED ring_test.cpp -o ring_test && ./ring_test
""") Output
$ g++-16 -std=c++23 -O0 ring_test.cpp -o ring_test && ./ring_test PASS newest of two readings …/c++/16/bits/stl_vector.h:1253: constexpr std::vector< <template-parameter-1-1>, <template-parameter-1-2> >::reference std::vector< <template-parameter-1-1>, <template-parameter-1-2> >::operator[](size_type) [with _Tp = int; _Alloc = std::allocator<int>; reference = int&; size_type = long unsigned int]: Assertion '__n < this->size()' failed. Abort trap: 6 [exit status 134] $ clang++ -std=c++23 -g -fsanitize=address,undefined -fno-sanitize-recover=all -DFIXED ring_test.cpp -o ring_test && ./ring_test PASS newest of two readings PASS newest after the buffer wraps PASS newest after a second lap $ g++-16 -std=c++23 -O0 -g -fsanitize=address,undefined -fno-sanitize-recover=all -DFIXED ring_test.cpp -o ring_test && ./ring_test PASS newest of two readings PASS newest after the buffer wraps PASS newest after a second lap $ g++-16 -std=c++23 -O2 -g -fsanitize=address,undefined -fno-sanitize-recover=all -DFIXED ring_test.cpp -o ring_test && ./ring_test PASS newest of two readings PASS newest after the buffer wraps PASS newest after a second lap
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 ring_fixed_session.py
The first run is the buggy buffer again, built by GCC without optimisation and without sanitizers. GCC’s standard
library checks operator[] by default in such builds, so it stops at the same line with its own message, before
any memory is corrupted: a different symptom of the same bug, which is why the method re-runs on both compilers.
Then the fixed buffer passes every check with Apple clang, and with GCC at -O0 and -O2, with no reports and with
-fno-sanitize-recover=all, so a UBSan report would have failed the run.
Keep ring_test.cpp and run it on every change; the build-system lessons later in this track show how CTest runs
such programs for you. A bug that comes back would now fail a check by name instead of corrupting memory quietly.
Your turn: ten broken programs
Each program below has a comment saying what it should do, and checks in main that print PASS or FAIL. Your
job, for each one:
- Build it with
clang++ -std=c++23 -g -fsanitize=address,undefined -fno-omit-frame-pointer hunt01_average.cpp -o hunt(org++), run it, and read the first report. The sessions below show what you should see on a Mac. - Find the cause and fix it. Do not change the checks: they are the regression tests.
- Done means: every check prints
PASS, there are no reports, and the same holds with-fno-sanitize-recover=allon both compilers.
All ten use the small helper check.h:
#pragma once
#include <print>
// A minimal test helper: prints PASS or FAIL for each check and counts the failures.
inline int failures = 0;
inline void check(bool ok, const char* what) {
std::println("{} {}", ok ? "PASS" : "FAIL", what);
if (!ok) ++failures;
} 1 average · hunts/hunt01_average.cpp
// Hunt 1: the average of a list of marks.
#include <vector>
#include "check.h"
double average(const std::vector<int>& marks) {
long long total = 0;
for (std::size_t i = 0; i <= marks.size(); ++i) total += marks[i];
return static_cast<double>(total) / marks.size();
}
int main() {
check(average({2, 4, 6}) == 4.0, "average of 2, 4 and 6 is 4");
check(average({7}) == 7.0, "average of one mark");
return failures;
} 2 negatives · hunts/hunt02_negatives.cpp
// Hunt 2: for every negative reading, append its absolute value at the end.
#include <vector>
#include "check.h"
void add_absolute_values(std::vector<int>& readings) {
for (int r : readings)
if (r < 0) readings.push_back(-r);
}
int main() {
std::vector<int> readings{3, -1, 4, -5};
add_absolute_values(readings);
check(readings == std::vector<int>{3, -1, 4, -5, 1, 5}, "two absolute values appended");
return failures;
} 3 counter · hunts/hunt03_counter.cpp
// Hunt 3: a function that makes a counter, a callable that returns 1, 2, 3, ... on each call.
#include <functional>
#include "check.h"
std::function<int()> make_counter() {
int count = 0;
return [&count] { return ++count; };
}
int main() {
auto next = make_counter();
int first = next();
int second = next();
check(first == 1 && second == 2, "counts 1, 2");
return failures;
} 4 grid · hunts/hunt04_grid.cpp
// Hunt 4: a grid of seats stored row by row in one vector; mark one seat as booked.
#include <vector>
#include "check.h"
struct Grid {
int rows, cols;
std::vector<char> cells;
Grid(int r, int c) : rows(r), cols(c), cells(r * c, '.') {}
char& at(int r, int c) { return cells[r * rows + c]; }
};
int main() {
Grid square(3, 3);
square.at(2, 2) = 'X';
check(square.cells[8] == 'X', "the last seat of a 3 x 3 grid");
Grid hall(5, 3); // 5 rows of 3 seats
hall.at(4, 2) = 'X';
check(hall.cells[14] == 'X', "the last seat of a 5 x 3 grid");
return failures;
} 5 copy · hunts/hunt05_copy.cpp
// Hunt 5: a small class that owns a buffer of scores; copying a report should give an independent copy.
#include <algorithm>
#include <cstddef>
#include "check.h"
class Scores {
public:
explicit Scores(std::size_t n) : size_(n), data_(new int[n]{}) {}
~Scores() { delete[] data_; }
int& operator[](std::size_t i) { return data_[i]; }
std::size_t size() const { return size_; }
private:
std::size_t size_;
int* data_;
};
int main() {
Scores term1(3);
term1[0] = 81;
{
Scores copy = term1;
copy[0] = 95;
}
check(term1[0] == 81, "changing the copy leaves the original alone");
return failures;
} from session import run
# Hunts 1 to 5, each built with AddressSanitizer and UBSan and run once: the start of each hunt.
# On macOS two ASan checks are off by default; hunt 3 needs detect_stack_use_after_return=1.
run("""
clang++ -std=c++23 -g -fsanitize=address,undefined -fno-omit-frame-pointer hunt01_average.cpp -o hunt && ./hunt
clang++ -std=c++23 -g -fsanitize=address,undefined -fno-omit-frame-pointer hunt02_negatives.cpp -o hunt && ./hunt
clang++ -std=c++23 -g -fsanitize=address,undefined -fno-omit-frame-pointer hunt03_counter.cpp -o hunt && ASAN_OPTIONS=detect_stack_use_after_return=1 ./hunt
clang++ -std=c++23 -g -fsanitize=address,undefined -fno-omit-frame-pointer hunt04_grid.cpp -o hunt && ./hunt
clang++ -std=c++23 -g -fsanitize=address,undefined -fno-omit-frame-pointer hunt05_copy.cpp -o hunt && ./hunt
""", max_lines=10) Output
$ clang++ -std=c++23 -g -fsanitize=address,undefined -fno-omit-frame-pointer hunt01_average.cpp -o hunt && ./hunt
==…==ERROR: AddressSanitizer: heap-buffer-overflow on address 0x…
READ of size 4 at 0x… thread T0
#0 in average(std::__1::vector<int, std::__1::allocator<int>> const&) hunt01_average.cpp:7
#1 in main hunt01_average.cpp:12
0x… is located 0 bytes after 12-byte region [0x…,0x…)
allocated by thread T0 here:
#10 in main hunt01_average.cpp:12
SUMMARY: AddressSanitizer: heap-buffer-overflow
[46 more lines of the report left out]
==…==ABORTING
[1 more lines]
[exit status 134]
$ clang++ -std=c++23 -g -fsanitize=address,undefined -fno-omit-frame-pointer hunt02_negatives.cpp -o hunt && ./hunt
==…==ERROR: AddressSanitizer: heap-use-after-free on address 0x…
READ of size 4 at 0x… thread T0
#0 in add_absolute_values(std::__1::vector<int, std::__1::allocator<int>>&) hunt02_negatives.cpp:6
#1 in main hunt02_negatives.cpp:12
0x… is located 8 bytes inside of 16-byte region [0x…,0x…)
freed by thread T0 here:
#11 in add_absolute_values(std::__1::vector<int, std::__1::allocator<int>>&) hunt02_negatives.cpp:7
#12 in main hunt02_negatives.cpp:12
previously allocated by thread T0 here:
#10 in main hunt02_negatives.cpp:11
[4 more lines]
[exit status 134]
$ clang++ -std=c++23 -g -fsanitize=address,undefined -fno-omit-frame-pointer hunt03_counter.cpp -o hunt && ASAN_OPTIONS=detect_stack_use_after_return=1 ./hunt
==…==ERROR: AddressSanitizer: stack-use-after-return on address 0x…
READ of size 4 at 0x… thread T0
#0 in make_counter()::$_0::operator()() const hunt03_counter.cpp:7
#7 in main hunt03_counter.cpp:12
Address 0x… is located in stack of thread T0 at offset 32 in frame
#0 in make_counter() hunt03_counter.cpp:5
SUMMARY: AddressSanitizer: stack-use-after-return
[45 more lines of the report left out]
==…==ABORTING
Abort trap: 6
[exit status 134]
$ clang++ -std=c++23 -g -fsanitize=address,undefined -fno-omit-frame-pointer hunt04_grid.cpp -o hunt && ./hunt
PASS the last seat of a 3 x 3 grid
==…==ERROR: AddressSanitizer: heap-buffer-overflow on address 0x…
WRITE of size 1 at 0x… thread T0
#0 in main hunt04_grid.cpp:18
0x… is located 7 bytes after 15-byte region [0x…,0x…)
allocated by thread T0 here:
#9 in Grid::Grid(int, int) hunt04_grid.cpp:8
#10 in Grid::Grid(int, int) hunt04_grid.cpp:8
#11 in main hunt04_grid.cpp:17
SUMMARY: AddressSanitizer: heap-buffer-overflow
[3 more lines]
[exit status 134]
$ clang++ -std=c++23 -g -fsanitize=address,undefined -fno-omit-frame-pointer hunt05_copy.cpp -o hunt && ./hunt
==…==ERROR: AddressSanitizer: heap-use-after-free on address 0x…
READ of size 4 at 0x… thread T0
#0 in main hunt05_copy.cpp:25
0x… is located 0 bytes inside of 12-byte region [0x…,0x…)
freed by thread T0 here:
#1 in Scores::~Scores() hunt05_copy.cpp:9
#2 in Scores::~Scores() hunt05_copy.cpp:9
#3 in main hunt05_copy.cpp:24
previously allocated by thread T0 here:
#1 in Scores::Scores(unsigned long) hunt05_copy.cpp:8
[6 more lines]
[exit status 134]
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 hunts_first_session.py
6 label · hunts/hunt06_label.cpp
// Hunt 6: copy a product code into a fixed-size label and print it.
#include <cstdio>
#include <cstring>
#include <string>
#include "check.h"
struct Label {
char code[8];
};
Label make_label(const std::string& code) {
Label label;
std::memcpy(label.code, code.data(), code.size());
return label;
}
int main() {
Label label = make_label("CPP-0042");
std::printf("label: %s\n", label.code);
check(std::strcmp(label.code, "CPP-0042") == 0, "the label holds the code");
return failures;
} 7 erase · hunts/hunt07_erase.cpp
// Hunt 7: remove the even numbers from a list.
#include <vector>
#include "check.h"
void remove_even(std::vector<int>& values) {
for (auto it = values.begin(); it != values.end(); ++it)
if (*it % 2 == 0) values.erase(it);
}
int main() {
std::vector<int> values{1, 2, 3, 4};
remove_even(values);
check(values == std::vector<int>{1, 3}, "1 and 3 remain");
return failures;
} 8 millis · hunts/hunt08_millis.cpp
// Hunt 8: convert days to milliseconds for a timer.
#include "check.h"
int to_millis(int days) {
int seconds = days * 24 * 60 * 60;
return seconds * 1000;
}
int main() {
check(to_millis(1) == 86'400'000, "one day");
check(to_millis(30) > 0, "thirty days is a positive duration");
return failures;
} 9 release · hunts/hunt09_release.cpp
// Hunt 9: allocate a buffer for a batch of readings, then release it.
#include "check.h"
int* make_batch(int n) {
int* batch = new int[n];
for (int i = 0; i < n; ++i) batch[i] = i * i;
return batch;
}
int main() {
int* batch = make_batch(4);
check(batch[3] == 9, "the fourth reading is 9");
delete batch;
return failures;
} 10 search · hunts/hunt10_search.cpp
// Hunt 10: find the position of the first mark that is at least a cut-off, in sorted marks.
#include <vector>
#include "check.h"
std::size_t first_at_least(const std::vector<int>& sorted, int cutoff) {
std::size_t lo = 0, hi = sorted.size();
while (lo < hi) {
std::size_t mid = (lo + hi) / 2;
if (sorted[mid] < cutoff) lo = mid + 1;
else hi = mid;
}
while (sorted[lo] < cutoff) ++lo; // "safety" loop
return lo;
}
int main() {
std::vector<int> marks{35, 48, 52, 67, 90};
check(first_at_least(marks, 50) == 2, "the first mark of at least 50 is at position 2");
check(first_at_least(marks, 95) == 5, "no mark of 95 or more: the end position, 5");
return failures;
} from session import run
# Hunts 6 to 10. Hunt 9 needs alloc_dealloc_mismatch=1, which macOS leaves off by default.
run("""
clang++ -std=c++23 -g -fsanitize=address,undefined -fno-omit-frame-pointer hunt06_label.cpp -o hunt && ./hunt
clang++ -std=c++23 -g -fsanitize=address,undefined -fno-omit-frame-pointer hunt07_erase.cpp -o hunt && ./hunt
clang++ -std=c++23 -g -fsanitize=address,undefined -fno-omit-frame-pointer hunt08_millis.cpp -o hunt && ./hunt
clang++ -std=c++23 -g -fsanitize=address,undefined -fno-omit-frame-pointer hunt09_release.cpp -o hunt && ASAN_OPTIONS=alloc_dealloc_mismatch=1 ./hunt
clang++ -std=c++23 -g -fsanitize=address,undefined -fno-omit-frame-pointer hunt10_search.cpp -o hunt && ./hunt
""", max_lines=10) Output
$ clang++ -std=c++23 -g -fsanitize=address,undefined -fno-omit-frame-pointer hunt06_label.cpp -o hunt && ./hunt
==…==ERROR: AddressSanitizer: stack-buffer-overflow on address 0x…
READ of size 14 at 0x… thread T0
#2 in main hunt06_label.cpp:19
Address 0x… is located in stack of thread T0 at offset 40 in frame
#0 in main hunt06_label.cpp:17
SUMMARY: AddressSanitizer: stack-buffer-overflow
[42 more lines of the report left out]
==…==ABORTING
Abort trap: 6
[exit status 134]
$ clang++ -std=c++23 -g -fsanitize=address,undefined -fno-omit-frame-pointer hunt07_erase.cpp -o hunt && ./hunt
==…==ERROR: AddressSanitizer: container-overflow on address 0x…
READ of size 4 at 0x… thread T0
#0 in remove_even(std::__1::vector<int, std::__1::allocator<int>>&) hunt07_erase.cpp:7
#1 in main hunt07_erase.cpp:12
0x… is located 12 bytes inside of 16-byte region [0x…,0x…)
allocated by thread T0 here:
#10 in main hunt07_erase.cpp:11
SUMMARY: AddressSanitizer: container-overflow
[48 more lines of the report left out]
==…==ABORTING
[1 more lines]
[exit status 134]
$ clang++ -std=c++23 -g -fsanitize=address,undefined -fno-omit-frame-pointer hunt08_millis.cpp -o hunt && ./hunt
PASS one day
hunt08_millis.cpp:6:20: runtime error: signed integer overflow: 2592000 * 1000 cannot be represented in type 'int'
SUMMARY: UndefinedBehaviorSanitizer: undefined-behavior hunt08_millis.cpp:6:20
FAIL thirty days is a positive duration
[exit status 1]
$ clang++ -std=c++23 -g -fsanitize=address,undefined -fno-omit-frame-pointer hunt09_release.cpp -o hunt && ASAN_OPTIONS=alloc_dealloc_mismatch=1 ./hunt
PASS the fourth reading is 9
==…==ERROR: AddressSanitizer: alloc-dealloc-mismatch (operator new [] vs operator delete) on 0x…
#1 in main hunt09_release.cpp:13
0x… is located 0 bytes inside of 16-byte region [0x…,0x…)
allocated by thread T0 here:
#1 in make_batch(int) hunt09_release.cpp:5
#2 in main hunt09_release.cpp:11
SUMMARY: AddressSanitizer: alloc-dealloc-mismatch
[8 more lines of the report left out]
==…==ABORTING
[1 more lines]
[exit status 134]
$ clang++ -std=c++23 -g -fsanitize=address,undefined -fno-omit-frame-pointer hunt10_search.cpp -o hunt && ./hunt
PASS the first mark of at least 50 is at position 2
==…==ERROR: AddressSanitizer: heap-buffer-overflow on address 0x…
READ of size 4 at 0x… thread T0
#0 in first_at_least(std::__1::vector<int, std::__1::allocator<int>> const&, int) hunt10_search.cpp:12
#1 in main hunt10_search.cpp:19
0x… is located 0 bytes after 20-byte region [0x…,0x…)
allocated by thread T0 here:
#10 in main hunt10_search.cpp:17
SUMMARY: AddressSanitizer: heap-buffer-overflow
[46 more lines of the report left out]
[2 more lines]
[exit status 134]
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 hunts_second_session.py
Two of the checks are off by default on macOS, so their sessions turn them on: hunt 3 needs
ASAN_OPTIONS=detect_stack_use_after_return=1, and hunt 9 needs ASAN_OPTIONS=alloc_dealloc_mismatch=1; without
them both programs run to the end with no report. The reports are shortened to their key lines, and long ones are cut
after ten lines; your own run shows them in full.
Where to look
- 1 average: the report is a read 0 bytes after the vector’s memory. Which value of
ireads it? - 2 negatives: the memory was freed inside
push_back. What happens to a range-for loop’s iterators when the vector it walks reallocates? - 3 counter: the report names the frame of
make_counter. Does that frame still exist when the lambda runs? - 4 grid: the square grid passes and the 5 by 3 hall fails. Which of
rowsandcolsbelongs in the index? - 5 copy: the memory was freed by the destructor of
copy. How many objects own the same buffer afterScores copy = term1;? - 6 label:
printfread 14 bytes from an 8-byte array. What marks the end of a C string, and where is it? - 7 erase: what does
erasedo to the iterator you pass it, and what does the loop then do with it? - 8 millis: how many milliseconds are in 30 days, and what is the largest
int? - 9 release: which form of
deletematchesnew int[n]? - 10 search: what is
lowhen the cut-off is larger than every mark, and what does the “safety” loop then read?
Compare your fixes
Try each hunt before you read its line here.
- Loop with
i < marks.size(), or use a range-for over the marks; then decide what the average of no marks is. - Do not insert into a vector while a range-for walks it: loop over the original size by index, or collect the new values in a second vector and append it after the loop.
- Capture by value:
[count]() mutable { return ++count; }keeps its own copy, which lives inside the lambda. - Index with
r * cols + c. Square grids hid the bug because thererows == cols; test non-square shapes. - Let the class hold a
std::vector<int>instead of an owning raw pointer: the vector copies correctly, so the class needs no destructor or copy constructor at all (the rule of zero). - Reserve room for the terminating zero,
char code[9], and copy it too, or keep the code in astd::string. - Use the iterator that
erasereturns,it = values.erase(it);, and advance only when nothing was erased, or callstd::erase_if(values, [](int v) { return v % 2 == 0; });(C++20). - Compute in
std::int64_t, or usestd::chrono::milliseconds, whose representation has at least 45 bits. - Release with
delete[] batch;, or better, return astd::vector<int>and release nothing by hand. - Delete the “safety” loop: the binary search already stops at the right position, including
sorted.size(), which is a valid answer meaning “none”.std::ranges::lower_bounddoes the same search.
Check yourself
5 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.
Interview questions
Fresher · Why should you write the regression test before you fix a memory bug?
Because a test written first proves two things that a test written afterwards cannot. Seeing it fail shows that it really detects this bug; a test that would pass with or without the fix proves nothing. Seeing it pass after the fix shows that the fix removed the cause rather than the symptom. With memory bugs this matters more than usual, because the program often looks correct without sanitizers: in the ring buffer, the build without checks failed one check and never crashed. Keep the test in the suite and run it under the sanitizers, so that if a later change brings the bug back, a check fails by name instead of memory being corrupted quietly.
Key takeaways
- Reproduce under
-fsanitize=address,undefined -g -fno-omit-frame-pointer, and read the first report: later ones may only be consequences of it. - The report shows where memory was misused; the cause is whatever produced the bad index, pointer or size. Follow the value back.
- A guard at the crash site can silence the sanitizer and leave the program wrong, as the guarded ring buffer did.
- Write the test so that it fails before the fix, then keep it in the suite; run it with
-fno-sanitize-recover=allon both compilers, because each can show a different symptom. - Most of the ten hunts have the same cure: let the standard library own memory and track sizes, through
std::vector,std::string,std::erase_ifand lambdas that capture by value.
References
- AddressSanitizer (Clang documentation) (LLVM Project)
- UndefinedBehaviorSanitizer (Clang documentation) (LLVM Project)
- Program Instrumentation Options (GCC) (GNU Project)
- The GNU C++ Library Manual: Macros (_GLIBCXX_ASSERTIONS) (GNU Project)
- Working Draft, Programming Languages: C++ (N4950) (ISO/IEC JTC1/SC22/WG21)
- C++ Core Guidelines (Standard C++ Foundation)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress