Pointers & Memory · intermediate · ~20 min
- Name every common memory-corruption bug class in C and recognise its fingerprint in real code. - Match each bug to its standard, one-line defence, so you fix the whole class instead of one instance. - Apply the defensive `malloc`/`free` discipline (init to NULL, check for OOM, free once, reset to NULL) every time. - Use the realloc-with-temp pattern so a failed grow never leaks or corrupts the original buffer. - Build and run your tests under AddressSanitizer and UndefinedBehaviorSanitizer to catch the bugs the compiler cannot see. - Pre-check integer arithmetic before it becomes an allocation size, so a wraparound never leads to under-allocation.
Memory safety means your program only ever reads or writes memory that it logically owns and that has already been initialised — nothing before an array, nothing past its end, nothing after it was freed, and nothing that was never given a value.
C does not enforce any of this. Unlike Python, Java, or Rust, C has no runtime that checks an index against a length or notices that a pointer is stale. The instruction to read array[1000] on a ten-element array compiles cleanly and runs; whether it crashes, returns garbage, or silently corrupts something depends on what happens to sit in that memory. That freedom is why C is fast and why it powers operating systems and embedded devices — and it is exactly why the responsibility for safety falls on you.
This lesson ties together everything from Pointer basics (what a pointer holds and how dereferencing works), malloc and the heap (where dynamic memory comes from and how it can fail), and free and ownership (who is allowed to release a block and when). It also builds directly on Dangling pointers, the lesson just before this one: a dangling pointer is one specific memory-safety failure, and here we zoom out to see the whole family it belongs to.
The key mental shift: memory safety is not a single feature you switch on. It is a small set of habits — call them the defence list — that you apply to a small set of named bugs — the bug list. Learn the two lists together and most memory corruption simply stops happening.
Both Microsoft and Google have independently reported that around 70% of their historically tracked critical security vulnerabilities trace back to memory-safety bugs — the exact classes in this lesson. (A CVE, Common Vulnerabilities and Exposures, is a publicly catalogued security flaw with an ID like CVE-2021-3156.) These are not exotic academic bugs; they are the everyday consequences of an unchecked copy, a pointer used one line too late, or a size calculation that quietly wrapped around.
The reason this is worth a whole lesson is that memory bugs are silent. A logic error usually announces itself with a wrong answer. A memory bug can run correctly for months, then corrupt an unrelated variable, crash in a different function, or hand an attacker control of the program — all far from the line that actually caused it. The cost of finding one late is enormous.
The good news: the defences are well understood and cheap. Bounded copies, a NULL check before a dereference, resetting a pointer after free, and running the sanitizers in your test suite cost almost nothing per line. The hard part is not knowing them — it is applying them everywhere, without exception. That discipline is the real skill this lesson teaches.
Memory safety is best learned as two matched lists. First learn to name each bug; then learn the single defence that neutralises it.
Definition. Writing (or reading) past the boundary of an array or heap block.
Plain explanation. You have room for 8 bytes and you write 20. The extra 12 bytes land on whatever happens to sit next in memory — an adjacent variable, a saved return address, heap bookkeeping.
How it works internally. Local arrays live on the stack, right next to saved registers and the function's return address. Heap blocks sit next to allocator metadata. Overrunning either overwrites data the program relies on to keep running correctly or securely.
Stack frame (local buffer overrun):
low address high address
+------------------+----------------+---------------------+
| char buf[8] | int secret | saved return addr |
+------------------+----------------+---------------------+
^ ^
| |
writing 20 bytes here spills right, clobbering secret
then the return address --> hijacked control flow
When to worry / when not. Any copy whose length is not provably ≤ the destination size is a candidate. A fixed strcpy(buf, "hi") with a known-short literal is fine; strcpy(buf, user_input) is not.
Defence. Use bounded copies: snprintf, strlcpy, or an explicit length check. Never let an input decide how many bytes land in a fixed buffer.
Pitfall. Off-by-one on the NUL terminator: an 8-char name needs a 9-byte buffer.
Definition. Use-after-free reads or writes memory through a pointer after that memory was returned to the allocator. Double-free frees the same block twice.
Plain explanation. free(p) does not erase p; it just marks the block as reusable. The pointer still holds the old address. Touch it again and you are reaching into memory the allocator may have handed to someone else.
How it works internally. After free, the allocator may reuse the block for the next malloc. A later write through the stale pointer silently corrupts the new occupant. Freeing twice corrupts the allocator's own free-list bookkeeping — a classic exploitation primitive.
Before free: p ---> [ block: "hello" ] (owned by you)
After free: p ---> [ block: reusable ] (p is now DANGLING)
After malloc: p ---> [ block: someone else's data ]
writing through p corrupts THEIR data
When to worry / when not. Every free is a hazard if the pointer might be used again. If the pointer goes out of scope immediately after, the window is small but still worth closing.
Defence. free(p); p = NULL; — a free(NULL) is a guaranteed no-op, and dereferencing NULL crashes loudly instead of corrupting silently.
Pitfall. Two pointers to the same block (aliasing): NULLing one does not protect the other.
Definition. Null dereference uses a pointer equal to NULL. Uninitialised read uses a variable (or heap block) before it was given a value.
Plain explanation. malloc can return NULL when memory runs out; using that result without checking dereferences NULL. Separately, a fresh malloc block and an uninitialised local both contain leftover garbage — reading them yields unpredictable values and undefined behaviour.
Defence. Check every malloc/realloc result. Initialise variables at declaration. Use calloc (which zeroes) when zeroed memory matters.
Pitfall. Assuming malloc memory is zeroed — it is not; only calloc guarantees that.
Definition. A size calculation like count * sizeof(T) wraps past the maximum of its type, producing a tiny result, so you allocate far less than you intended and then write the full amount. Type confusion reinterprets a block as the wrong type.
Plain explanation. If count comes from untrusted input, count * 8 can wrap to a small number. malloc succeeds with a small block; your loop writes the full count elements — an overflow driven purely by arithmetic.
Defence. Pre-check the multiplication (or use calloc(count, size), which detects overflow for you). Keep types consistent.
Pitfall. Doing the check after multiplying (if (count*size > MAX)) — the wrap has already happened. Check count > MAX / size instead.
| Bug class | Fingerprint in code | Standard defence |
|---|---|---|
| Buffer overflow | copy length not bounded by dest size | snprintf / explicit length check |
| Use-after-free | pointer used after free |
free(p); p = NULL; |
| Double-free | free reachable twice |
reset to NULL; clear ownership |
| Null dereference | unchecked malloc/realloc result |
check every allocation result |
| Uninitialised read | value used before assignment | init at declaration / calloc |
| Integer overflow → under-alloc | n * size from untrusted n |
calloc(n, size) or n > MAX/size |
Knowledge check.
p = NULL after free(p) defend against two different bug classes at once?int *a = malloc(4 * sizeof(int)); printf("%d", a[0]); — is the printed value predictable? Why or why not?char buf[16]; strcpy(buf, argv[1]); — which bug class is this, and what single change removes it?The defensive five-line malloc/free pattern — memorise its shape:
T *p = NULL; /* 1. always start pointers at a known value */
p = malloc(sizeof *p); /* sizeof *p tracks the type automatically */
if (!p) { return -1; } /* 2. never use an unchecked allocation */
/* ... use p ... */
free(p); /* 3. free exactly once */
p = NULL; /* 4. kill the dangling pointer immediately */
The realloc-with-temp pattern — never overwrite the original with a possibly-NULL result:
T *tmp = realloc(buf, new_size); /* if this fails, buf is still valid */
if (!tmp) { return -1; } /* handle OOM WITHOUT losing buf */
buf = tmp; /* only now is it safe to reassign */
Writing buf = realloc(buf, n); directly is the classic leak: on failure realloc returns NULL, you overwrite the only pointer to the old block, and it is lost forever.
Memory safety is the umbrella term for a family of bugs that cost attackers nothing and defenders everything.
The family includes:
Modern C development means knowing each one by name, and knowing its standard defence.
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
/* Grow an int array to hold `want` elements, defending against every
* memory-safety bug touched in this lesson. Returns 0 on success, -1 on
* failure; on failure *arr and *cap are left unchanged and still valid. */
static int grow_ints(int **arr, size_t *cap, size_t want)
{
/* Integer-overflow guard: reject sizes that would wrap when multiplied. */
if (want > SIZE_MAX / sizeof(int)) {
fprintf(stderr, "requested size too large\n");
return -1;
}
/* realloc-with-temp: never lose the original block on failure. */
int *tmp = realloc(*arr, want * sizeof(int));
if (!tmp) { /* out of memory: *arr still valid */
fprintf(stderr, "out of memory\n");
return -1;
}
/* realloc leaves new bytes uninitialised: zero only the grown region. */
if (want > *cap)
memset(tmp + *cap, 0, (want - *cap) * sizeof(int));
*arr = tmp;
*cap = want;
return 0;
}
int main(void)
{
int *data = NULL; /* start pointers at a known value */
size_t cap = 0;
if (grow_ints(&data, &cap, 4) != 0)
return 1;
for (size_t i = 0; i < cap; i++) /* bounded by cap, never guessed */
data[i] = (int)(i * i);
if (grow_ints(&data, &cap, 8) != 0) {
free(data); /* clean up on the failure path too */
return 1;
}
printf("cap=%zu ->", cap);
for (size_t i = 0; i < cap; i++)
printf(" %d", data[i]); /* new slots are zeroed, not garbage */
printf("\n");
free(data); /* free exactly once */
data = NULL; /* kill the dangling pointer */
return 0;
}
What it does. grow_ints safely resizes a heap array. It rejects overflowing sizes, uses realloc-with-temp so a failed grow never loses the old data, and zeroes only the newly added region (because realloc does not initialise it). main fills four squares, grows to eight, and prints the result.
Expected output:
cap=8 -> 0 1 4 9 0 0 0 0
Edge cases. If want is 0, realloc may return NULL legitimately (not an error) — production code that supports zero-length should special-case it. On a 64-bit system the overflow guard only triggers for astronomically large want, but on 32-bit or with int scaled by a large element it is a real, reachable check.
Walking the key path — the first grow_ints(&data, &cap, 4) call, then the loop:
data and cap start at NULL and 0. Passing their addresses lets grow_ints update the caller's variables.want > SIZE_MAX / sizeof(int) is checked before multiplying. With want == 4 it is false, so we proceed.realloc(*arr, want * sizeof(int)) with *arr == NULL behaves exactly like malloc(16) — realloc treats a NULL pointer as "allocate fresh". The result goes into tmp, never into *arr, so if it fails we still hold the old (here NULL) pointer safely.tmp is non-NULL, so we skip the OOM branch. want (4) > *cap (0) is true, so memset zeroes bytes from tmp + 0 for 4 * sizeof(int) bytes — the whole new block.*arr = tmp; *cap = want; commits: the caller's data now points at a 4-int zeroed block, cap is 4.| Step | want | *cap before | *cap after | *arr |
|---|---|---|---|---|
| first call | 4 | 0 | 4 | 16-byte zeroed block |
| loop writes | — | 4 | 4 | data[0..3] = 0,1,4,9 |
| second call | 8 | 4 | 8 | old 4 preserved, +4 zeroed |
data[i] = i*i runs i from 0 to cap-1 (3). It is bounded by cap, the real allocation size, so no out-of-bounds write is possible.grow_ints(..., 8) grows the block. realloc copies the existing 16 bytes, then memset(tmp + 4, 0, 4*sizeof(int)) zeroes only slots 4–7, leaving 0,1,4,9 intact.free(data); data = NULL; releases the block once and removes the dangling pointer, so no use-after-free or double-free can follow.Mistake 1 — buf = realloc(buf, n) (the self-assign leak).
buf = realloc(buf, n); /* WRONG */
if (!buf) return -1;
Why it is wrong: if realloc fails it returns NULL, and you have just overwritten the only pointer to the old block — an unrecoverable leak. Corrected:
int *tmp = realloc(buf, n);
if (!tmp) return -1; /* buf still valid, nothing leaked */
buf = tmp;
Recognise it: any x = realloc(x, ...) on one line is a red flag.
Mistake 2 — assuming malloc memory is zeroed.
int *v = malloc(n * sizeof *v);
for (...) total += v[i]; /* WRONG: v holds garbage */
malloc returns uninitialised memory. Use calloc(n, sizeof *v) when you need zeros, or initialise every element before reading it.
Mistake 3 — checking overflow after it happened.
if (count * size > LIMIT) return -1; /* WRONG: multiply already wrapped */
p = malloc(count * size);
The wrap occurs during count * size, so the check sees the small wrapped value. Correct: if (count > LIMIT / size) return -1; or use calloc(count, size), which checks internally.
Mistake 4 — freeing then using "just once more".
free(node);
next = node->next; /* WRONG: use-after-free */
Read everything you need before freeing: next = node->next; free(node); node = NULL;.
Mistake 5 — off-by-one on the terminator.
char name[8];
strncpy(name, "filename", 8); /* WRONG: no room for NUL */
"filename" is 8 characters, so its NUL needs a 9th byte. Either size the buffer 9, or use snprintf(name, sizeof name, "%s", src) which always terminates.
Compiler-level (free and early). Turn warnings up: -Wall -Wextra -Wconversion. The compiler will flag many uninitialised-use and signedness issues before you ever run the program.
Sanitizers (the single most valuable tool here). Build tests with:
cc -g -fsanitize=address,undefined -o prog prog.c
AddressSanitizer (ASan) catches buffer overflows, use-after-free, and double-free, printing the exact file, line, and the allocation/free stack traces. UndefinedBehaviorSanitizer (UBSan) catches integer overflow, null dereference, and misaligned access. Add -fsanitize=address,undefined to your test build in CI so every run is checked.
Reading an ASan report. The first line names the bug (heap-buffer-overflow, heap-use-after-free, ...). Below it, one stack trace shows where you touched the memory; a second shows where it was allocated and, for use-after-free, where it was freed. Match those three to pinpoint the lifetime error.
Runtime crashes without sanitizers. A Segmentation fault usually means a null or wild dereference, or a badly out-of-bounds access. Run under valgrind ./prog (Linux) or lldb/gdb to get a backtrace to the faulting line.
Questions to ask when it "works sometimes". Intermittent behaviour is the signature of a memory bug. Ask: Is any buffer written with an input-controlled length? Is any pointer used after a free? Is any variable read before it was assigned? Did any size come from multiplication of untrusted values? Sanitizers turn all four from "sometimes" into "every time, with a line number".
This lesson is a memory-safety lesson, so the notes are the core. Undefined behaviour (UB) is the throughline: every bug here is UB, meaning the C standard places no requirement on what happens — it may crash, corrupt, or appear to work, and the compiler is free to optimise on the assumption that it never occurs.
[0, length). Track the length alongside every buffer and check against it before writing. Out-of-bounds access is UB even if it "seems fine."free (heap) or while the variable is in scope (stack). Never return the address of a local; never use a pointer after free. Reset freed pointers to NULL.calloc when you need zeroed heap memory.Defensive practices, not vulnerabilities. Everything here is shown to prevent corruption. The one deliberately-wrong snippet (the overrun stack diagram) is labelled as the bug and immediately paired with its defence (bounded copies). Keep production builds compiled with stack canaries (-fstack-protector-strong) and non-executable memory (NX / -z noexecstack) on — they are defence-in-depth for the cases discipline misses, not a substitute for it.
Concrete case. The 2014 Heartbleed vulnerability (CVE-2014-0160) in OpenSSL was a single out-of-bounds read: a length field from the network was trusted without checking it against the actual buffer size, letting attackers read up to 64 KB of server memory per request — including private keys. The fix was one bounds check. Every class in this lesson has shipped in software you use daily; the defences here are exactly what patches them.
Where these habits live in professional work:
Best-practice habits (beginner): always check malloc/realloc; always bound copies with snprintf/lengths; free once then NULL; compile with -Wall -Wextra and run tests under ASan+UBSan; give buffers and their lengths clear paired names (buf / buf_len).
Best-practice habits (advanced): enforce sanitizers and warnings-as-errors in CI; add fuzzing (libFuzzer/AFL) for input parsers to surface overflows automatically; adopt bounds-carrying types or wrapper APIs so length always travels with the pointer; run static analysers (clang-tidy, Coverity) in review; document ownership at every API boundary and consider hardened allocators in production.
Beginner 1 — Bounded copy. Write void safe_name(char *dst, size_t cap, const char *src) that copies src into dst without ever overrunning it and always leaves dst NUL-terminated. Requirements: use snprintf; work correctly when src is longer than cap. Example: cap = 4, src = "filename" → dst holds "fil". Concepts: buffer overflow, bounded copies.
Beginner 2 — Free-and-NULL discipline. Write void free_and_clear(int **p) that frees *p and sets it to NULL. Then show in main that calling it twice on the same pointer is safe (no double-free) because the second call frees NULL. Concepts: use-after-free, double-free, free(p); p = NULL;.
Intermediate 1 — Overflow-checked allocation. Write int *make_array(size_t n) that allocates room for n ints, returning NULL if n * sizeof(int) would overflow size_t. Requirements: perform the check before multiplying (n > SIZE_MAX / sizeof(int)); zero the memory. Hint: calloc does both for you — implement it once by hand, then compare. Concepts: integer overflow into allocation, calloc.
Intermediate 2 — Safe grow. Write int push(int **arr, size_t *len, size_t *cap, int value) that appends value, doubling capacity with the realloc-with-temp pattern when *len == *cap. Requirements: never leak on OOM; start from NULL/0. Input/output: pushing 1,2,3 onto an empty array yields [1,2,3] with cap >= 3. Concepts: realloc-with-temp, null checks, bounds.
Challenge — Audit and fix. Given a short program that contains one buffer overflow, one use-after-free, and one unchecked malloc, (a) build it under -fsanitize=address,undefined, (b) use the diagnostics to locate each bug by file and line, and (c) apply the standard defence for each without changing the program's output. Deliverable: the fixed source plus one sentence per bug naming its class and its defence. Concepts: the whole bug list + defence list, reading sanitizer output.
Memory safety is two matched lists. The bug list: buffer overflow, use-after-free, double-free, null dereference, uninitialised read, and integer overflow leading to under-allocation. The defence list: bounded copies (snprintf), check every allocation result, free(p); p = NULL;, initialise (or calloc), and pre-check integer arithmetic before it becomes a size.
The key syntax to internalise is the five-line malloc/free pattern (init to NULL, check OOM, free once, reset to NULL) and the realloc-with-temp pattern (tmp = realloc(...); if(!tmp) handle; buf = tmp;). The most common mistakes are the self-assign realloc leak, assuming malloc zeroes memory, checking overflow after the multiply, and using a pointer one line after freeing it.
Remember: every one of these bugs is undefined behaviour, which means it can hide for a long time and strike far from its cause. You cannot out-test them by luck. Compile with -Wall -Wextra, run every test under AddressSanitizer and UndefinedBehaviorSanitizer, and apply the defence for each bug class everywhere, every time. The discipline is small; the payoff is the elimination of roughly 70% of critical security bugs.