Chapter 08 — C: the explicit model
The 30-second version. C has no protective memory model — it has a memory contract: a value is raw bytes at an address, you own every byte, you free every heap byte yourself, and the language promises nothing in exchange. Pointers are typed addresses;sizeofand alignment rules let you compute any layout on paper;mallochides a small receipt next to your data; and "undefined behavior" is not a crash — it is a license for the optimizer to assume you never make mistakes. Everything other languages wrap around memory (headers, tags, GC, bounds checks) is machinery C leaves out on purpose.
The contract: four blunt answers
Part II of this module asks every language the same four questions. C's answers are the shortest in the book:
| Question | C's answer |
|---|---|
| What is a value? | Raw bytes at an address. The type is a compile-time interpretation; nothing at runtime records it. |
| Who owns it? | You. Ownership is a convention between programmers, invisible to the compiler. |
| Who frees it? | You. Stack objects pop with the frame (Chapter 04); statics live until exit; heap blocks wait for free(). |
| What safety is guaranteed? | None. The standard promises nothing; safety is the programmer's job. |
Chapter 06 showed a Python int as a 28-byte box; a C int is 4 bytes, nothing else — every byte of that cost is opt-in here.
A pointer is a typed address: 8 raw bytes on x86-64/ARM64 (Chapter 07) holding a virtual address (Chapter 03), plus a type that says how to interpret the bytes there — and how big one "step" is:
int a[4] = {10, 20, 30, 40};
int *p = a; // "a" decays to &a[0]: p = address + the type int
p + 1; // the ADDRESS advances sizeof(int) == 4 bytes, not 1
*(p + 2); // 30
&a[3] - &a[0]; // 3 — pointer difference counts ELEMENTS, never bytes
Pointer arithmetic is scaled by sizeof(*p): char *p; p+1 moves 1 byte, struct rec *q; q+1 moves the whole struct. That is why indexing works for any type: a[i] is defined by the standard as *((a)+(i)) — address plus i elements.
Array decay. In almost every expression, an array's name becomes a pointer to its first element. Exactly three contexts refuse to decay (C11 6.3.2.1): operand of sizeof, operand of unary &, and a string literal initializing a char array:
int a[4]; // an ARRAY: 16 contiguous bytes in main's frame
sizeof(a); // 16 — the whole object (4 * sizeof(int), x86-64)
sizeof(a) / sizeof(a[0]); // 4 — the "count elements" idiom
void f(int b[100]); // the [100] is DOCUMENTATION ONLY: b is int *
sizeof(b); // 8 inside f — a pointer, not an array
The size is amputated at the call boundary, by rule — which is why every C API that takes a buffer also takes a length (read, memcpy, strncpy).
One program, six neighborhoods
Chapter 03 built the virtual address space; here it is from inside a running C program. Six neighborhoods, each with its own lifetime, permissions, and growth direction:
a running C program, x86-64 Linux, non-PIE (ASLR shifts heap/stack/libs every run)
0x0000000000400000 +---------------------+
| ELF headers r-- |
0x0000000000401000 | .text r-x | the code: read + execute
0x0000000000402000 | .rodata r-- | string literals, const tables
0x0000000000403000 | .data.rel.ro r-- | RELRO hardening (Chapter 21)
0x0000000000404000 | .data rw- | initialized globals and statics
0x0000000000405000 | .bss rw- | zeroed globals/statics: 0 file bytes
+---------------------+
| heap rw- | grows UP via brk (Chapter 05)
| malloc < 128 KiB | glibc 2.35 default M_MMAP_THRESHOLD
| ... |
+---------------------+
0x00007f.......... | shared libraries | libc and friends: the mmap region
| large mallocs | >= 128 KiB: fresh mmap, page-aligned
| ... |
0x00007ffd........ | stack rw- | grows DOWN: frames (Chapter 04)
0x00007ffffffff000 +---------------------+ argv/envp at the very top
0x0000000000000000 | the NULL page | unmapped on purpose (Chapter 03)
Where each declaration lands:
const char *msg = "hi"; /* "hi\0" = 3 bytes in .rodata; msg itself wherever declared */
static const int tbl[] = {1, 2}; /* .rodata (gcc 13/clang put const globals there;
the C standard guarantees no location) */
int counter = 1; /* .data — a nonzero initializer costs file bytes */
static int visits; /* .bss — 0 file bytes; the loader maps zeroed pages */
void f(void) {
static int calls; /* .bss too: local SCOPE, program-long LIFETIME */
char buf[8]; /* stack: f's frame; vanishes at return */
char *p = malloc(100); /* the 100 bytes: heap; the pointer p: stack */
}
Three details worth burning in:
.bsscosts zero file bytes: memsz = N, filesz = 0 in the ELF header; the loader maps anonymous zero pages (Chapter 03).static int big[1<<20]grows your address space 4 MiB and your binary not at all. gcc 13 even moves an explicit= 0into.bss— a zero is a zero..rodatais mapped read-only, so writing a string literal faults.char buf[] = "hi"copies the bytes into the stack frame and is writable.- The 128 KiB cliff comes from the allocator (Chapter 05): blocks under 128 KiB are carved from the
brkregion above.bss; at or above it, glibc 2.35 usesmmap, andfree()callsmunmap— the pages leave your RSS immediately.
malloc's hidden receipt
Chapter 05 built a free-list allocator from scratch; Chapter 19 tours the real ones. The fact to internalize: malloc's bookkeeping lives next to your data, not in a side table. The signature forces it:
void free(void *p); // takes the pointer VALUE and nothing else
free() receives an address and no size. There is no global index to look it up in. The only place the block's size can live is memory the allocator controls — the bytes next to the block. glibc 2.35 on x86-64 puts the size word 8 bytes below your pointer:
glibc 2.35 chunk, x86-64 (sizes in bytes)
lower addresses
+0x00 +----------------------------+ <- chunk start (16-aligned)
| prev_size (8) | meaningful only if PREVIOUS chunk is free;
| | otherwise it is the previous chunk's tail
+0x08 +----------------------------+
| size (8) | <- free() reads THIS word, at p - 8
| low 3 bits = flags P/M/A | sizes are multiples of 16: bits are free
+0x10 +----------------------------+ <- p: the value malloc returned (16-aligned)
| user data ... | malloc(0) still gets 24 usable bytes here
| next chunk's prev_size (8) | <- overlaps your LAST 8 usable bytes
+----------------------------+ <- next chunk's size word
higher addresses usable bytes = chunk size - 8
The failure modes, in order of how often they ruin a week:
char *p = malloc(64); // chunk 80 B (64 + 8 header, rounded to 16); usable 72
p[72] = 'x'; // 1 byte past usable: the NEXT chunk's size word.
// Corruption with a delay: nothing dies until that chunk
// is freed or split (Chapters 05, 19, 21).
free(p); // reads p-8, checks alignment and neighbors, pushes the
// chunk to tcache/bins. Your DATA is untouched, not zeroed.
free(p); // glibc 2.35: abort "free(): double free detected in tcache 2"
free(NULL); // specified no-op since C89: the one pointer you may free blind
free(NULL)is safe because the standard says so — cleanup paths need noif.- Double free is catastrophic: the block enters the free structure twice, and if the tcache key check (glibc 2.29+) misses it, two later
mallocs return the same address and two "owners" write over each other, far from the cause. Chapter 21 turns exactly this into an exploit. free()never zeros your data: freed memory usually still contains your bytes until someone else gets the chunk — which is why use-after-free leaks secrets, not just crashes.
Layout arithmetic: alignment and padding
Chapter 01 derived alignment from the memory system; Chapter 06 padded objects in Java and C++. C is where you do the arithmetic yourself — and the compiler never reorders your fields (declaration order is semantic; contrast Go's compiler, Chapter 11). Three rules compute any struct on x86-64 Linux:
- Each member lands at the next offset that is a multiple of its alignment (
char1,short2,int4,long/pointer 8,long double16). - The struct's alignment is the strictest member's.
- Total size rounds up to a multiple of the struct's alignment — so array element [1] is still aligned.
struct rec { /* layout A: the order you thought of the fields in */
char kind; /* offset 0x00 */
int len; /* offset 0x04 — 3 pad bytes precede it (int needs 4) */
char *data; /* offset 0x08 — already 8-aligned: no gap */
char flag; /* offset 0x10 */
}; /* sizeof == 24: 3 pad + 7 tail pad; alignof == 8 */
layout A (declaration order) layout B (sorted by size, descending)
+0x00 | kind |1B +0x00 | data |8B
+0x01 | pad |3B +0x08 | len |4B
+0x04 | len |4B +0x0c | kind |1B
+0x08 | data |8B +0x0d | flag |1B
+0x10 | flag |1B +0x0e | pad |2B
+0x11 | pad |7B +0x10 (end) sizeof == 16
+0x18 (end) sizeof == 24
Layout B is the same four fields — char *data; int len; char kind; char flag; — with sizeof == 16. Identical semantics, 24 versus 16 bytes: a third less memory and a third fewer bytes per cache line in an array of a million (Chapter 02). Nothing was "optimized" — two declarations moved. offsetof and _Alignas pin layouts when wire formats demand it; __attribute__((packed)) removes padding at the price of unaligned access — slow on x86-64, trapping on some ARM profiles.
The optimizer's license: undefined behavior and aliasing
C's safety answer was "nothing is guaranteed." The fine print: with undefined behavior, the standard imposes no requirements on the result — and compilers read that as permission to assume the UB never happens. At -O0 your bug crashes; at -O2 the absence of your bug becomes an optimization fact. The classic:
/* "detect overflow by watching for wraparound" — a famous broken idiom */
int add_overflows(int a, int b) {
int sum = a + b; /* signed overflow is UB (C11 6.5/5) */
if (sum < a) /* "if it wrapped, sum drops below a" */
return 1;
return 0;
}
Since signed overflow is UB, the optimizer reasons: assume a + b never wraps; then a + b < a simplifies to b < 0. The addition and comparison both vanish. gcc 13, x86-64, -O2, emits exactly this:
add_overflows:
xorl %eax, %eax
testl %esi, %esi ; inspects b ONLY — the add is gone
setl %al
ret
add_overflows(INT_MAX - 1, 2) returns 0. Your check was deleted, legally:
int add_overflows_fixed(int a, int b) {
return b > INT_MAX - a; /* arithmetic that cannot overflow */
}
/* or, gcc/clang: a checked operation with no UB at all */
int sum;
if (__builtin_add_overflow(a, b, &sum)) return 1;
Strict aliasing is the same license aimed at types. C11 6.5/7 lists the types that may access an object's bytes: its own type, qualified/signed/unsigned variants, and char. Anything else is UB:
float f = 1.0f;
uint32_t bits = *(uint32_t *)&f; /* UB: compiler may assume these objects are
unrelated and REORDER the store to f past
this load */
The two defined ways to pun:
union { float f; uint32_t u; } pun = { .f = 1.0f };
uint32_t a = pun.u; /* C99 TC3+: write one member, read another —
allowed; the VALUE is implementation-defined
(the IEEE-754 bits everywhere you've heard of) */
uint32_t b;
memcpy(&b, &f, sizeof b); /* always defined: memcpy copies as bytes, and
gcc/clang compile it to one 4-byte mov */
There is no performance argument for the cast. The folklore: Linux builds with -fno-strict-aliasing (in KBUILD_CFLAGS for decades) because kernel macros pun types as a way of life and auditing every one was judged riskier than opting out; PostgreSQL does the same. The flag does not make punning legal — it only stops the optimizer from exploiting the illegality.
The ragged edges: VLAs, alloca, and the C11 repairs
Chapter 04 gave every function a fixed frame built by the prologue and destroyed by the epilogue. C99's variable-length array punches a hole in that:
void f(size_t n) {
int buf[n]; /* VLA: size known only at run time */
...
} /* reclaimed when the frame pops — see below */
gcc on x86-64 computes n * 4, rounds to 16, and subtracts from rsp — the frame becomes variable-sized. Three problems:
- Failure is silent. A huge
nmovesrsppast the stack guard page (Chapter 03): noNULLto check, no error code. The fault lands somewhere unrelated, or worse, in another mapping. Pure UB. - Worst-case depth becomes uncomputable. Frame-size audits go blind; the kernel's budget check is
CONFIG_FRAME_WARN, 2048 bytes by default on 64-bit. - Recursion multiplies both.
The Linux kernel finished purging VLAs in v4.20 (late 2018, Kees Cook's series) and builds with -Wvla so they cannot return — near-zero measured cost, total auditability win. C11 made VLAs optional (__STDC_NO_VLA__ == 1 means absent; MSVC never had them).
alloca() is the same idea without the array syntax: extend the current frame, return a pointer into it. Never standardized (a BSD/SVID tradition every major libc implements), freed not by any call but by the function's return — Chapter 07's epilogue (leave, or mov rsp, rbp; pop rbp; ARM64's restoring ldp) resets rsp in one instruction and every alloca evaporates. In a loop it accumulates until return, and there is no free() you could call. alloca(0) is UB.
The C11 repairs for the legitimate uses:
/* 1. aligned_alloc: your alignment, freed by plain free() */
void *line = aligned_alloc(64, 4096); /* 64 = cache line (Chapter 02). C11 says size
"should" be a multiple of alignment (DR 460
ambiguity); glibc 2.35 tolerates any size,
some BSD libcs return NULL. Round up
yourself, or use posix_memalign(). */
/* 2. flexible array member: header + payload in ONE allocation */
struct msg { /* C11 */
uint64_t tag; /* offset 0, 8 bytes */
uint32_t len; /* offset 8, 4 bytes */
/* 4 bytes pad: struct alignment is 8 */
unsigned char data[]; /* offset 16, 0 declared bytes: the FAM */
}; /* sizeof == 16, offsetof(data) == 16 */
struct msg *m = malloc(sizeof *m + n); /* one malloc, one free(m) */
The pre-C99 idiom it replaces:
struct msg_old {
uint64_t tag;
uint32_t len;
unsigned char data[1]; /* the 1-byte hack: sizeof lies (13 -> padded to 16),
every allocation site hand-does the -1 */
};
struct msg_old *m = malloc(sizeof *m + n - 1); /* forget the -1: off-by-one into
the next chunk's header */
The hack's whole cost is that arithmetic: sizeof no longer reports the header size and the -1 is a per-site ritual. The FAM says: zero declared size, offsetof is the truth, one malloc, one free, no arithmetic to get wrong.
What insiders know
malloc(0)is legal. The standard allows NULL or a unique freeable pointer; glibc 2.35 x86-64 returns non-NULL — a 32-byte minimum chunk with 24 usable bytes (malloc_usable_size(malloc(0)) == 24). Soif (!p) abort()is not an error check whenncan be 0.MALLOC_PERTURB_(glibc): fresh allocations are filled with~value, freed blocks withvalue(the 0xa5/0x5a pair, glibc 2.35 malloc(3)). It exists to break the dev/prod illusion: heap memory fresh from the kernel is zeroed (Chapter 03), so uninitialized-read bugs hide on a fresh heap and detonate on a recycled one.- tcache exists. Since glibc 2.27 every thread has a private cache: 64 size bins, up to 7 chunks each, covering chunk sizes up to 1040 bytes (glibc 2.35).
free()of a small block pushes it onto the per-thread list — no lock — and a matchingmalloc()pops it. Cheaper free/malloc pairs, and the double-free abort ("free(): double free detected in tcache 2"). Chapter 19 tours it; Chapter 21 poisons it. - The memcpy/memmove overlap rule.
memcpywith overlapping source and destination is UB;memmovebehaves as if copied via a temporary buffer. Both exist becausememcpymay assume disjointness and copy with wide vector loads/stores in whichever direction is fastest (Chapter 02) — dropping the overlap check is the entire difference. Provable disjointness:memcpy. Otherwise:memmove. restrictis a promise, not a hint.void copy(char *restrict dst, const char *restrict src, size_t n)promises the pointers do not overlap for the whole call; the compiler then vectorizes with no runtime check. Break the promise: UB — the same license as strict aliasing, one clause over.- NULL, 0, and C23's
nullptr. In glibc'sstddef.h, C-modeNULLis((void*)0)— pointer-sized, 8 bytes. The variadic trap:printf("%p", 0)passes a 4-byteintwhere the varargs ABI expects 8 bytes of pointer — UB that happens to work on x86-64 and nowhere portable. Insiders write(void*)0; C23'snullptr(typenullptr_t) ends the macro's type quirks. - String literals are shared. gcc/clang put literals in mergeable
.rodatasections and the linker folds identical strings into one object — twochar *p = "hi";in different translation units can point at the same 3 bytes. Writing one is UB; on Linux it segfaults because the page isr--(Chapter 03's PTE bits). On systems with writable code segments (or gcc's long-dead-fwritable-strings) it "worked," which is why the bug is still written. - The 128 KiB cliff is dynamic. glibc 2.35 serves requests under 128 KiB from the
brkheap; 128 KiB and above get a fresh page-alignedmmapthatfree()munmaps immediately. The threshold can grow to 32 MiB on 64-bit after large frees, so "freeing the big buffer shrinks RSS" is true only above the current threshold. offsetofandcontainer_ofare a NULL-dereference that never runs. The macro((size_t)&((struct rec *)0)->flag)computes a member's offset from a "struct at address 0" — pure compile-time arithmetic, nothing dereferenced. Formally questionable, universally implemented, and the trick behind Linux'scontainer_of.
See it yourself
The lab: make the six neighborhoods personal. The program prints where its own objects live, then dumps its own mappings — it opens /proc/self/maps itself (cat /proc/self/maps in a shell shows cat's map: "self" resolves per opener):
/* map.c — x86-64 Linux */
#include <stdio.h>
#include <stdlib.h>
static const char *lit = "a shared literal"; /* the bytes: .rodata */
static const int tbl[3] = {1, 2, 3}; /* .rodata */
static int answer = 42; /* .data */
static int zeros; /* .bss */
int main(void) {
int stack_var = 0; /* main's frame */
char *small = malloc(64); /* heap via brk */
char *big = malloc(1u << 20); /* 1 MiB >= 128 KiB: via mmap */
printf("literal %p\n", (void *)lit);
printf("const tbl %p\n", (void *)tbl);
printf("init'd 42 %p\n", (void *)&answer);
printf("zeroed %p\n", (void *)&zeros);
printf("stack %p\n", (void *)&stack_var);
printf("heap 64 B %p\n", (void *)small);
printf("mmap 1MiB %p\n", (void *)big);
FILE *f = fopen("/proc/self/maps", "r");
char line[256];
while (fgets(line, sizeof line, f)) fputs(line, stdout);
}
$ gcc -no-pie -O0 -g -o map map.c && ./map # -no-pie: textbook addresses;
literal 0x402010 # default PIE gives 0x55.. + ASLR
const tbl 0x402020
init'd 42 0x404028
zeroed 0x404038
stack 0x7ffc7a2b1c94
heap 64 B 0x4062a0
mmap 1MiB 0x7f539c000010
00400000-00401000 r--p 00000000 /tmp/map
00401000-00402000 r-xp 00001000 /tmp/map <- .text: the code
00402000-00403000 r--p 00002000 /tmp/map <- .rodata: literal, tbl
00403000-00404000 r--p 00003000 /tmp/map <- .data.rel.ro
00404000-00405000 rw-p 00004000 /tmp/map <- .data: answer (0x404028)
00405000-00406000 rw-p 00000000 00:00 0 <- .bss: zeros, no file backing
00406000-00427000 rw-p 00000000 00:00 0 [heap] <- small (0x4062a0)
...
7f539c000000-7f539e010000 rw-p 00:00 0 <- big: the 1 MiB mmap
7ffc7a29c000-7ffc7a2bd000 rw-p [stack]
What to notice: every printed address falls inside the matching line — find each one. The permission column explains the segfault before the write: .rodata is r--. The .bss line has no file backing. Run it twice: stack and mmap addresses move (ASLR, Chapter 03); the 0x40… ones do not (non-PIE). nm -n map adds the symbol view: letter codes R/D/B for rodata/data/bss.
Now break it, with two tools:
/* asan.c */
#include <stdlib.h>
int main(void) {
char *p = malloc(16);
p[20] = 'x'; /* 4 bytes past the end */
return 0;
}
$ gcc -fsanitize=address -g -O1 -o asan asan.c && ./asan
=================================================================
==2718==ERROR: AddressSanitizer: heap-buffer-overflow on address 0x6020000000d4
WRITE of size 1 at 0x6020000000d4 thread T0
#0 0x... in main /tmp/asan.c:4
0x6020000000d4 is located 4 bytes after 16-byte region [0x6020000000c0,0x6020000000d0)
allocated by thread T0 here:
#0 0x... in malloc
#1 0x... in main /tmp/asan.c:3
SUMMARY: AddressSanitizer: heap-buffer-overflow /tmp/asan.c:4
The report gives what you need: where ("4 bytes after a 16-byte region") and who allocated it (the exact line). ASan wraps malloc with poisoned redzones (at least 16 bytes on each side) plus a shadow map — 1 shadow byte per 8 memory bytes, encoding how many leading bytes are addressable. Freed blocks are poisoned and quarantined too, so use-after-free trips the same machinery. Cost: roughly 2x runtime, 2–3x memory — a debug-build tool (Chapter 21 covers the production relatives).
$ valgrind --leak-check=full ./map # no recompile; ~20-50x slowdown
==31337== HEAP SUMMARY:
==31337== in use at exit: 1,048,600 bytes in 2 blocks
==31337== definitely lost: 1,048,600 bytes in 2 blocks
valgrind runs the binary in an instrumented VM, watching every load and store; no rebuild needed, and unlike ASan it also flags reads of uninitialized memory.
How to use this knowledge
- Crash triage by address. Fault at 0x0/0x8/0x10: NULL pointer plus a small field offset — the offset names the member. Address in an
r--mapping: a literal is being written. Address near the stack pointer: frame overflow, VLA, or recursion. Match the faulting address against/proc/<pid>/mapsbefore guessing. - Free performance from layout. Sort fields by descending size (24 → 16 bytes above: a third less memory, a third fewer cache lines in arrays). Use
aligned_alloc(64, …)for per-thread buffers to dodge false sharing (Chapters 02, 20). Reach forrestrictonly in measured hot loops. - Review code with the failure catalog. Return of a local's address, writing through a pointer to a literal,
sizeof(param)that is secretly 8, the double free in the error path — you can now derive why each is broken, not memorize a lint rule. - Interview answers from first principles. "Why doesn't
freetake a size?" (metadata at p−8). "What doesmalloc(0)return?" (implementation-defined; glibc: 24 usable bytes). "How big is this struct?" (three rules, on the whiteboard). - Read other languages as layers. The JVM heap, CPython arenas, V8 spaces (Chapters 12–15) are giant C programs managing slabs of bytes — slabs they got from this chapter's
malloc(Chapter 19 shows the same bins under every runtime).
Connections
- Chapter 01 — alignment is the memory system's unit shape; padding is where that shape meets declaration order.
- Chapter 02 — cache lines justify field reordering,
aligned_alloc(64, …), and the vectorizedmemcpythe overlap rule permits. - Chapter 03 — page permissions turn
.rodatawrites into SIGSEGV; ASLR moves every lab address; mmap'd big allocations and their instant munmap. - Chapters 04, 05, 07 — frames explain free locals,
alloca's vanishing act, and silent VLA failure; the free-list machinery leaves its receipt at p−8; the prologue/epilogue build and destroy frames, andleaimplements scaled pointer arithmetic. - Chapter 06 — the counter-image: every header, tag, and box other languages add, C omits.
- Chapters 09, 10, 11 — C++ layers RAII on this exact model; Rust makes ownership compiler-enforced; Go keeps the layout game but reorders your fields.
- Chapters 19, 20, 21 — the bins under
malloc;restrictat the hardware memory-model level; the exploit families this chapter's failures become. - ROADMAP Module 02 — memory security: the attack surface is this chapter's gaps (tcache poisoning, off-by-one into chunk headers). Module 04 — decompilation is reading this chapter's layouts in reverse. Module 10 — compilers: where UB-as-license is implemented.
Forward links — where the rest of the program picks this chapter up:
- Module 02 ch02 (stack exploitation) — C's promise of nothing is the exploit's promise of everything: this chapter's contract is the attacker's opportunity.
- Module 20 ch02 (bare metal) — C without an OS: the language this chapter assumed Linux under turns out to be the language of linker scripts and startup loops.
- Module 19 ch03 (the safety spectrum) — C is the unsound-by-design point on the proof spectrum — the baseline every other language is measured against.
Quiz
free()takes only a pointer — no size, no handle. Explain why that signature forces allocator metadata to live adjacent to every heap block, and what that implies about a one-byte overflow past the end of an allocation.
- What is array decay? Name the three expression contexts in which an array name does not decay, and explain why nearly every C API that accepts a buffer must also accept a length.
- Predict
sizeofand every field offset, on x86-64 Linux, for both structs:
struct s1 { char a; char *b; char c; };
struct s2 { char *b; char a; char c; };
- Predict the output and where each object's bytes live (x86-64 Linux):
char str[] = "hello";
char *p = "hello";
printf("%zu %zu\n", sizeof(str), sizeof(p));
str[0] = 'H'; /* A */
p[0] = 'H'; /* B */
What happens at A and at B, and why?
- Diagnose the bug: why is this broken, what exactly happens to the returned pointer, and what are two correct fixes?
char *make(void) {
char buf[32];
snprintf(buf, sizeof buf, "%d", 42);
return buf;
}
- Diagnose: at
-O2(gcc 13, x86-64) this function's guard is deleted — the compiler treats it as equivalent toreturn b < 0 ? -1 : a + b;. Which rule of the standard licenses that, and what is a correct overflow guard for twosize_tvalues?
int check(int a, int b) {
int s = a + b;
if (s < a) return -1;
return s;
}
- A program runs flawlessly in development but corrupts data in production. The bug is a use-after-free: dev runs mostly touch fresh heap pages while production recycles used chunks. Name two tools or knobs that reproduce the corruption deterministically in dev, and what each does to freed memory.
- Cross-chapter:
alloca()"never fails and never leaks," yet many style guides ban it. Tie its reclamation to the exact function epilogue from Chapters 04 and 07, explain why a hugealloca(or VLA) size produces no error at all, and name the kernel version and year in which Linux finished removing VLAs.
Answers
- The signature makes memory itself the parameter block. With only a pointer value,
free()must recover size, boundaries, and status without any lookup table — none exists. The only storage the allocator controls is the memory around the block, so the size word sits atp − 8(glibc 2.35, x86-64) and free-list links live inside free chunks' user areas. A one-byte overflow past the usable area lands in the next chunk'ssizefield: nothing fails immediately; the corruption detonates when that chunk is later freed, split, or consolidated — the mechanism Chapters 05, 19, and 21 exploit.
- Decay is the implicit conversion of an array name to a pointer to its first element. The exceptions: (1) operand of
sizeof— the whole array is measured; (2) operand of unary&— the result is a pointer to the array; (3) a string literal initializing achararray — the bytes are copied in. APIs need lengths because the callee receives 8 bytes of address with the size deleted at the call boundary, so the caller must ship the count (read(fd, buf, count)).
s1: a@0, pad 7, b@8, c@16, pad 7 —sizeof24.bneeds 8-byte alignment, so 7 bytes pad after the 1-bytea;clands at 16; the total rounds up to the struct's alignment (8), adding 7 tail bytes.s2: b@0, a@8, c@9, pad 6 —sizeof16. Descending size order collects the two chars into one gap. Same fields, same alignment, 8 bytes less — the whole game is where the padding pools.
6 8.stris a 6-byte array (5 chars + NUL) in main's stack frame, initialized by copying the literal;sizeofsees the whole array.pis an 8-byte pointer; the 6 bytes it aims at live in.rodata. At A the write hits main's writable stack frame — fine. At B the write targets a read-only mapping — SIGSEGV, because the page isr--(Chapter 03). Formally B is UB; on Linux the fault is the outcome. Extra credit: identical literals can merge into one.rodataobject, so a "successful" B would corrupt every user of that string.
- Dangling pointer into a dead frame.
buflives inmake's stack frame; the epilogue restoresrsp/rbp(Chapter 07) and the 32 bytes become memory below the stack pointer, reusable by the next call — oftenprintfitself. The address is valid mapped memory whose contents can change at any moment: UB that "works in demos." Fixes: (1)malloc/strdupand hand ownership to the caller; (2) invert the contract — the caller supplies the buffer and size, keeping the memory in a frame that outlives the callee.
- Signed overflow is UB (C11 6.5/5), and UB licenses assumptions. The optimizer may assume
a + bnever wraps; thena + b < asimplifies tob < 0, the check reduces to a sign test, and the addition runs unchecked —check(INT_MAX-1, 2)returns a wrapped value with no −1. For sizes the correct guard cannot overflow:if (b > SIZE_MAX - a) fail;, or__builtin_add_overflow(a, b, &s)— arrange arithmetic whose operands cannot wrap in the first place.
- (a)
MALLOC_PERTURB_=165(glibc): freed blocks are filled with 0xa5 and fresh allocations with its complement 0x5a — the stale read now hits recognizable poison, and the uninitialized read hits garbage instead of lucky zeros. (b) AddressSanitizer (-fsanitize=address):free()moves the block into a poisoned quarantine (shadow bytes mark it dead), so the next access reportsheap-use-after-freewith both the free and allocation stacks. (c) valgrind also catches it as an invalid read, no rebuild required. Any two of the three earn the point; the shared principle is that only the allocator can make freed memory detectably dead.
- The epilogue is the deallocator. x86-64:
leaveormov rsp, rbp; pop rbp; ARM64: the restoringldp. One register write resets the stack pointer and everyalloca/VLA in the frame evaporates — no free list, no metadata, nofree()that could release it early ("never leaks" = "waits for return"). A hugealloca/VLA simply subtracts a huge value fromrsp: the stack pointer can jump past the guard page (Chapter 03), so later accesses fault at unrelated addresses — no NULL, no error code, pure UB. The Linux kernel completed its VLA purge in v4.20, late 2018, and builds with-Wvlato keep them out (CONFIG_FRAME_WARN, 2048 B default on 64-bit, needs statically auditable frames).