The Memory Bugs
A field guide to the six classic memory bugs of systems programming - and how C, C++, HolyC, Zig, Hare, Odin, and Forth either enable or fight them.
Every systems language that gives you direct control over memory hands you the same set of loaded guns. The six classic memory bugs - use-after-free, double-free, memory leaks, buffer overflows, dangling pointers, and uninitialized reads - are not exotic. They are the bread-and-butter of CVE databases, the reason browsers and kernels run fuzzers around the clock, and (by Microsoft's and Google's own published audits) roughly 70% of severe security bugs in large C/C++ codebases.
This article is a tour of those bugs: what each one is at the machine level, why C and C++ permit them, and how seven systems languages - C, C++, HolyC, Zig, Hare, Odin, and Forth - and their tooling either prevent them, detect them, or simply trust you not to write them. The thesis of a memory-focused site is worth stating up front: none of these languages are memory-safe in the way Rust or a GC'd language is. What differs is the size of the footgun and the quality of the flashlight you get to find it in the dark.
The cast of bugs
Before the languages, the bugs. Each is defined by the lifetime and bounds of an allocation - the two things manual memory management forces you to track by hand.
Use-after-free (UAF)
You free() a pointer, then dereference it anyway. The allocator may have already handed that block to someone else, so your read returns someone else's data and your write corrupts it. In an exploit, an attacker arranges to control what now lives at that address - UAF is one of the most weaponized bug classes in existence.
char *p = malloc(16);
strcpy(p, "hello");
free(p);
puts(p); /* use-after-free: p now points into freed memory */
The pointer p still holds the old address. C does nothing to invalidate it; free only tells the allocator the block is reusable. The bug is silent until the block is reused, which is exactly why UAF is so hard to reproduce.
Double-free
You call free() twice on the same pointer. Most allocators keep free blocks in intrusive linked lists, so a second free corrupts that metadata - and corrupted allocator metadata is a classic primitive for arbitrary-write exploits.
char *p = malloc(16);
free(p);
free(p); /* double-free: corrupts the allocator's free list */
A close cousin is freeing a pointer that was never malloc'd (e.g. a stack address or an interior pointer), which is equally undefined behavior.
Memory leaks
You allocate and never free. The program's resident memory grows until the OS kills it or the machine swaps to death. Leaks are the benign-looking member of the family - no crash, no corruption - but a long-running server that leaks a kilobyte per request is a time bomb.
char *p = malloc(1 << 20); /* 1 MiB */
/* ... early return on error, p never freed ... */
return -1; /* the megabyte is gone for the process lifetime */
The error path is the usual culprit: the happy path frees correctly, but one of the dozen goto fail / early-return branches forgets.
Buffer overflow (and underflow)
You read or write past the end (or before the start) of an allocation. A stack buffer overflow can overwrite the saved return address; a heap overflow smashes adjacent allocations or allocator metadata. This is the original sin of memory bugs - the mechanism behind the 1988 Morris Worm.
char buf[8];
strcpy(buf, "this string is far too long"); /* writes past buf[7] */
strcpy has no idea how big buf is, because in C an array decays to a bare pointer the moment you pass it - the length is not part of the value.
Dangling pointers
A pointer that outlives the object it points to. UAF is the heap version; the stack version is returning the address of a local:
int *bad(void) {
int x = 42;
return &x; /* x dies when bad() returns; the pointer dangles */
}
The memory isn't "freed" by an allocator - it's reclaimed by the next stack frame - but the effect is identical: dereferencing reads garbage or corrupts a live frame.
Uninitialized reads
You read a variable before writing it. In C, automatic (stack) and heap (malloc) storage are not zeroed; they contain whatever bits were last there. Conditional behavior on uninitialized memory is undefined and notoriously non-deterministic - the bug vanishes under a debugger because the debugger changes the stack layout.
int x;
if (x == 0) { /* x is indeterminate; this branch is undefined behavior */ }
char *p = malloc(16); /* contents are garbage, not zeros */
size_t n = strlen(p); /* reads until a chance NUL - overflow + uninit read */
Why C and C++ allow all of this
These bugs are not oversights. They are the direct, deliberate consequence of C's design goals, inherited by C++.
- Pointers are just integers. A
T *is an address with a type annotation and no provenance, lifetime, or bounds metadata attached. The hardware has no concept of "this address is no longer valid," so neither does C. - Arrays decay to pointers. Passing an array passes a pointer; the length is lost. Bounds checking is therefore impossible at a call boundary without a separate length argument that the language never enforces.
- No runtime ownership tracking.
malloc/freeare ordinary library functions. The compiler does not know who owns a block, when it is freed, or whether a pointer still points to a live object. There is no GC, no refcount, no borrow checker. - Undefined behavior is a contract, not a check. The standard says a UAF or overflow is UB - meaning the compiler may assume it never happens and optimize accordingly. This is what makes C fast and what makes these bugs invisible at compile time.
C++ adds opt-in safety machinery on top - RAII, smart pointers, std::vector, std::span - but it keeps every unsafe primitive C ever had, including raw new/delete and pointer arithmetic. Discipline is mandatory; the language will not save you from delete-then-use.
The good news: because these bugs are so well understood, the tooling to catch them is excellent. The bad news: tooling finds bugs on the paths you exercise. It is detection, not prevention.
The C/C++ flashlights: ASan, Valgrind, and friends
You cannot make C memory-safe, but you can make its bugs loud during testing.
AddressSanitizer (ASan)
A compiler instrumentation pass (-fsanitize=address, in both Clang and GCC) that surrounds every allocation with poisoned redzones and maintains shadow memory describing which bytes are addressable. Every load and store is checked against the shadow. It catches heap and stack buffer overflows, use-after-free, use-after-return, and double-free, with a precise stack trace at the moment of the bad access - at roughly 2x slowdown.
// gcc -fsanitize=address -g uaf.c && ./a.out
char *p = malloc(16);
free(p);
p[0] = 'x'; // ASan: heap-use-after-free, with alloc + free + access traces
Pair it with LeakSanitizer (bundled with ASan) for leak reports at exit, UndefinedBehaviorSanitizer (-fsanitize=undefined) for signed overflow, misaligned access, and more, and MemorySanitizer (Clang-only) specifically for uninitialized reads.
Valgrind / Memcheck
A dynamic binary-instrumentation tool that runs your unmodified binary on a synthetic CPU, tracking the definedness and addressability of every byte. It catches the same families plus uninitialized-value use, with no recompile required - at a heavier 10-50x slowdown. ASan is faster and catches stack overflows Valgrind misses; Valgrind needs no rebuild and tracks uninitialized values out of the box. Many shops run both.
Compile-time and hardening
-Wall -Wextra -Werrorplus-fanalyzer(GCC's static analyzer) and Clang'sscan-buildcatch a useful fraction before running._FORTIFY_SOURCE=2/3rewritesstrcpy/memcpy/sprintfto bounds-checked variants when sizes are known at compile time.- Stack canaries (
-fstack-protector-strong), ASLR, and non-executable stacks (-z noexecstack) are mitigations - they make exploitation harder, not the bugs absent.
C++'s structural defenses
C++ lets you design the bugs out rather than detect them:
#include <memory>
#include <vector>
void example() {
auto p = std::make_unique<int[]>(16); // freed automatically at scope exit
std::vector<char> buf(8); // knows its own size; .at() bounds-checks
// no delete, no leak on early return, no manual size tracking
} // unique_ptr's destructor runs here
RAII ties a resource's lifetime to a stack object's scope, so the destructor frees it deterministically on every exit path - including exceptions. std::unique_ptr/std::shared_ptr encode ownership in the type, automating delete (and, for shared_ptr, freeing only when the last owner dies). std::vector/std::array/std::span carry their length, killing the array-decay overflow. The Core Guidelines plus the GSL and -fsanitize round it out. The catch: none of this is mandatory, and a single raw new or dangling reference into a vector that later reallocated reopens every door.
HolyC: manual memory with no safety net, by design
HolyC, Terry A. Davis's C dialect and the native language of TempleOS (2005-2017), deserves an accurate, respectful treatment: it is a serious, internally consistent design, not a curiosity. It is a JIT-compiled hybrid of C and a little C++, and it is more exposed than C, entirely on purpose.
Memory is fully manual and GC-free. You allocate from a per-task data heap with MAlloc() and return memory with Free() (calling Free on NULL is allowed). MSize() reports the real allocated size, since large requests round up to a power of two. When a task dies, its entire code and data heaps are reclaimed automatically - a coarse-grained safety valve that turns many would-be leaks into bounded, per-task lifetimes.
// HolyC (highlighted as C). Runs in 64-bit ring 0, single address space.
U8 *p = MAlloc(16); // grab 16 bytes from this task's data heap
StrCpy(p, "hello");
Free(p); // return it; Free(NULL) is a no-op
// p now dangles - and there is NO memory protection to catch a stray write
The defining fact for a memory article: TempleOS has no memory protection at all. Everything runs in 64-bit ring 0 in one flat address space. A double-free, a wild pointer, or a buffer overflow can corrupt the entire system, not merely the offending program. There is no ASan, no Valgrind, no paging fault to politely segfault you - errors are unforgiving by design. Davis framed TempleOS as a modern Commodore 64: a machine simple enough that one person could understand all of it, where the price of total control is total responsibility. HolyC doesn't fight the memory bugs; it removes the referee and trusts the programmer completely.
Zig: explicit allocators and defer
Zig is the clearest modern answer to "what if C had no hidden behavior." There is no garbage collector and no RAII destructors - but the bugs are attacked from several angles at once.
Allocators are explicit. Any function that allocates takes an Allocator parameter, so allocation is always visible at the call site - no hidden heap traffic. defer (and errdefer) schedule cleanup at scope exit, keeping the free next to the alloc so leaks and missed-error-path frees are far less likely:
const std = @import("std");
fn load(allocator: std.mem.Allocator) !void {
const buf = try allocator.alloc(u8, 16);
defer allocator.free(buf); // runs on every exit, including the error below
_ = try std.posix.read(0, buf); // if this fails, defer still frees buf - no leak
}
The killer feature is the pluggable allocator. In debug/test builds you use std.heap.GeneralPurposeAllocator, which actively detects double-frees, use-after-free, and leaks at runtime and reports them with stack traces - a built-in mini-ASan, no external tool:
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer std.debug.assert(gpa.deinit() == .ok); // asserts no leaks at shutdown
const a = gpa.allocator();
const p = try a.create(u32);
a.destroy(p);
a.destroy(p); // GeneralPurposeAllocator: detected double free
Swap in an ArenaAllocator and you free an entire subsystem's memory in one deinit, sidestepping per-object lifetime bugs. For bounds: Zig slices ([]u8) carry a length, and indexing is bounds-checked in Debug/ReleaseSafe modes (panic, not corruption). Optionals (?T) make null an opt-in, checked state. Undefined variables must be written var x: u32 = undefined; explicitly, and reading undefined is caught in safe builds. Zig won't stop a determined UAF in ReleaseFast, but its defaults make the common bugs loud.
Hare: C-like manual memory with slices and defer
Hare is deliberately close to C - manual, GC-free, deterministic - but trims the sharpest edges. Allocation uses a built-in alloc expression and free; with libc linked, the heap goes straight through malloc, so behavior is predictable.
use io;
fn copy(in: io::handle) (void | io::error) = {
let buf: []u8 = alloc([0...], 4096)!; // allocate a 4096-byte slice (abort on OOM)
defer free(buf); // Go/Zig-style scope-exit cleanup
io::read(in, buf)?; // buf carries its own length
};
defer keeps allocation and release together, the same anti-leak discipline as Zig and Odin. The structural win is slices: []u8 bundles a pointer with a length, so buffer sizes travel with the data instead of being passed separately - closing a major class of the C overflow bug. Tagged unions ((i32 | f32), tested with is, extracted with as) make errors-as-values and variant handling explicit rather than relying on out-of-band sentinels. Hare keeps a tiny runtime with no hidden allocations, betting that predictability plus slices and defer is most of the safety C lacked, without a GC or borrow checker.
Odin: implicit context allocator, defer, and arenas
Odin shares the philosophy - manual, GC-free, no RAII, defer for cleanup running in reverse order - and adds an ergonomic twist that directly targets leaks: the implicit context.
Every Odin-convention procedure receives a hidden context carrying a context.allocator. So new, make, free, and delete use the in-scope allocator without you threading it through every call. Crucially, you can swap it:
import "core:mem"
do_work :: proc() {
arena: mem.Arena
buf: [64 * 1024]byte
mem.arena_init(&arena, buf[:])
context.allocator = mem.arena_allocator(&arena)
p := new(int) // allocated from the arena, not the heap
q := make([]int, 100) // also from the arena
_ = p; _ = q
// no individual frees needed - the whole arena is dropped at scope end
}
This is the arena/region strategy as a first-class idiom: instead of matching every new with a free (and risking double-frees, leaks, and UAF), you reset or drop the entire arena at once, and a whole subsystem's memory vanishes in O(1) with zero dangling-pointer bookkeeping. Odin also ships a built-in mem.Tracking_Allocator that records every allocation and reports leaks and bad frees, plus bounds-checked slices/arrays and distinct types (My_Int :: distinct int) to catch unit mix-ups the C type system would wave through. Errors are plain return values (or_return), so no exception can skip a defer.
Forth: you are the memory manager
Forth is the extreme case and a useful mirror: no type system, no GC, and almost no abstraction over the hardware. Cells are untyped machine words; memory management is the program.
Static allocation is the dictionary bump pointer. HERE is the address of the next free byte; ALLOT moves it forward; , (comma) appends a cell; CREATE/VARIABLE name a region:
CREATE buf 16 ALLOT \ reserve 16 bytes of data space named buf
buf 5 + 65 SWAP C! \ store byte 65 ('A') at buf+5 - no bounds check at all
buf @ \ fetch a cell from buf - reads whatever is there
Raw access is @ (fetch) and ! (store), with C@/C! for bytes - no bounds checks, no abstraction, any address goes. Dynamic memory is an optional word set: ALLOCATE, FREE, RESIZE give C-style malloc/free/realloc, and you pair every ALLOCATE with a matching FREE by hand:
16 ALLOCATE THROW \ ( -- addr ) allocate 16 bytes, throw on failure
DUP 5 + C@ DROP \ read uninitialized byte - contents are indeterminate
FREE THROW \ release; using the address after this is use-after-free
Every memory bug in this article is trivially expressible in Forth and nothing in the language stops you - there is no redzone, no shadow memory, no slice length, no destructor. What Forth offers instead is smallness and total transparency: a complete Forth has historically fit in a few kilobytes, so you can hold the entire memory model in your head. Like HolyC, its answer to the memory bugs is not a guardrail but radical simplicity and trust.
The spectrum, in one table
| Language | Memory model | Anti-leak tool | Bounds safety | Built-in bug detection |
|---|---|---|---|---|
| C | manual malloc/free |
none (discipline) | none (array decay) | external: ASan, Valgrind, MSan |
| C++ | manual + RAII + smart ptrs | RAII / unique_ptr |
vector/span/.at() |
external sanitizers; opt-in containers |
| HolyC | manual MAlloc/Free, per-task heaps |
task death frees heaps | none; no memory protection | none - ring 0, by design |
| Zig | explicit Allocator, no GC |
defer/errdefer |
slices, checked in safe modes | GeneralPurposeAllocator (UAF/double-free/leak) |
| Hare | manual alloc/free, no GC |
defer |
slices carry length | minimal; relies on slices + discipline |
| Odin | manual + context.allocator |
defer, arenas |
bounds-checked slices/arrays | Tracking_Allocator |
| Forth | dictionary bump + optional ALLOCATE |
none (manual FREE) |
none | none |
Takeaways
- The bugs are a property of manual memory, not of bad programmers. UAF, double-free, leaks, overflows, dangling pointers, and uninitialized reads all follow from tracking lifetime and bounds by hand.
- C and C++ permit them because the abstract machine has no lifetime or bounds metadata - pointers are integers, arrays decay, and UB is a license to optimize. C++ lets you design most of them out with RAII, smart pointers, and length-carrying containers, but never removes the unsafe primitives.
- The modern trio - Zig, Hare, Odin - converge on the same playbook: no GC, no RAII, but
deferfor cleanup, slices that carry their length, explicit/pluggable allocators, and arenas that retire whole regions at once. Zig and Odin go furthest with built-in allocators that detect UAF/double-free/leaks in debug builds. - HolyC and Forth take the opposite road - radical simplicity and total trust. HolyC strips even memory protection (ring 0, per-task heaps reclaimed on task death); Forth strips types and abstraction down to a bump pointer you move yourself. Both are coherent designs that hand you the whole machine and ask you to be worthy of it.
- For C/C++ today, the non-negotiable hygiene is tooling: compile with
-Wall -Wextra, test under-fsanitize=address,undefined(and MSan/Valgrind for uninitialized reads), and reach forunique_ptr/vector/spaninstead of raw pointers and arrays. Detection is not prevention - but on the paths you exercise, it turns silent corruption into a loud, fixable crash.