Secure Coding in C · intermediate · ~12 min

Buffer overflow basics

## What you will learn - Define precisely what a buffer overflow is, and identify the moment in code where one can occur - Draw the stack layout of a function and explain how an overflow reaches the saved return address - Distinguish stack-based from heap-based overflows and describe what each one corrupts - Recognise the unbounded C functions (`strcpy`, `strcat`, `sprintf`, `gets`) that cause overflows, and swap in bounded replacements - Compile and run a toy overflow under AddressSanitizer, then read the report to locate the bug - Explain the main compiler and OS mitigations (stack canaries, ASLR, NX, FORTIFY_SOURCE) and why none of them replace bounds checking

Overview

The one-sentence idea

A buffer is a fixed-size block of memory — most often a char array like char name[16];. A buffer overflow happens when a program writes more bytes into that block than it can hold, so the extra bytes spill past the end and land on whatever memory sits next door.

Think of a buffer as a row of 16 mailboxes. If the mail carrier keeps stuffing letters after box 16, the letters do not vanish — they pile onto the neighbour's porch. In memory, that "neighbour" might be another variable, a saved CPU register, or the return address that tells the function where to jump back to when it finishes. Corrupt the right neighbour and you do not just get a wrong answer; you can hand control of the program to an attacker.

How this builds on what you know

In Arrays you learned that char buf[16] reserves exactly 16 contiguous bytes and that C does not check indexes for you — buf[20] is perfectly legal to write and quietly out of bounds. In strcpy and its dangers you saw that strcpy(dst, src) copies bytes until it hits a '\0', with no idea how big dst is. This lesson connects those two facts: an unbounded copy into a fixed array is a buffer overflow. Everything here is the natural, dangerous consequence of "no bounds checking" plus "no size argument."

Terminology, once the picture is clear

  • Buffer: a fixed-size region of memory used to hold data (a char[], an int[], a malloc'd block).
  • Overflow / out-of-bounds write: writing at an index at or beyond the buffer's capacity.
  • Stack: the region where local variables and function bookkeeping live.
  • Return address: the saved location a function returns to; the classic overflow target.
  • Undefined behaviour (UB): what the C standard says happens on an out-of-bounds write — anything at all, from "works by luck" to a crash to a security breach.

Why it matters

Buffer overflows are the foundational memory-safety bug — the one from which a huge share of serious security incidents descend. When you understand this bug, most of the defensive habits in the rest of this course stop feeling like arbitrary rules and start feeling obvious.

A short hall of infamy, all rooted in an overflow:

  • Morris worm (1988) — overflowed a buffer in the Unix fingerd daemon and spread across the early internet.
  • Code Red (2001) — overflowed a buffer in Microsoft IIS and infected hundreds of thousands of servers.
  • Heartbleed (2014) — an out-of-bounds read in OpenSSL that leaked private keys and passwords from memory.
  • Ongoing kernel CVEs — Linux, Windows, iOS, and Android still ship overflow fixes in most security updates.

Microsoft and Google have each reported that roughly 70% of the serious security vulnerabilities they fix are memory-safety issues, and overflows are the largest single family. The dollar and trust cost is enormous. Learning to spot and prevent overflows is one of the highest-leverage skills a C programmer can have.

Core concepts

Concept 1 — What actually happens on an overflow

Definition. A buffer overflow is any write to a buffer at an offset greater than or equal to its capacity. The C runtime does not stop you; the extra bytes overwrite adjacent memory.

Plain language. You promised the machine you needed 16 bytes. You then wrote 40. The first 16 fill the buffer; the remaining 24 keep going into whatever comes after it in memory. Nothing warns you at write time.

How it works internally. A write like buf[i] = c compiles to "store byte c at address buf + i." The CPU faithfully stores to that address whether or not i < 16. If buf + i happens to land on another live variable, that variable silently changes value. If it lands on saved control data, the program's behaviour changes.

When it is (not) a concern. Every unbounded write into a fixed buffer risks it. A bounded write that you have proven fits is safe. There is no "small overflow that doesn't matter" — a one-byte overflow (an off-by-one) has caused real exploits.

Pitfall. Forgetting the space for the terminating '\0'. A 16-byte buffer holds a 15-character string plus its null terminator. Writing 16 visible characters overflows by one byte.

char buf[16];  strcpy(buf, "HELLO, EVERYONE!!")  // 17 chars + '\0' = 18 bytes

 index:  0              15 | 16   17
        +----------------+ | +----+----+
 buf -> |H E L L O , ...E| | | ! | \0 |   <-- 2 bytes land PAST the buffer
        +----------------+ | +----+----+
         16 bytes capacity |  spilled into the neighbour

Knowledge check. A buffer is declared char code[8];. What is the longest string (visible characters, not counting '\0') you can safely strcpy into it? Why?

Concept 2 — The stack, and why the return address is the prize

Definition. The stack is a region of memory that grows and shrinks as functions are called and return. Each call gets a stack frame holding its local variables, saved registers, and the return address.

Plain language. When main calls greet, the CPU writes down "when greet finishes, come back to this spot in main." That note is the return address, and it sits in greet's frame right alongside greet's local buffer.

How it works internally. On common systems the stack grows toward lower addresses, but arrays are written toward higher addresses. So a local char buf[16] fills upward, straight toward the saved return address. Overflow buf far enough and you overwrite that address. When the function executes its ret instruction, the CPU jumps to whatever now sits there.

higher addresses
   +------------------------+
   |  return address        |  <-- overwrite this and you control the jump
   +------------------------+
   |  saved frame pointer   |
   +------------------------+
   |  char buf[16]          |  <-- copying too much writes UPWARD past here
   |  (fills this way  --->) |
   +------------------------+
lower addresses   (stack top)

When it matters. Any function with a local buffer that receives external data (a filename, a network packet, user input) is a candidate. Functions with only fixed, internal data are far lower risk.

Pitfall. Assuming "it returned normally, so the copy was fine." A corrupted return address may only bite on the ret, far from the line that caused it, which makes overflows maddening to debug without tools.

Knowledge check (explain in your own words). Why is overwriting the return address so much more dangerous than overwriting another int local variable next to the buffer?

Concept 3 — Stack vs heap overflows

Aspect Stack-based overflow Heap-based overflow
Buffer location Local variable inside a function malloc/calloc block
Typical victim Saved return address, saved registers Allocator metadata, adjacent heap objects
Classic outcome Redirect execution via ret Corrupt allocator, later crash or code execution
Detection tool ASan, stack canaries ASan, hardened allocators

Plain language. Both are "wrote past the end," but they hit different neighbours. On the stack the juicy neighbour is control data (the return address). On the heap the juicy neighbours are the bookkeeping the allocator keeps between blocks; corrupt it and a later free or malloc can be tricked into misbehaving.

Pitfall. Believing heap overflows are "safer" because there is no return address right there. They are simply exploited differently — and are often harder to spot.

Concept 4 — Unbounded functions are the root cause

Definition. An unbounded string function copies or appends until a terminator, with no destination-size parameter. Because it does not know how big the destination is, it cannot avoid overflowing it.

Plain language. strcpy, strcat, sprintf, and gets all trust that "the destination is big enough." The moment the input is longer than you assumed, you overflow. The fix is always the same shape: use a variant that takes a size and never writes past it.

Dangerous Why it overflows Bounded replacement
gets(buf) reads a whole line, any length fgets(buf, sizeof buf, stdin)
strcpy(d, s) copies until '\0' in s snprintf(d, sizeof d, "%s", s)
strcat(d, s) appends with no room check snprintf/strlcat with size
sprintf(d, ...) formats with no length cap snprintf(d, sizeof d, ...)

Pitfall. strncpy looks safe but does not guarantee a '\0' terminator when the source is exactly as long as the size. Prefer snprintf, or manually set the last byte to '\0' after strncpy.

Knowledge check (find the bug). char user[32]; strncpy(user, input, sizeof user); printf("%s", user); — even with strncpy, what can go wrong when strlen(input) >= 32?

Syntax notes

The overflow and its fix differ by exactly one thing: whether a size bounds the write.

#include <stdio.h>
#include <string.h>

void unsafe_copy(const char *src) {
    char buf[16];
    strcpy(buf, src);          // UNSAFE: no size limit; overflows if src > 15 chars
    printf("%s\n", buf);
}

void safe_copy(const char *src) {
    char buf[16];
    snprintf(buf, sizeof buf, "%s", src);  // BOUNDED: never writes > 16 bytes
    // snprintf always null-terminates within the buffer; long input is truncated
    printf("%s\n", buf);
}

Key points:

  • sizeof buf yields 16 here because buf is a real array in scope. Never pass sizeof on a pointer parameter — it gives the pointer size, not the buffer size.
  • snprintf returns how many bytes it would have written; a return >= sizeof buf tells you truncation happened, which you can detect and handle.

Lesson

A buffer overflow happens when a program writes past the end of an allocated buffer. The stray bytes overwrite other things: adjacent variables, return addresses, or function pointers. Historically, this gave attackers a way to run their own code.

Safety first

This course only demonstrates overflows in toy programs that you compile and run yourself.

Never test memory-corruption techniques against real software you do not own.

Modern toolchains add many mitigations (stack canaries, ASLR, NX). These reduce the risk, but they do not eliminate the underlying class of bug.

Code examples

#include <stdio.h>
#include <string.h>

/*
 * Two versions of the same task: copy a caller-supplied name into a
 * fixed 16-byte buffer and greet them. One overflows, one is safe.
 * Compile the demo with AddressSanitizer to SEE the overflow:
 *   cc -g -fsanitize=address bof.c -o bof && ./bof
 */

/* VULNERABLE: overflows whenever name is longer than 15 characters. */
void greet_unsafe(const char *name) {
    char buf[16];
    strcpy(buf, name);              /* no bound -> out-of-bounds write */
    printf("Unsafe greeting: Hi %s\n", buf);
}

/* SAFE: snprintf can never write past the 16th byte of buf. */
int greet_safe(const char *name) {
    char buf[16];
    int need = snprintf(buf, sizeof buf, "Hi %s", name);
    if (need < 0) {
        fprintf(stderr, "formatting error\n");
        return -1;
    }
    if ((size_t)need >= sizeof buf) {
        /* Not an error, but the name was truncated to fit. */
        fprintf(stderr, "note: greeting truncated (needed %d bytes)\n", need);
    }
    printf("Safe greeting:   %s\n", buf);
    return 0;
}

int main(void) {
    const char *shortname = "Ada";
    const char *longname  = "Grace Brewster Murray Hopper"; /* 28 chars */

    greet_safe(shortname);   /* fits, prints in full */
    greet_safe(longname);    /* too long, safely truncated with a note */

    /* The next call is a real buffer overflow. Under AddressSanitizer it
       aborts with a detailed report. Without ASan it is undefined behaviour:
       it may appear to work, print garbage, or crash. */
    greet_unsafe(longname);

    return 0;
}

What it does. greet_safe formats the greeting into buf but never writes past 16 bytes; a short name prints in full, a long one is truncated and reported. greet_unsafe uses strcpy, which blindly copies all 28 characters of the long name into a 16-byte buffer.

Expected output (compiled with -fsanitize=address). The two safe calls print first:

Safe greeting:   Hi Ada
note: greeting truncated (needed 31 bytes)
Safe greeting:   Hi Grace Brews

Then greet_unsafe triggers AddressSanitizer, which aborts the program with a report headed:

==NNNN==ERROR: AddressSanitizer: stack-buffer-overflow ...
WRITE of size 29 at 0x... thread T0
    #0 ... in greet_unsafe bof.c:13

Edge cases. With a name of exactly 13 characters, "Hi " + 13 = 16 bytes needed including '\0', so greet_safe truncates the last character — worth noticing. Without ASan, greet_unsafe may seem to run fine on some systems; that is undefined behaviour masquerading as success, not proof the code is correct.

Line by line

Walking the interesting path — the two greet_safe calls and the greet_unsafe call.

  1. main calls greet_safe("Ada"). Inside, buf[16] is uninitialised local storage.
  2. snprintf(buf, 16, "Hi %s", "Ada") writes H i (space) A d a \0 — 7 bytes — and returns 6 (bytes it wrote, excluding '\0'). Since 6 < 16, no truncation branch runs. buf now holds "Hi Ada".
  3. printf prints Safe greeting: Hi Ada.
  4. main calls greet_safe("Grace Brewster Murray Hopper"). snprintf wants to write "Hi " + 28 chars = 31 bytes plus '\0', so need becomes 31. But it only actually writes 15 characters plus a '\0' into buf, giving "Hi Grace Brews".
  5. Because need (31) >= sizeof buf (16), the truncation note prints, then the truncated greeting prints. Crucially, no memory outside buf was touched.
  6. main calls greet_unsafe(longname). Here is the trace of the damage:
step  action                          bytes written   in bounds?
----  ------------------------------  -------------   ----------
1     strcpy copies 'G'..15th char    buf[0..15]      yes (fills buffer)
2     strcpy keeps going, 16th char   buf[16]         NO  <-- overflow starts
3     ... through 28th char + '\0'    buf[16..28]     NO  (13 stray bytes)
  1. Those 13 stray bytes land on whatever the compiler placed above buf — saved registers, the frame pointer, or the return address. Under AddressSanitizer the first out-of-bounds byte (step 2) is caught immediately and the program aborts with a stack-buffer-overflow report pointing at line 13. Without ASan, execution continues on corrupted memory and the misbehaviour may only surface when greet_unsafe tries to return.

The contrast is the whole lesson: the safe path is bounded by sizeof buf and self-limits; the unsafe path is bounded only by the input's length, which the attacker controls.

Common mistakes

Mistake 1 — Using strcpy "because the input is usually short."

char path[64];
strcpy(path, argv[1]);   // WRONG: argv[1] can be any length

Why it is wrong: "usually short" is an assumption an attacker will deliberately break. Corrected:

if (snprintf(path, sizeof path, "%s", argv[1]) >= (int)sizeof path) {
    fprintf(stderr, "path too long\n");
    return 1;
}

Recognise it: any strcpy/strcat/sprintf whose source can come from outside the program is a red flag.

Mistake 2 — Trusting strncpy to null-terminate.

char name[8];
strncpy(name, input, sizeof name);   // WRONG when strlen(input) >= 8
printf("%s", name);                  // reads past the array: another overflow (read)

Why it is wrong: when the source is at least as long as the size, strncpy writes no '\0', so name is not a valid string and printf runs off the end. Corrected:

strncpy(name, input, sizeof name - 1);
name[sizeof name - 1] = '\0';        // force termination

Mistake 3 — sizeof on a pointer parameter.

void copy_in(char *dst, const char *src) {
    snprintf(dst, sizeof dst, "%s", src);  // WRONG: sizeof dst == 8 (pointer size)
}

Why it is wrong: inside the function dst is a pointer, so sizeof dst is 4 or 8, not the buffer's real capacity. Corrected: pass the size explicitly.

void copy_in(char *dst, size_t dst_sz, const char *src) {
    snprintf(dst, dst_sz, "%s", src);
}

Mistake 4 — Off-by-one on the terminator. Sizing a buffer for the visible characters but forgetting the '\0'. Always reserve length + 1 bytes for a C string.

Debugging tips

Compile-time help

  • Turn on warnings: -Wall -Wextra. GCC and Clang warn about many obviously unsafe calls, and -D_FORTIFY_SOURCE=2 (with -O2) upgrades some overflows into compile-time or runtime errors.
  • -Wstringop-overflow (GCC) flags copies it can prove overflow.

Runtime help — your best friend is AddressSanitizer

cc -g -fsanitize=address -fno-omit-frame-pointer bof.c -o bof
./bof

ASan intercepts the first out-of-bounds byte and prints the exact file/line of the bad write plus a backtrace. Read the top of the report: the tag (stack-buffer-overflow, heap-buffer-overflow, global-buffer-overflow) tells you where the buffer lived, and the WRITE of size N line tells you how far you went.

Logic errors and how to hunt them

  • Symptom: crash on function return, not on the copy. Classic return-address corruption. Rebuild with ASan; it will point at the real overflowing line, which is usually earlier.
  • Symptom: a nearby variable mysteriously changes value. Suspect an overflow of the buffer declared next to it. Print addresses (printf("%p", (void*)buf)) to confirm adjacency.
  • Symptom: works in debug, crashes in release (or vice versa). A hallmark of undefined behaviour; layout changed between builds. Do not "fix" it by switching optimisation — fix the bounds.

Questions to ask when it does not work

  1. What is the maximum length the source can ever be? Can I prove it fits?
  2. Did I reserve a byte for '\0'?
  3. Am I passing the destination's real size, or sizeof of a pointer?
  4. Have I run it under ASan with a deliberately over-long input?

Memory safety

This entire topic is a memory-safety topic, so the notes are the heart of it.

The undefined-behaviour reality

An out-of-bounds write is undefined behaviour. The C standard makes no promise at all: the program may work, corrupt data, crash, or be exploited. "It ran fine on my machine" is never evidence of safety. Treat every unbounded write as a latent security bug.

The vulnerability, clearly labelled, with its fix

/* VULNERABILITY: classic stack buffer overflow (CWE-121). */
void render(const char *user_input, char *banner) {
    strcpy(banner, "Welcome, ");
    strcat(banner, user_input);   /* unbounded append -> overflow */
}

/* FIX: bound both writes to the destination size. */
void render_safe(const char *user_input, char *banner, size_t banner_sz) {
    int n = snprintf(banner, banner_sz, "Welcome, %s", user_input);
    if (n < 0 || (size_t)n >= banner_sz) {
        /* handle truncation/error; never leave banner unterminated */
    }
}

Defensive checklist for this topic

  • Ban the unbounded family in new code: gets, strcpy, strcat, sprintf, scanf("%s"). Prefer fgets, snprintf, strlcpy/strlcat.
  • Validate input length before you copy. Reject or truncate anything longer than the destination can hold. This ties directly into the next lesson, Bounds checking everywhere.
  • Least privilege still matters. Even with an overflow, a process that runs with minimal permissions limits the blast radius.
  • Keep the mitigations on but do not rely on them. Modern compilers add these by default; they raise the cost of exploitation, they do not remove the bug:
Mitigation What it does What it does NOT do
Stack canary (-fstack-protector-strong) Detects return-address overwrite before ret Stop the overwrite from happening
ASLR Randomises addresses so attackers can't guess targets Prevent corruption
NX / -z noexecstack Marks the stack non-executable Stop overwrites of code pointers
FORTIFY_SOURCE Adds bounds checks to some libc calls Cover every write

The only real cure is not overflowing in the first place.

Real-world uses

Where this shows up in real systems

  • Network daemons and parsers. Web servers, DNS resolvers, and protocol parsers read attacker-controlled bytes into buffers; historically the richest source of overflow CVEs (the Morris worm hit a network daemon, Code Red hit a web server).
  • Embedded and IoT firmware. Small C programs on routers and cameras frequently strcpy network data into fixed buffers, and rarely have ASan in the loop — a major real-world attack surface.
  • Defensive engineering. Stack canaries, ASLR, and NX are built into every mainstream compiler and OS precisely because of this bug class. Security teams also fuzz parsers to find overflows before attackers do.
  • Offensive learning (lab-only). Capture-The-Flag (CTF) challenges use tiny, intentionally vulnerable binaries so students can study overflows safely. Only ever practise on binaries you own or are explicitly authorised to test — never on real services.

Professional best-practice habits

Beginner level

  • Never call gets; treat strcpy/strcat/sprintf as banned unless you have proven the size.
  • Always size a string buffer as max_length + 1 and terminate it.
  • Compile and test with -Wall -Wextra -fsanitize=address before you trust the code.

Advanced level

  • Centralise copying behind small helpers that take a destination size, so no call site can forget it.
  • Fuzz any code that parses external input; add ASan/UBSan to CI so overflows fail the build.
  • Track the length of every buffer alongside its pointer (a struct { char *p; size_t len; } discipline) rather than trusting '\0' terminators.

Practice tasks

Beginner 1 — Reproduce and observe. Write a program with a char buf[16] and strcpy a 30-character string into it. Compile once normally and once with -fsanitize=address. Objective: see that the normal build may "pass" while the ASan build reports a stack-buffer-overflow. Deliverable: paste the first three lines of the ASan report and note the line number it blames. Concepts: overflow, ASan.

Beginner 2 — Make it safe. Rewrite the program above using snprintf(buf, sizeof buf, "%s", input). Requirements: detect truncation by checking the return value and print "[truncated]" when it happens. Input/output example: input "abcdefghijklmnopqrstuvwxyz" should print the first 15 characters followed by [truncated]. Concepts: bounded copy, return-value checking.

Intermediate 1 — A safe copy helper. Implement int safe_copy(char *dst, size_t dst_sz, const char *src) that copies src into dst without ever overflowing, always null-terminates, and returns 0 on a full copy or 1 if it had to truncate. Constraints: no use of strcpy/strcat. Hint: snprintf returns the length it wanted. Concepts: destination-size passing, truncation detection.

Intermediate 2 — Predict the corruption. Declare char buf[8]; int guard = 0x41414141; next to each other, then strcpy(buf, "AAAAAAAAAAAA") (12 A's). Before running, predict on paper whether guard changes and why, using the stack-layout diagram from this lesson. Then print guard in hex to check. Objective: connect the diagram to observed behaviour. Concepts: stack adjacency, out-of-bounds write. (Note: exact result is UB and layout-dependent — the point is the reasoning.)

Challenge — Length-tracked string type. Define typedef struct { char *data; size_t len; size_t cap; } sstr; and implement sstr_append(sstr *s, const char *add) that grows the buffer with realloc when needed and never overflows. Requirements: handle realloc failure without leaking, keep data always null-terminated, and free everything at the end. Hint: new capacity should cover len + strlen(add) + 1. Concepts: ownership, bounds checking, safe reallocation, cleanup.

Summary

Key takeaways

  • A buffer overflow is writing past the end of a fixed-size buffer; the stray bytes overwrite adjacent memory, and C never stops you.
  • On the stack, the prize target is the saved return address — overwrite it and you can redirect execution. On the heap, overflows corrupt allocator metadata. Both are undefined behaviour.
  • The root cause is almost always an unbounded call: gets, strcpy, strcat, sprintf. The fix is always a bounded call with a size: fgets, snprintf, strlcpy — and remember strncpy may not null-terminate.
  • Most important syntax: snprintf(buf, sizeof buf, "%s", src) and checking its return value for truncation. Never use sizeof on a pointer parameter; pass the real size.
  • Common mistakes: forgetting the '\0' byte, trusting "input is usually short," and treating stack-smashing warnings as noise.
  • Mitigations (canaries, ASLR, NX, FORTIFY_SOURCE) raise the attacker's cost but do not remove the bug — bounds checking does. Compile with -Wall -Wextra -fsanitize=address and test with over-long input. Next up: Bounds checking everywhere.

Practice with these exercises