5.1.18 · D3C Programming

Worked examples — Common memory errors — null dereference, buffer overflow, use-after-free, double free, memory leak

4,024 words18 min readBack to topic

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 — Common memory errors — null dereference, buffer overflow, use-after-free, double free, memory leak

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 valid

Why 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 the malloc.


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-terminated

Why this step? snprintf is told the size, so it truncates instead of overflowing.

Verify: , , overflow bytes. Truncated result from snprintf into buf[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 — Common memory errors — null dereference, buffer overflow, use-after-free, double free, memory leak

Figure s02 is the visual proof for Step 1 above: the blue "copy" arrow into free() never touches the yellow p box, so p stays 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 corruption

Verify: the value written before the free is 7; the legal read of it (before free) equals 7. After free, *p is 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);           // harmless

Verify: 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 on NULL (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?
Source needs 12 bytes (11 chars + \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?
Exactly 1 (the i==5 write to a[5]).
Can you free the result of malloc(0)?
Yes — it returns either 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?
100 bytes — the first block's only handle was overwritten.
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?
Forces the number to be an unsigned long long (64-bit), so the size multiplication cannot overflow a 32-bit int.
When p == NULL, is free(p); free(p); a double free?
No — free(NULL) is a guaranteed no-op; no real block is freed, so there is no double free.
What is the difference between GiB and GB?
GiB is binary ( bytes); GB is decimal ( bytes).