← History

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.

CC++Zig

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++.

  1. 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.
  2. 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.
  3. No runtime ownership tracking. malloc/free are 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.
  4. 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

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