Secure Coding in C · intermediate · ~12 min
## 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
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.
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."
char[], an int[], a malloc'd block).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:
fingerd daemon and spread across the early internet.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.
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?
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?
| 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.
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?
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.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.
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.
#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.
Walking the interesting path — the two greet_safe calls and the greet_unsafe call.
main calls greet_safe("Ada"). Inside, buf[16] is uninitialised local storage.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".printf prints Safe greeting: Hi Ada.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".need (31) >= sizeof buf (16), the truncation note prints, then the truncated greeting prints. Crucially, no memory outside buf was touched.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)
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.
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.
-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.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.
printf("%p", (void*)buf)) to confirm adjacency.'\0'?sizeof of a pointer?This entire topic is a memory-safety topic, so the notes are the heart of it.
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.
/* 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 */
}
}
gets, strcpy, strcat, sprintf, scanf("%s"). Prefer fgets, snprintf, strlcpy/strlcat.| 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.
strcpy network data into fixed buffers, and rarely have ASan in the loop — a major real-world attack surface.Beginner level
gets; treat strcpy/strcat/sprintf as banned unless you have proven the size.max_length + 1 and terminate it.-Wall -Wextra -fsanitize=address before you trust the code.Advanced level
struct { char *p; size_t len; } discipline) rather than trusting '\0' terminators.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.
gets, strcpy, strcat, sprintf. The fix is always a bounded call with a size: fgets, snprintf, strlcpy — and remember strncpy may not null-terminate.snprintf(buf, sizeof buf, "%s", src) and checking its return value for truncation. Never use sizeof on a pointer parameter; pass the real size.'\0' byte, trusting "input is usually short," and treating stack-smashing warnings as noise.-Wall -Wextra -fsanitize=address and test with over-long input. Next up: Bounds checking everywhere.