cswork / libraryOpen practice workspace

Articles

AddressSanitizer Interview Exercises: Use-After-Free, Buffer Overflows, and Diagnostic Limits

Read real AddressSanitizer reports, trace use-after-free and heap overflow to ownership and bounds errors, then test the repair and its limits.

The program crashes after a packet is released. A teammate points to the last read and says, “Move the read above free.” That may stop one failure, but it does not tell you who owns the packet, whether another path retains the pointer, or whether the writer can fit its terminator. An AddressSanitizer interview exercise tests that chain of reasoning: reproduce an invalid access, read the report as evidence, repair the lifetime or bounds contract, and explain what the tool still cannot guarantee.

The two cases below are original practice fixtures, not candidate reports or claims about any employer's interview. I compiled them on arm64 macOS with Apple clang 21.0.0 using the flags shown. The observed error classes and source-line relationships come from those local runs; addresses, stack frames, and precise runtime behavior may change with compiler, platform, optimization, and input. Clang's AddressSanitizer documentation is the primary source for tool behavior. The proposed API repairs are engineering choices for this fixture, not promises that ASan repairs code automatically. For adjacent ownership practice, start with Detect memory leaks in C++, then distinguish its leak scenario from the invalid accesses below.

Set up one fixture with two distinct faults

The packet owns eight allocated bytes. The uaf branch frees it and reads byte two; the overflow branch writes byte eight before freeing. That separation matters: ASan normally stops at its first detected error, so a single run containing both operations does not reliably demonstrate both diagnostics. In an interview, ask whether the interviewer wants the first crash diagnosed, a repaired patch, or a test strategy that exposes other paths after the first fix.

#include <cstdlib>
#include <cstring>

__attribute__((noinline)) char* make_packet() {
  char* p = static_cast<char*>(std::malloc(8));
  if (!p) std::abort();
  std::memcpy(p, "ABCDEFG", 8); // seven characters plus '\0'
  return p;
}

__attribute__((noinline)) int stale_read(char* p) {
  volatile char* observed = p;
  return observed[2];
}

__attribute__((noinline)) void append_marker(char* p) {
  volatile char* observed = p;
  observed[8] = '!';
}

int main(int argc, char** argv) {
  if (argc != 2) return 2;
  char* p = make_packet();
  if (std::strcmp(argv[1], "uaf") == 0) {
    std::free(p);
    return stale_read(p);
  }
  if (std::strcmp(argv[1], "overflow") == 0) {
    append_marker(p);
    std::free(p);
    return 0;
  }
  std::free(p);
  return 2;
}

Compile and link with the sanitizer enabled. Debug symbols and frame pointers make the report easier to read. Clang recommends -O1 or higher for realistic performance, -g for source information, and -fno-omit-frame-pointer for clearer traces. Keeping optimization modest also makes an interview fixture easier to map back to source. The volatile access and noinline attributes here make the deliberately invalid operations visible in this particular experiment; they are not a repair technique.

clang++ -O1 -g -fsanitize=address -fno-omit-frame-pointer \
  -fno-optimize-sibling-calls asan_fixture.cc -o asan_fixture
./asan_fixture uaf
./asan_fixture overflow

The second command must be a separate invocation after the first process exits. An unsanitized or partly instrumented build can give a different symptom, and “it didn't crash” is not an acceptable validation. Preserve the exact compiler command, binary version, input, and stderr with the issue report. If functions live in separate libraries, confirm the code being investigated was actually built with the instrumentation and the final executable links the ASan runtime; the Clang build guidance explicitly warns that the final link should use clang++, not ld directly.

Exercise 1: reconstruct the use-after-free timeline

For the uaf branch, the local report began with heap-use-after-free, READ of size 1, and stale_read(char*) in the local source. It then said the address was two bytes inside an eight-byte region, showed free from main, and showed the earlier malloc inside make_packet. Read these as a timeline, not as four unrelated stack traces.

Report evidenceWhat it establishes in this runWhat it does not establish
READ of size 1 in stale_readThe instrumented access that triggered the report read one byte.That stale_read owns the storage or chose when to free it.
2 bytes inside of 8-byte regionIndex two would be within the allocation's original bounds.That the object's lifetime still exists.
freed by in mainThis run released that allocation before the read.That every real-world caller uses the same ownership path.
previously allocated by in make_packetThe report can connect the stale access to an allocation site.That allocation alone is the faulty operation.

The violated rule is lifetime, not array bounds: offset two is spatially inside the allocation but temporally after free. A non-null pointer can still dangle. Its numerical address may continue to look plausible, and a normal build may even print the old character. Neither observation extends the object's lifetime. In a more complex report, the access stack tells you where the invalid use was detected; the allocation and free stacks often tell you where the ownership agreement broke. A useful answer states the full sequence aloud: allocated, released, then read through a stale alias.

For this fixture, a minimal correction reads the byte while the allocation remains owned, then frees it. A stronger design gives the packet one explicit owner and makes any reader's borrow shorter than that owner's lifetime. In C++, std::unique_ptr with an appropriate deleter can encode exclusive ownership for malloc storage, or std::vector<char> can own a resizable byte buffer. Merely setting p = nullptr after free changes one variable; another copied pointer may still dangle. std::shared_ptr is justified only when ownership is genuinely shared, and a reference-counted design must still handle cycles and synchronization requirements. The right interview move is to ask who may retain the packet and then make that contract visible in the API.

Consider a callback that stores p for later. Moving the immediate read before free makes this branch pass yet leaves the callback with a dangling pointer. Change the callback to copy the bytes, take ownership, or finish while the owner remains alive; which option is correct depends on the API's intended lifetime. That follow-up prevents a shallow answer that patches one source line while preserving the defect class.

Exercise 2: explain the one-byte heap overflow

The overflow branch produces heap-buffer-overflow, WRITE of size 1, and an access in append_marker. ASan says the address is zero bytes after the eight-byte region. An allocation of eight bytes has valid indices zero through seven. Index eight is the first byte past the end even though the write is only one byte. The packet already contains seven letters and a null terminator in those eight bytes. Appending a marker while retaining the terminator needs room for nine bytes, plus code that moves or writes the terminator intentionally.

This is a bounds and capacity failure, not the same failure as the prior branch. Here the block is still live at the point of the write. A candidate who calls both reports “memory corruption” without locating the allowed interval has missed the key distinction. Walk the interviewer through base, size, allowed half-open range [base, base + size), attempted address base + 8, and operation size one. The report's allocation stack confirms which block ASan associates with that address. It does not prove the business rule for how large packets should be; that rule comes from the code's input and output contract.

One correct fixture repair allocates nine bytes for seven letters, marker, and terminator, then writes marker at index seven and terminator at index eight. Another is to keep eight bytes and replace an existing character rather than append. A production API should carry capacity or use a container that tracks it instead of handing a bare char* to a writer that guesses. If an external length is involved, validate it before calculating length + 1; integer overflow in the size calculation can defeat an otherwise well-intended check. Also distinguish logical length from allocated capacity: enough allocated bytes do not imply the current string operation is semantically correct.

Try varying the example: if append_marker writes index seven, is it within allocation bounds? Yes, but it overwrites the terminator and may create a later string-reading bug. ASan may not flag that write because the address is valid. That is an excellent diagnostic-limit follow-up: a sanitizer reports certain invalid memory accesses, not every violation of a protocol or data-format invariant. The interview answer should name both the immediate memory safety repair and the intended packet representation.

Test the repair without claiming a proof of safety

Build the original and patched programs with the same compiler flags, run both fault branches separately, and assert the patched outputs and storage state. A useful small test matrix changes one contract at a time:

CaseOriginal expectationCorrected expectation
Read byte two after freeASan reports heap-use-after-free.Reader finishes before owner releases storage; no dangling alias is retained.
Write byte eight of an eight-byte allocationASan reports heap-buffer-overflow.Capacity and intended terminator placement are asserted; no invalid write.
Delayed callback keeps a raw pointerMay fail only when the callback finally runs.Callback owns a copy or an explicit lifetime-safe handle.
Empty, maximum-size, and one-past-maximum inputsMay expose different boundary paths.Each path checks its actual logical length and capacity.
Multiple fault paths in one test binaryFirst detected ASan error ends the normal run.Run independent inputs after each fix so later defects become observable.

For the local fixture, I observed both original reports under Apple clang 21.0.0. A small std::vector<char> repair owns the eight initial bytes, reads byte two while the vector is alive, and inserts the marker before the terminator so the vector grows to nine bytes. Both repaired branches exited successfully in the same instrumented build without an ASan report; this only validates those inputs. The delayed-callback and boundary rows remain test goals, not claims about a patched production program. A concrete validation plan would include a regression test that reproduces the old input, a unit test of the ownership rule, boundary tests at capacity minus one, capacity, and capacity plus one, and a sanitizer-enabled CI job that fails on a report. For concurrent ownership, add a schedule that makes release race with access; ASan is not a substitute for a data-race detector or a synchronization proof.

Read the report's first error and fix its cause before trying to interpret later fallout. Clang documents first-error termination as the default design. Symbolization may need llvm-symbolizer on the path or ASAN_SYMBOLIZER_PATH; if a report has raw addresses, missing function names do not make the underlying memory error disappear. Rebuild with symbols and preserve the unsymbolized report as evidence. On macOS, some optional checks differ from Linux; for example, Clang notes initialization-order checking is unsupported on macOS, and leak detection has platform-specific behavior. State the environment instead of copying a Linux-only recipe into every answer.

Where ASan's evidence stops

ASan instruments code and observes executions. A clean run says that this instrumented build, under these inputs and runtime settings, did not produce a detected ASan report. It does not prove that every path was executed, every dependency was instrumented, the allocator's logical contract was correct, or the program is memory-safe for all inputs. Undefined behavior may be optimized away before an instrumented access exists; untested callbacks and different schedules may hide a defect. Some bugs, such as a data race or an in-bounds but semantically invalid write, belong to other checks or reasoning. Clang's documentation describes specific detectable classes, options, and platform limitations; do not inflate that list into a general correctness guarantee.

ASan also changes the runtime environment. Clang documents extra real and stack memory use, substantial virtual-address mapping on 64-bit systems, no support for static linking of executables, and a runtime that is not intended for production security-sensitive binaries. That does not make it an optional curiosity: it is valuable in development and CI precisely because it turns many otherwise intermittent accesses into actionable reports. It does mean you should reproduce with the deployment build when diagnosing performance or compatibility and keep the sanitized build's scope explicit. An interview answer that says “ship ASan in production to catch everything” needs those qualifications.

Finish with the key separation: the access stack identifies the point of detection, the allocation/free stacks establish a particular lifetime history, and the application invariant determines the repair. If the report shows a freed block, fix ownership or borrowing. If it shows a write past the block, fix capacity and index calculations. Rerun the same trigger, add neighboring boundary and lifetime cases, and treat the absence of a report as evidence for the tested paths only.

Sources and further reading