Worked examples — Common memory errors — null dereference, buffer overflow, use-after-free, double free, memory leak
Before any code, one picture to fix the vocabulary we reuse everywhere. In the figure below, memory is drawn as a row of numbered lockers. Look at the yellow sticky note labelled p: it does not contain your data, it only holds a locker number ("4") and an arrow pointing at that locker. The three blue-outlined lockers (3, 4, 5) are your owned block. Notice the pink note on the right — it shows the same number "4" but its arrow is dashed: that is a dangling pointer after the block was freed. The take-away: two notes can name the same locker, but only one legally owns it.

Figure s01 illustrates the vocabulary in the definition box just below — refer back to it whenever "owned block" or "dangling pointer" appears.
The scenario matrix
Every cell below is a distinct situation a memory bug can live in. The examples that follow are each tagged with the cell(s) they cover, so you can see the whole grid is filled.
| # | Cell class | Concrete situation | Example |
|---|---|---|---|
| C1 | Null path — failure | malloc returns NULL, pointer dereferenced |
Ex. 1 |
| C2 | Null path — success + guard | malloc succeeds, guarded correctly |
Ex. 1 |
| C3 | Bounds — string, off-by-one | Buffer too small by the \0 byte |
Ex. 2 |
| C4 | Bounds — loop index degenerate | <= vs < on an array |
Ex. 3 |
| C5 | Zero / degenerate size | malloc(0), empty string, n = 0 |
Ex. 4 |
| C6 | Lifetime — use-after-free | Read/write through freed pointer | Ex. 5 |
| C7 | Lifetime — double free | free called twice |
Ex. 6 |
| C8 | Leak — reassignment / early return | Last handle lost | Ex. 7 |
| C9 | Real-world word problem | Long-running server, counting bytes | Ex. 8 |
| C10 | Exam twist | Looks safe, is UB — combine two rules | Ex. 9 |
| C11 | Combined bug in the null path | malloc fails, then null is used and double-freed |
Ex. 10 |
Recall Quick self-test before you read on
Which single line of code neutralises BOTH C6 and C7? ::: p = NULL; immediately after free(p) — because free(NULL) is a guaranteed no-op and a later *p becomes a detectable null deref.
Example 1 — C1 & C2: the null path, both branches
Step 1 — Ask what malloc promises.
malloc(bytes) returns the address of a fresh block of that many bytes, or NULL if it could not find room.
Why this step? Because the whole bug hinges on which of those two things happened, and the code above never asked.
Step 2 — Size the request.
sizeof(int) is bytes on a typical machine. So the request is
Why this step? A request that large often fails, so the NULL branch is realistic, not theoretical.
Step 3 — Trace the failure branch (cell C1).
If malloc returns NULL, then p == 0. The line p[0] = 42; means "write 42 to address 0 + 0". Address 0 is unmapped by the OS → segfault (a null dereference, UB).
Why this step? We must follow the failure branch to its consequence — otherwise we'd never see that the crash comes from the write, not the allocation.
Step 4 — The fix (cell C2).
int *p = malloc(sizeof(int) * 1000000000ULL);
if (p == NULL) { fprintf(stderr, "out of memory\n"); return 1; }
p[0] = 42; // now guaranteed validWhy this step? One if converts a random crash into a controlled, reported failure.
Verify: bytes requested ; in GiB that is GiB. The answer to the forecast: sometimes (crashes only when the OS refuses the allocation), and the risky line is
p[0] = 42, not themalloc.
Example 2 — C3: the classic string off-by-one
Throughout this example let = the string length (number of visible characters in the text being copied) and = the buffer size (number of bytes the destination array can hold).
Step 1 — Count the source bytes.
"hello world" has visible characters, so . A C string always ends with an invisible null terminator \0 (see Strings in C and the null terminator). So the true length in bytes is
Why this step? strcpy copies until and including the \0; it never looks at the destination's size.
Step 2 — Apply the size law from the parent note.
A buffer of bytes can legally hold a string of chars only if . Here (from char buf[8]), so
Why this step? This is the single inequality that decides every string-overflow question — we plug our numbers into it rather than eyeballing.
Step 3 — Measure the overflow.
Bytes written past the end . Those bytes clobber whatever sits after buf on the stack — possibly a saved return address (see Buffer Overflow Exploits).
Why this step? Knowing the exact overflow count tells you how much adjacent memory is corrupted, which is what an exploit or a crash actually depends on.
Step 4 — The fix.
snprintf(buf, sizeof buf, "%s", "hello world"); // writes at most 8 bytes, always \0-terminatedWhy this step? snprintf is told the size, so it truncates instead of overflowing.
Verify: , , overflow bytes. Truncated result from
snprintfintobuf[8]is"hello w"(7 chars +\0).
Example 3 — C4: the fencepost loop
Let = the array size (the number of slots the array has — here from int a[5]).
Step 1 — List the legal indices.
An array a[5] has valid indices — exactly slots, top index .
Why this step? C indexes from 0, so the last valid index is always one less than the size.
Step 2 — List what the loop actually visits.
i <= 5 runs i = 0,1,2,3,4,5. The final i = 5 writes a[5] — one past the end.
Why this step? The <= includes the boundary; < would not.
Step 3 — Count the damage.
The loop runs times but only slots are legal, so out-of-bounds writes . One int (4 bytes) is stomped just past the array.
Why this step? Turning "off-by-one" into an explicit count makes it undeniable exactly one write is illegal.
Step 4 — The fix. Change <= to <: for (int i = 0; i < 5; i++).
Verify: loop with
<=iterates times ( to ), legal slots , so out-of-bounds writes . Loop with<iterates exactly times.
Example 4 — C5: zero and degenerate sizes
Step 1 — Read the rule for zero.
malloc(0) may return either NULL or a unique non-NULL pointer that you must not dereference. Both are legal.
Why this step? Many overflow bugs come from assuming a size is positive; zero is a real case you must handle.
Step 2 — Check the free is safe in both branches.
- If
p == NULL:free(NULL)is a guaranteed no-op → safe. - If
p != NULL: it is a real (zero-usable-byte) block →free(p)is correct.
So the snippet is not a bug. The bug would be p[0] = 'x'; — reading/writing a zero-size block is UB.
Why this step? Because "is this a bug?" can only be answered by checking both possible return values — a single-branch check would miss half the cases.
Step 3 — Degenerate string case.
The empty string "" still occupies byte (just the \0), where is the string length. So char e[1]; strcpy(e, ""); is legal; char e[0] would not even hold the terminator.
Why this step? The empty string is the smallest input; if our size law still holds here, it holds everywhere, so we test the boundary explicitly.
Verify: empty string byte count ; minimum buffer for
""is .free(NULL)performs no operation (no crash).
Example 5 — C6: use-after-free, and why free can't help you
Recall the two abbreviations from the definition box above: UAF = use-after-free, and UB = undefined behaviour.
Step 1 — What free(p) actually changes.
free receives a copy of the address (pass-by-value). It returns the block to the allocator's free-list but does not touch your variable p — see the picture. In Figure s02 below, follow the blue arrow: only a copy of the address 0x40 travels into free(). Now look at the yellow box on the left — your variable p is never reached by that arrow, so after the call it still holds 0x40 (the pink label), now pointing at a locker the allocator has taken back (the pink heap block at the bottom). The picture is the whole argument for this step: free cannot clear p because it never sees p.

Figure s02 is the visual proof for Step 1 above: the blue "copy" arrow into
free()never touches the yellowpbox, sopstays dangling.
Why this step? This is the whole misconception: people think free "clears" p. It cannot; it never sees p, only its value.
Step 2 — Diagnose the read.
After free, p is dangling: it still names the old locker, but that locker may already be handed to the next malloc. Reading *p is UAF (use-after-free) — it may print 7, print garbage, or crash. Classic sometimes behaviour.
Step 3 — The fix.
free(p);
p = NULL; // now *p is a detectable null deref, not silent corruptionVerify: the value written before the free is
7; the legal read of it (beforefree) equals7. Afterfree,*pis UB, so no deterministic value is guaranteed.
Example 6 — C7: double free
Step 1 — Trace ownership.
After the first free(p), the 10-byte block is back with the allocator. p still holds its address (dangling, unchanged).
Step 2 — The second free.
free(p) hands the same address back again. The allocator tries to link an already-free block into its free-list → metadata corruption, usually an abort() on modern libc.
Why this step? The allocator stores bookkeeping (size, next-free) inline with the block; re-freeing scribbles on it.
Step 3 — The one-line cure (kills C6 and C7).
free(p);
p = NULL; // second "free(p)" is now free(NULL) → guaranteed no-op
free(p); // harmlessVerify: the number of distinct frees of a live block here is ; the correct count for one allocation is ; excess → double free. With
p = NULL, the second call operates onNULL(no-op).
Example 7 — C8: the leak by reassignment
Step 1 — Count allocations and frees. Allocations (100 bytes, then 200). Frees . Why this step? The conservation law says a correct program ends with ; here .
Step 2 — Identify the lost handle.
The line p = malloc(200) overwrites the only pointer to the 100-byte block. That block is now unreachable → leaked.
Step 3 — Quantify. Leaked bytes . Freed bytes . Net difference orphaned block.
Step 4 — Fix. free the first block before reassigning, or use two variables.
Verify: , , leaked bytes , freed bytes , orphaned blocks .
Example 8 — C9: real-world word problem (a leaking server)
Step 1 — Bytes leaked per second. Why this step? Each un-freed request adds exactly one 512-byte block ( grows, stays flat), so the leak is a steady stream we can put a rate on.
Step 2 — Total budget in bytes. Why this step? The rate is in bytes/s, so the budget must be in the same unit (bytes) before we divide — otherwise the units don't cancel. Note we use GiB (), not GB ().
Step 3 — Time to exhaustion, in seconds. Why this step? Dividing a total amount (bytes) by a per-second rate (bytes/s) leaves seconds — the bytes cancel, giving a time.
Step 4 — Convert seconds to hours. There are seconds in an hour, so Why this step? This is why leaks are insidious: nothing crashes for almost two days, then the OOM-killer strikes at 3 a.m. Tools like Valgrind and AddressSanitizer catch it in seconds instead.
Step 5 — Fix. Match every session malloc with a free at request end (one owner per block).
Verify: rate B/s; budget B; s; h.
Example 9 — C10: the exam twist (looks safe, is UB)
Step 1 — Check the bounds (the decoy).
"id-7" is chars, so (buffer size). The overflow rule is satisfied. No buffer overflow here — that's the trap.
Why this step? We must clear the obvious suspect first, so the real bug stands out instead of hiding behind it.
Step 2 — Check the lifetime.
tag lives on the stack (see Stack vs Heap Memory). When make_tag returns, its stack frame is destroyed. Returning tag returns a pointer to memory that no longer belongs to anyone — a dangling pointer, and any use is UAF-flavoured UB.
Why this step? Two rules apply here (bounds AND lifetime); the exam hides the real bug behind a correctly-handled one.
Step 3 — Fix (heap-allocate so the caller owns it).
char *make_tag(void) {
char *tag = malloc(16);
if (!tag) return NULL;
snprintf(tag, 16, "id-%d", 7);
return tag; // caller must free()
}Verify: the content length check holds (so the decoy really is safe); the actual defect is returning a stack address, not a bounds error.
Example 10 — C11: two bugs stacked in the null path
Step 1 — Establish the state.
Assume malloc failed, so p == NULL (address 0). Every following line must be judged against that single fact.
Why this step? Combined bugs are only confusing until you pin down the pointer's actual value; once p == NULL is fixed, each line is decidable.
Step 2 — Line (a): the null dereference.
p[0] = 'x'; writes to address 0 → segfault, a null dereference (UB). This is a genuine bug.
Why this step? It shows the null path's first victim is the dereference, exactly as in Example 1.
Step 3 — Line (b): free(NULL) is safe.
Because p == NULL, free(p) is free(NULL), which the standard guarantees to do nothing. Harmless.
Why this step? This is the surprising cell: a "free in the failure path" is not a bug when the pointer is null.
Step 4 — Line (c): still safe, no double free.
The block was never actually allocated, so there is nothing to free twice. free(NULL) again → no-op. So the usual double-free danger evaporates in the null path.
Why this step? It proves double-free requires a real, once-freed block — freeing NULL twice is not a double free.
Step 5 — The real lesson.
The only bug here is line (a). Had we checked if (!p) return; after malloc, all three lines would be skipped. The single guard from Example 1 fixes the combined scenario too.
Why this step? It ties the combined case back to the one discipline (check malloc) that dissolves it.
Verify: with
p == NULL: bug count (only line (a));free(NULL)called twice performs operations; number of real blocks freed , so double-free count .
Coverage check
Recall Did we fill every cell of the matrix?
C1/C2 → Ex.1 · C3 → Ex.2 · C4 → Ex.3 · C5 → Ex.4 · C6 → Ex.5 · C7 → Ex.6 · C8 → Ex.7 · C9 → Ex.8 · C10 → Ex.9 · C11 → Ex.10. ::: All eleven cells covered.
Flashcards
What overflow size does strcpy(buf,"hello world") into char buf[8] cause?
\0), buffer holds 8, so it writes 4 bytes past the end.How many out-of-bounds writes does for(i=0;i<=5;i++) over int a[5] make?
i==5 write to a[5]).Can you free the result of malloc(0)?
NULL (free is a no-op) or a valid non-dereferenceable block (free is correct).In p=malloc(100); p=malloc(200); free(p); how many bytes leak?
Why is returning a local char tag[16] a bug even when sized correctly?
tag lives on the stack; the frame dies at return, leaving a dangling pointer → UB.What does the ULL suffix on a literal like 1000000000ULL do?
int.When p == NULL, is free(p); free(p); a double free?
free(NULL) is a guaranteed no-op; no real block is freed, so there is no double free.