Chapter 12 · Part III · Software & the web

Memory safety & buffer overflows

A program's memory is one long row of numbered bytes, and C and C++ let code write to any of them. Nothing stops a copy from running past the end of the space it was given, or a pointer from being used after its memory was handed to someone else. Those two mistakes, and their cousins, are behind a large share of the most serious security bugs ever found. This chapter shows how they happen, what the layers of mitigation actually stop, and why the industry is now moving to languages that make the mistakes impossible.

#5rank of CWE-787, out-of-bounds write, in the 2025 CWE Top 25. Use-after-free is #7 and out-of-bounds read is #8.
about 70%of Chromium's high and critical severity security bugs are memory-safety problems (912 bugs since 2015, per the Chromium project); half of those are use-after-free
76% → 24%memory-safety share of Android vulnerabilities, 2019 to 2024, as new code moved to memory-safe languages (Google)

Common mix-up: a stack canary does not prevent a buffer overflow. The overflow has already happened, and anything it overwrote before the function returns, such as a permission flag, has already been used. The canary is checked only on the way out, and all it can do then is stop the program. Mitigations turn takeovers into crashes; only fixing the bug removes it.

No networking background needed. This chapter lives inside one program's memory. It helps to know that a program is a list of instructions working on numbers, and that text is stored as one number per character.

The ground floor

Memory is a row of numbered boxes

To a running program, memory is a very long row of boxes, each holding one byte (a number from 0 to 255), each with a number of its own called its address. A variable is just a name the programmer gave to a run of boxes. A pointer is a variable that holds an address: "the thing I mean lives at box 4,096". Everything else in this chapter follows from one fact: in C and C++, a pointer is only a number, and the language does not check that the box it points to is one you are entitled to touch.

Two regions matter most. The stack holds the local variables of functions that are running right now. Each call gets a stack frame: its local variables, plus bookkeeping the processor needs to get back to the caller, most importantly the return address, the place in the code to continue from when this function finishes. Frames are pushed when a function is called and popped when it returns, so they are fast and tidy. The heap holds memory requested at run time with malloc or new, which lives until the program explicitly gives it back with free or delete. The heap is where objects that outlive a single function call go: a user's session, a decoded image, a parsed web page.

A language is memory-safe when the program cannot reach memory it has no right to, whatever input it receives. That breaks into two promises. Spatial safety: every access stays inside the bounds of the object it is meant for, so writing item 9 of an 8-item array is caught. Temporal safety: every access happens while the object is still alive, so memory that was freed cannot be read or written through an old pointer. C and C++ make neither promise. The programmer is expected to keep both by hand, every time, in every line, for the life of the codebase.

Diagram of a process's memory: code and data at low addresses, the heap above them growing upward, free space, and the stack at high addresses growing downward, with one stack frame enlarged. stack grows down ↓ unused heap grows up ↑ code + globals read-only / fixed high addresses low addresses return address saved frame ptr canary (if on) is_admin buf[8] one stack frame a copy into buf writes toward higher addresses ↑
A simplified process layout. The stack grows toward lower addresses, but a copy into a buffer writes toward higher ones, which is exactly where the frame keeps the things that matter: other locals, the saved frame pointer and the return address. Real layouts vary by compiler, processor and operating system.
Go deeper: why C doesn't just check

C was designed in the early 1970s to write an operating system on small machines, and its arrays are deliberately thin: an array's name turns into a pointer to its first element the moment you pass it anywhere, and the length is not carried along. A function that receives char *s has no idea whether s points at 8 bytes or 8 million. Checking would need the length, which the language threw away.

That is why so much of C's string handling relies on a terminator: a zero byte marks the end of text. Functions such as strcpy copy until they meet that zero, trusting that the destination is large enough. When the input is longer than the destination, they keep going.

C++ added containers like std::vector and std::string that know their size, but the fast element access v[i] is still unchecked (the checked version is v.at(i)), raw pointers and iterators can outlive what they point at, and every C library underneath is still C. Hardened modes such as libstdc++'s _GLIBCXX_ASSERTIONS turn on some of those checks, at a small cost.

CWE-787 · CWE-121 · CWE-120

Writing past the end

Here is the shape of the classic bug, in a function that greets a user. It has an 8-byte buffer for the name and a flag that says whether this user is an administrator. The name comes from outside: a form field, a network message, a file.

/* illustrative only: an unchecked copy */
void greet(const char *name) {
    int  is_admin = 0;
    char buf[8];
    /* copies until the zero byte,
       however far away that is */
    strcpy(buf, name);
    if (is_admin) { show_admin_menu(); }
    printf("Hello, %s\n", buf);
}

strcpy copies the name and its terminating zero byte, one byte after another, into buf and onward. A 7-letter name plus its terminator fills the 8 boxes exactly. A 12-letter name keeps writing into whatever the compiler placed next. In the stylised frame below, that is the is_admin flag; keep going and you reach the saved frame pointer and then the return address. Nothing in the program notices, because to the processor these are just more boxes.

Three different things can now go wrong, and they are worth keeping apart because different defenses address each one. Overwriting another variable changes the program's decisions without crashing it: a non-zero byte in is_admin means "true". Overwriting the return address means that when the function finishes, the processor jumps to whatever number is now stored there, usually crashing, and in the worst case going somewhere an attacker chose. Overwriting data in the caller's frame breaks code that isn't even running yet. Try it: type a name, run the copy, and watch the bytes land.

Instrument 1 · Stack frame visualiser

A teaching model of one 32-byte frame: no real addresses, no machine code. Cells show each byte in hexadecimal with its character underneath. Bytes written inside buf are tinted blue; bytes written outside it get a red-purple border and a "!" mark.

Bytes written0
Past the end of buf0
is_admin0 (false)
Canaryoff
Return addressintact
Ready

    Notice the "exactly 8" preset. Eight letters fit in eight boxes, but strcpy also writes the terminating zero, so the ninth byte lands in the neighbour. In this frame the neighbour was already zero, so nothing visibly changes. That is how off-by-one errors hide for years: the bug is real, the corruption is real, and the test suite passes because the byte it overwrote happened to hold the same value.

    The fix is to never copy without knowing both lengths. Check before you write, or use an API that takes the destination size and refuses or truncates:

    /* fixed: check the length first */
    void greet(const char *name) {
        int  is_admin = 0;
        char buf[8];
        size_t n = strlen(name);
        if (n >= sizeof buf) {
            reject("name too long");
            return;
        }
        memcpy(buf, name, n + 1);
        /* or, if cutting the name short is fine:
           snprintf(buf, sizeof buf, "%s", name); */
        ...
    }
    Go deeper: stack, heap and "classic" overflows in the CWE catalogue

    MITRE's Common Weakness Enumeration names these precisely. CWE-787, Out-of-bounds Write, is the parent: any write outside the intended buffer, before its start or after its end. Its children include CWE-121, Stack-based Buffer Overflow, where the buffer is a local variable as in the greeting above, and CWE-122, Heap-based Buffer Overflow, where it was allocated at run time and the neighbours are other heap objects or the allocator's own bookkeeping. CWE-120, Buffer Copy without Checking Size of Input, the "classic buffer overflow", names the cause rather than the location: a copy that never compared the input's size to the destination's.

    All four appear in the 2025 CWE Top 25: CWE-787 at #5, CWE-120 at #11, CWE-121 at #14 and CWE-122 at #16. Counting use-after-free (#7), out-of-bounds read (#8) and NULL pointer dereference (#13), seven of the twenty-five entries are memory-safety weaknesses. The Top 25 is computed from the CVE records of the previous two years, scored by how often each weakness appears and how severe those vulnerabilities were.

    Heap overflows look different from stack ones in the details but not in kind. There is no return address next door; instead there is another object, perhaps one that holds a function pointer or a length field, or the allocator's own metadata. Corrupting those changes what the program does later, which is why hardened allocators check their metadata and separate objects of different kinds.

    CWE-125

    Reading past the end: the over-read

    Reads can be as dangerous as writes. An out-of-bounds read doesn't change anything, so there is no crash and nothing for a canary to notice. It simply hands back whatever happened to sit next to the buffer: another user's request, a password still in memory from a moment ago, a private key.

    The most famous example is Heartbleed, CVE-2014-0160 in OpenSSL. TLS has a small "heartbeat" message: the sender includes some bytes and says how many it sent, and the receiver echoes them back to prove the connection is alive. OpenSSL 1.0.1 through 1.0.1f believed the stated length without comparing it to the number of bytes that had actually arrived. Ask for a 4-byte echo while claiming 40, and the reply contained the 4 bytes plus the next 36 bytes of the server's memory. The OpenSSL advisory put the limit at up to 64 KB per message, and the NVD record notes it was demonstrated reading private keys. The fix was one comparison, the one RFC 6520 already required: if the stated length is too large, discard the message silently.

    The echo below works the same way on a tiny made-up memory row. The neighbours are invented and labelled as such.

    Instrument 2 · The over-read echo

    Reply
    Bytes received4
    Bytes echoed4
    Leaked0
    Go deeper: why no mitigation saw Heartbleed coming

    Every mitigation in the section below is designed to stop an attacker from taking control of what the program does next: run injected code, return to a chosen address, call a function through a corrupted pointer. An over-read takes control of nothing. The program runs exactly as written, copies a few kilobytes too many, and sends them. Canaries are untouched, no code is executed from data, no address needs to be guessed, every jump is legitimate. Only a bounds check, from the programmer, the language, or a sanitizer during testing, stops it.

    The pattern to look for in code review is any length that came from the other side of a trust boundary and is then used to read, copy or allocate. The defense is always the same: compare it against what you actually have before you use it.

    CWE-416 · temporal safety

    The key that still opens the box

    The heap has a different failure. When a program frees an object, the memory goes back to the allocator, the library that hands out heap memory, which will give the same bytes to the next request of a suitable size. Many fast allocators reuse the most recently freed block first, because it is likely still in the processor's cache. Any pointer that still holds the old address is now a dangling pointer. Use it, and you are reading or writing an object that belongs to someone else.

    In C, freeing memory does nothing to the pointers that point at it. A program with a session object referenced from three places has to make sure that all three forget it at the moment it is freed, through every error path and every callback. Miss one, and the code that later uses it believes it is reading the old session, while actually reading whatever the allocator put there since. If an attacker can influence what gets allocated into that slot, they can choose what the stale pointer "sees": a role field that says admin, or a function pointer that now points somewhere else.

    Chromium's security team reports that half of its memory-safety bugs are use-after-free. They are hard to find by reading code, because the free and the use are often far apart, in different functions, triggered by different events.

    Instrument 3 · Use-after-free heap toy

    Six heap slots and a real (tiny) allocator: freed slots go on a last-in, first-out free list, so the next allocation reuses the slot freed most recently. Pointers p1, p2 … are your program's variables. Try the classic sequence, then switch on the defenses and run it again.

      /* C: the free is legal, the later use
         is not, and nothing stops it */
      struct session *s = session_new("alice");
      /* a second pointer to the same object */
      cache_put(cache, s);
      /* the cache still holds it after this */
      session_free(s);
      ...
      check_role(cache_get(cache, "alice"));
      // Rust: the compiler tracks ownership
      let s = Session::new("alice");
      drop(s);   // ownership given up
      println!("{}", s.role);
      // error[E0382]: borrow of moved value
      // the program is never built
      Go deeper: quarantine, ownership and garbage collection

      Quarantine delays reuse: freed blocks wait in a queue before going back on the free list, so a dangling pointer is much less likely to land in a fresh object. Poisoning overwrites freed memory with a recognizable pattern, so a stale read produces nonsense instead of another user's data. AddressSanitizer uses both, and reports any access to a quarantined block as a heap-use-after-free with the stack traces of the allocation, the free and the bad use. Hardened production allocators use cheaper versions of the same ideas, plus separating objects by size and type so that a stale pointer to a session can't land on attacker-controlled text. These raise the cost; they are not a guarantee, because quarantine is finite.

      Memory-safe languages close the hole completely, in two different ways. Java, C#, Go and Swift free memory only when nothing refers to it any more (garbage collection or automatic reference counting), so a reference can never dangle. Rust has no garbage collector; instead its compiler enforces ownership: every value has exactly one owner, references may not outlive the owner, and using a value after it has been moved or dropped is a compile-time error. The cost is paid at build time, not at run time.

      Rust and C# both have an unsafe escape hatch for the places that genuinely need raw pointers, such as talking to hardware or C libraries. The point is that those places become small, marked and reviewable, instead of being every line of the program.

      CWE-190

      When the arithmetic wraps

      Some memory bugs start as arithmetic bugs. A fixed-width integer has a maximum: a 32-bit unsigned number holds 0 to 4,294,967,295. Add one more and it doesn't grow; it wraps around to zero, like a car's odometer. In C, unsigned arithmetic is defined to wrap this way, silently.

      Now picture code that reads a count from a file, multiplies by the size of each item to decide how much memory to ask for, and then loops count times writing items. If the multiplication wraps, the allocation is tiny while the loop is still enormous, and the loop writes far past the end of the block. The integer overflow (CWE-190) is the cause; the heap out-of-bounds write (CWE-787) is the effect. CWE-190 is not in the 2025 Top 25 on its own, but it is one of the most common ways the entries above it begin.

      allocated = (count × size) mod 2bits
      The number of bytes actually requested is the true product with everything above the top bit thrown away. When the product needs more bits than the variable has, "allocated" can come out smaller than a single item.

      Instrument 4 · Allocation size calculator

      True size needed–
      Bytes requested–
      Loop writes past end–
      Checked multiply–
      –
      /* illustrative: the product can wrap */
      uint32_t n = read_count(file);
      uint32_t bytes = n * sizeof(struct item);
      struct item *p = malloc(bytes);
      /* writes n items into a tiny block */
      for (uint32_t i = 0; i < n; i++)
          p[i] = read_item(file);
      /* fixed: refuse a product that won't fit */
      size_t bytes;
      bool too_big =
          __builtin_mul_overflow(n, sizeof *p, &bytes);
      if (too_big) return ERR_TOO_BIG;
      p = malloc(bytes);
      /* or calloc(n, sizeof *p), which checks
         the product in mainstream C libraries;
         in Rust: n.checked_mul(size) */
      Defense in depth

      Mitigations raise the cost, not the bar

      Since the late 1990s, compilers, processors and operating systems have added layers that assume memory bugs exist and try to keep them from becoming takeovers. Each one breaks a particular step that turning corruption into control would need. None of them removes the bug, and each has a known gap, which is why they are stacked.

      Stack canaries (MSVC /GS, on by default; GCC and Clang -fstack-protector-strong) put a secret value between a function's locals and its return address, and check it before returning. A linear overflow that reaches the return address must trample the canary first, so the check fails and the program stops. Microsoft's documentation describes it exactly: the "security cookie" is computed once at module load and, if it has changed, the process is terminated. It catches nothing that is used before the return, and it is defeated if the secret leaks.

      Non-executable memory (the processor's NX bit; Windows calls its use Data Execution Prevention) marks the stack and heap as data that the processor refuses to run as instructions. That largely ended the oldest technique, placing instructions in the overflowing input itself. It doesn't stop a redirect into code that already exists in the program, and attackers responded by stitching together fragments of existing code. That response, called return-oriented programming, is the reason the next two layers exist.

      Address space layout randomization (ASLR) loads the program, its libraries, stack and heap at different random addresses each run. A redirect needs to know where to go; with ASLR the attacker must first find out, which usually needs a second bug that leaks an address. It raises the cost; an information leak lowers it again.

      Control-flow integrity (CFI; Clang's -fsanitize=cfi, Microsoft's Control Flow Guard) checks, before every indirect call, that the target is a function the program could legitimately call from there. Shadow stacks (Intel CET, enabled on Windows with /CETCOMPAT) keep a second, hardware-protected copy of every return address and stop the program if the two disagree on return. Together they cover the "forward" jumps (calls through pointers) and the "backward" ones (returns).

      Hardened allocators check heap metadata for corruption, randomize where objects go, quarantine freed blocks and separate objects by type, making heap overflows and use-after-free less predictable.

      What none of them touches is a data-only attack: corrupt a flag, a length, a user ID, and let the program's own legitimate code do the rest. And none of them touches an over-read. The matrix below makes this concrete. Pick a bug and a set of defenses, and see which step each one breaks.

      Instrument 5 · Mitigation matrix

      Steps are described conceptually, at the level of a defender's threat model. Green: this step is stopped. Orange: harder, needs another bug or more luck. Red-purple: nothing enabled stands in the way.

        –
        MitigationBreaks this stepDoes not stop
        Stack canaryAn overflow reaching the return address goes undetectedCorruption of locals used before return; heap bugs; over-reads; a leaked canary
        NX / DEPRunning instructions placed in dataRedirecting into code that already exists
        ASLRKnowing where anything isAttacks that also have an address leak; data-only attacks
        CFIIndirect calls to unexpected targetsReturns (that's the shadow stack's job); calls to another valid target; data-only attacks
        Shadow stackReturns to an address other than the callerForward-edge calls; data-only attacks; over-reads
        Hardened allocatorPredictable reuse and metadata corruption on the heapStack bugs; any reuse after the quarantine drains
        Bounds + lifetime checksThe out-of-bounds or stale access itselfLogic bugs that stay inside the rules (they are still bugs, but not memory corruption)
        Go deeper: the canary's zero byte and why locals get reordered

        GNU C library builds commonly make the lowest byte of the canary zero. A string copy stops at a zero byte, so an overflow driven by a string cannot write the canary back with its true value: either it stops before the canary, or it changes it. The rest of the canary is random per process.

        Toggle "Arrays placed above other locals" in Instrument 1 to see the other half of stack protection. Compilers that protect the stack also try to lay the frame out so that arrays sit right next to the guard and scalar variables such as flags and pointers sit below the arrays, where a forward overflow can't reach them. Microsoft's /GS documentation describes a related move for parameters: vulnerable parameters (pointers, references, buffers) are copied below the buffers so an overrun cannot rewrite them. Even so, an array overflowing into another array in the same frame, or a struct whose buffer is followed by a flag in the same struct, is still unprotected.

        GCC 14 added -fhardened, one switch that turns on a documented set of these options for production builds on GNU/Linux, including -D_FORTIFY_SOURCE=3, -fstack-protector-strong, -fstack-clash-protection, position-independent executables and, on x86, -fcf-protection=full for CET.

        Before the attacker does

        Finding the bugs: warnings, sanitizers, fuzzing

        If the code base is C or C++ and can't be rewritten tomorrow, the job is to find memory bugs before anyone else, and four tools do most of the work.

        Compiler warnings are free. Building with warnings on (-Wall -Wextra and friends) and treating them as errors catches a surprising number of dangerous calls, format-string mistakes and sign conversions. Fortified C library builds (_FORTIFY_SOURCE) swap calls like strcpy and memcpy for checked versions whenever the compiler knows the destination's size.

        AddressSanitizer (ASan, in Clang and GCC as -fsanitize=address) instruments every memory access in a test build. It surrounds each object with poisoned "red zones" and keeps a map of which bytes are valid, so the very first byte written past a buffer stops the program with a report naming the bug class, the line and the variable. It also quarantines freed heap memory to catch use-after-free. The Clang documentation puts its typical slowdown at 2x: too slow for production, ideal for tests. Its relatives catch other classes: MemorySanitizer for reads of uninitialized memory, UndefinedBehaviorSanitizer for signed overflow and similar, and KASAN for the Linux kernel.

        # illustrative AddressSanitizer report for the greet() bug (addresses elided)
        ==ERROR: AddressSanitizer: stack-buffer-overflow on address …
        WRITE of size 13 at … thread T0
            #0 in strcpy
            #1 in greet  greet.c:5
            #2 in main   greet.c:14
          This frame has 2 object(s):
            [32, 40) 'buf' <== Memory access at offset 40 overflows this variable

        Fuzzing supplies the inputs. A coverage-guided fuzzer such as libFuzzer or AFL++ calls one function of your program with generated bytes, millions of times, watching which code paths each input reaches. Inputs that reach new code are kept and mutated further; the rest are discarded. Combined with ASan, every out-of-bounds byte becomes a crash with a reproducible input attached. Google's OSS-Fuzz runs this continuously and for free for widely used open-source projects. Writing a fuzz target is often a few lines:

        // a fuzz target for the name parser (libFuzzer's entry point)
        extern "C" int LLVMFuzzerTestOneInput(const uint8_t *data, size_t size) {
            parse_display_name(data, size);   // the code under test
            return 0;
        }
        // build with: -fsanitize=fuzzer,address

        Static analysis reads the source without running it, following values across functions to flag copies whose sizes can't be proven safe, frees followed by uses, and unchecked arithmetic feeding allocations. It finds bugs in code that tests and fuzzers never reach, at the cost of false alarms that need triage.

        All four are worth doing, and all four share a limit: they find bugs one at a time, after the bugs were written. A large C++ code base with active development produces new memory bugs about as fast as these tools find old ones.

        Fix the class, not the instance

        Memory-safe languages

        The durable fix is to make the mistakes impossible to express. The first step is available in any language: carry the length with the pointer. A slice (Go, Rust), a Span<T> (C#), a std::span (C++20) is a pointer plus a length, travelling together, so every function that receives it knows where the end is and indexing can be checked against it.

        A bare pointer carries only an address; a slice carries the address and the length, so an access to index 9 of 8 can be refused. char *p addr length? unknown &[u8] / span addr len = 8 [9] 8 bytes of data refused
        A bare C pointer is only an address. A slice or span carries the length alongside it, so s[9] on an 8-byte slice can be refused with a clean error instead of reading or writing the neighbour.

        Memory-safe languages make that the only way to work. Java, C#, Go, Python and Swift check every array index and manage object lifetimes for you. Rust does the same without a garbage collector, by checking ownership and borrowing at compile time, which is why it is the usual choice where C and C++ were used for speed: operating system components, browsers, network services, firmware.

        The evidence that this works comes from projects that measured it. Google reports that memory-safety issues made up 76% of Android vulnerabilities in 2019 and 24% in 2024, as new code was written mostly in memory-safe languages, while the existing C++ stayed. Their key observation is that vulnerabilities concentrate in new and recently changed code, so writing new code in a safe language pays off long before old code is rewritten. The Chromium project reaches the same conclusion from its own bug data. Microsoft has publicly estimated that 70% of the vulnerabilities in its products over the past decade were memory-safety issues, as summarized by the Internet Security Research Group's memorysafety.org.

        Governments have turned this into guidance. In December 2023, CISA and partner agencies published The Case for Memory Safe Roadmaps, which asks software manufacturers to publish a plan for moving to memory-safe languages: which new projects start in one, which components are rewritten first (the ones that parse untrusted input are the usual candidates), and how the remaining C and C++ is hardened in the meantime with everything above.

        Where you areWhat to do firstWhat it buys
        New componentWrite it in a memory-safe languageThe whole class of bugs never enters
        Parser of untrusted inputRewrite in a safe language, behind a narrow interfaceThe most-attacked code becomes the safest
        Large existing C/C++Hardened build flags, ASan + fuzzing in CI, banned-function listFewer bugs reach users; the rest are harder to use
        Every C/C++ APIPass lengths with pointers; replace strcpy-style calls; checked arithmetic for sizesRemoves the most common causes, one call at a time
        Shipping productPublish a memory-safety roadmapCustomers can see the plan and hold you to it
        Go deeper: what "memory-safe" does not promise

        A memory-safe language removes memory corruption. It does not remove logic bugs, injection (Chapter 13), broken authentication, or vulnerable dependencies (Chapter 16). A Java program can still build an SQL query by string concatenation; a Rust program can still check the wrong permission.

        The boundaries matter too. Every safe language eventually calls into C: the operating system, a compression library, a graphics driver. Code inside unsafe blocks, foreign-function interfaces and native extensions is held to C's rules, and memory bugs found in "Rust programs" or "Python programs" usually live there. The gain is that this code is small, labelled, and can be reviewed and fuzzed with the attention it deserves.

        Finally, a panic or exception from a failed bounds check is still a failure. It is a clean, predictable one: the program stops with an error message instead of continuing with corrupted state. Availability matters, so handle errors; but a crash that can be reported is a vastly better outcome than a silent takeover.

        Where this comes from

        Sources