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

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.

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

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

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

Seven steps of a memory-bug hunt, from reproducing under sanitizers to keeping the test, with a loop back when a re-run still fails.1. Reproducebuild with -fsanitize=address,undefinedrun the failing case2. Read the first reportkind, line, region, origin3. Find the causewhy is the index, pointer or size wrong?4. Write a test that failsit must fail before the fix5. Fix the causenot the line that crashed6. Re-run everythingboth compilers, sanitizers on7. Keep the testin the suite that runs on every changeall pass, no reportsa new report or a failing test

The steps of a memory-bug hunt

Text description of the diagram

The diagram is a sequence of seven boxes from top to bottom.

  1. Reproduce: build with -fsanitize=address,undefined and run the case that fails.
  2. Read the first report: the kind of error, the line, the memory region and where it came from.
  3. Find the cause: ask why the index, pointer or size is wrong, which is often in a different place from the line that crashed.
  4. Write a test that fails: it must fail before the fix, so that it proves the fix later.
  5. Fix the cause, not the line that crashed.
  6. Re-run everything with both compilers and the sanitizers on.
  7. 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.

  1. Reproduce. Build with -fsanitize=address,undefined -g -fno-omit-frame-pointer and run the case that fails. If it only fails sometimes, make the input smaller and fixed until it fails every time.
  2. 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.
  3. 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?
  4. Write a test that fails. It must fail before the fix; a test that passes either way proves nothing.
  5. Fix the cause. Change the code that produced the bad value, not the line that used it.
  6. Re-run everything with both compilers and the sanitizers on, with -fno-sanitize-recover=all so that any UBSan report fails the run. Each compiler and library can expose a different symptom of the same bug.
  7. 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:

The ring buffer and its test

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

The test without checks, then with the sanitizers Python · ring_session.py
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:

ring_buffer_guarded.h C header · ring_buffer_guarded.h
#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;
};
The guarded version under the same checks Python · ring_guard_session.py
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:

ring_buffer_fixed.h C header · ring_buffer_fixed.h
#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;
};
GCC's own check, then the fixed buffer on both compilers Python · ring_fixed_session.py
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.

Text Diff Paste the output of a failing run and of a passing one, or of the GCC and Clang builds, to see exactly which lines differ.

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:

  1. Build it with clang++ -std=c++23 -g -fsanitize=address,undefined -fno-omit-frame-pointer hunt01_average.cpp -o hunt (or g++), run it, and read the first report. The sessions below show what you should see on a Mac.
  2. Find the cause and fix it. Do not change the checks: they are the regression tests.
  3. Done means: every check prints PASS, there are no reports, and the same holds with -fno-sanitize-recover=all on both compilers.

All ten use the small helper check.h:

check.h C header · hunts/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;
}
Hunts 1 to 5

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;
}
Hunts 1 to 5: the first report of each Python · hunts/hunts_first_session.py
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

Hunts 6 to 10

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;
}
Hunts 6 to 10: the first report of each Python · hunts/hunts_second_session.py
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 i reads 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 rows and cols belongs in the index?
  • 5 copy: the memory was freed by the destructor of copy. How many objects own the same buffer after Scores copy = term1;?
  • 6 label: printf read 14 bytes from an 8-byte array. What marks the end of a C string, and where is it?
  • 7 erase: what does erase do 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 delete matches new int[n]?
  • 10 search: what is lo when 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.

  1. Loop with i < marks.size(), or use a range-for over the marks; then decide what the average of no marks is.
  2. 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.
  3. Capture by value: [count]() mutable { return ++count; } keeps its own copy, which lives inside the lambda.
  4. Index with r * cols + c. Square grids hid the bug because there rows == cols; test non-square shapes.
  5. 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).
  6. Reserve room for the terminating zero, char code[9], and copy it too, or keep the code in a std::string.
  7. Use the iterator that erase returns, it = values.erase(it);, and advance only when nothing was erased, or call std::erase_if(values, [](int v) { return v % 2 == 0; }); (C++20).
  8. Compute in std::int64_t, or use std::chrono::milliseconds, whose representation has at least 45 bits.
  9. Release with delete[] batch;, or better, return a std::vector<int> and release nothing by hand.
  10. 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_bound does 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.

  1. Question 1 of 5 Put the steps of a memory-bug hunt in order.

    Give each item its position, from 1 (first).

    Show the answer to question 1

    Answer:

    1. Reproduce the bug in a build with AddressSanitizer and UBSan
    2. Read the first report
    3. Find the code that produced the bad index, pointer or size
    4. Write a test that fails because of the bug
    5. Fix the cause
    6. Re-run everything on both compilers with the sanitizers on

    Reproducing comes first, because a bug you cannot trigger cannot be shown fixed. The test comes before the fix, so that you see it fail; the re-run on both compilers comes last, because each can show a different symptom.

  2. Question 2 of 5 A sanitized run prints three reports. Which one do you work on first, and why?

    Choose one answer.

    Show the answer to question 2

    Answer: The first, because the later ones may only be consequences of the memory it corrupted

    Once memory has been misused, the rest of the run is undefined behaviour, and later reports can be side effects. AddressSanitizer usually stops at its first report for exactly this reason; UBSan, which goes on, can print several.

  3. Question 3 of 5 The report shows a write past the end of an array at the line data_[next_] = reading;. Which change fixes the cause?

    Choose one answer.

    Show the answer to question 3

    Answer: Correcting the code that lets next_ reach the array's size

    The line that writes is correct for every valid index; the bug is the value of next_. A guard or a bigger array makes the report go away and leaves the logic wrong: in the ring buffer, the guard silently dropped a reading.

  4. Question 4 of 5 Why add -fno-sanitize-recover=all to the build that runs the tests?

    Choose one answer.

    Show the answer to question 4

    Answer: So that the first UBSan report ends the program with a failing exit status instead of being printed and ignored

    Most UBSan checks recover by default: they print a report and the program goes on, often with exit status 0, so a test suite would pass. With recovery off, any report fails the test.

  5. Question 5 of 5 The fixed program passes under Apple clang with the sanitizers. Why run it with GCC as well?

    Choose one answer.

    Show the answer to question 5

    Answer: Each compiler and standard library can expose the same bug differently, so one clean run is weaker evidence

    In this lesson GCC's library stopped the buggy ring buffer at -O0 with its own assertion, while libc++ marks a vector's spare capacity, which finds container overflows. Two toolchains see more than one.

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=all on 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_if and lambdas that capture by value.

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.