How Heap Buffer Overflows Happen
Nearly every heap buffer overflow traces back to the same root shape: a write index or length is computed from a value the function trusts — a caller-supplied parameter, a value parsed from untrusted input, or the result of arithmetic that can overflow — and that computed value is used to write into a buffer without being checked against the buffer's actual allocated size first. The bug is rarely in the write itself; it's in the missing validation step immediately before it.
Reading an AddressSanitizer Heap-Buffer-Overflow Report
AddressSanitizer's report is structured to answer three questions immediately: what happened, where, and against what allocation. The first block states the access type and size — for example, "WRITE of size 1024" — and the faulting address, followed by the stack trace of the offending access. The second block identifies the nearby heap allocation: its size, and the stack trace of where it was allocated. Critically, ASan states whether the faulting address falls before or after that allocation's bounds — "0 bytes after an 8192-byte region" points directly at an off-by-one or a sizing mismatch, while a large offset usually indicates an index computed from the wrong unit (elements instead of bytes, or vice versa).
Case Study: GHSA-8f95-v3jq-cj86 in pymonocypher
GHSA-8f95-v3jq-cj86 is a heap buffer overflow in argon2i_32(), the Argon2i key-derivation function in the Monocypher cryptographic library, reached through exactly this shape. The function computes a block index from a caller-supplied nb_blocks cost parameter and writes a 1024-byte block directly to blocks[index]inside the caller's work area — without validating that index is within the bounds of the memory the caller actually allocated. A 21-line reproducer allocates a work area sized for 8 blocks, then supplies a cost configuration implying 64 blocks; AddressSanitizer catches the resulting out-of-bounds write immediately, reporting a WRITE of size 1024 zero bytes past an 8192-byte allocation — the off-by-a-full-region signature of a caller/callee sizing contract that was never enforced in code. The finding was confirmed in under two hours and patched within fifteen. Full code, the fixed version, and the complete AddressSanitizer output are in the GHSA-8f95-v3jq-cj86 writeup.
From Report to Fix
The fix for this class of bug is almost always the same shape: validate the computed index or size against the actual allocation before using it, and fail explicitly — reject the input or abort — rather than trusting the caller's arithmetic. In the pymonocypher case, the patched argon2i_32() clamps nb_blocks to the Argon2 minimum segment size and adds an explicit bound check before every write, returning instead of writing past the allocation. This is a narrower, more defensive contract than the original — the function no longer assumes the caller sized the buffer correctly; it verifies.
Finding Heap Buffer Overflows Systematically
Coverage-guided fuzzing is the highest-yield technique for this bug class, because heap buffer overflows are triggered by specific numeric edge cases — an index one past the boundary, a size calculation that overflows a fixed-width integer — that a fuzzer's mutation strategy finds far faster than manual code review. Running the fuzzer against a sanitizer-instrumented build is not optional: without ASan, most heap buffer overflow writes silently corrupt adjacent memory without crashing immediately, and the program may run for a long time before an unrelated, confusing crash surfaces far from the actual root cause.