C Pointers and malloc: Use-After-Free, Leaks and ASan
C lecture 3, run on Apple clang 21: & and *, malloc/free, use-after-free and double free in ASan, leaks --atExit on macOS, and realloc growth measured.
Series: C lecture 3/6 (intermediate). For readers who can write functions and loops in C (lecture 2) and now meet * and & everywhere in real code. After this lecture you can explain what a pointer holds, allocate and free memory correctly, and read the AddressSanitizer reports for the four classic heap bugs, plus find leaks with the leaks tool that ships with macOS.
Pointer bugs are the reason C has its reputation. They rarely crash where the mistake is; they corrupt something that crashes later, or they never crash and quietly return stale data. If you work on a native library, a game, an embedded device or a C extension for Python or PHP, these are the bugs that take days. Everything below was compiled and run with Apple clang 21.0.0 on an Apple Silicon Mac (M1 Pro), macOS 26; Valgrind and gdb were not used (neither ships with macOS), and paths in output are shortened to ~/demo/.
What a pointer is, in one program
A pointer is a variable whose value is a memory address. &x gives the address of x; *p follows the address in p (called dereferencing).
#include <stdio.h>
void swap_wrong(int a, int b) { int t = a; a = b; b = t; }
void swap(int *a, int *b) { int t = *a; *a = *b; *b = t; }
int main(void) {
int x = 10;
int *p = &x; /* p holds the address of x */
printf("x=%d &x=%p p=%p *p=%d\n", x, (void *)&x, (void *)p, *p);
*p = 20; /* write through the pointer */
printf("after *p = 20: x=%d\n", x);
printf("sizeof x=%zu sizeof p=%zu\n", sizeof x, sizeof p);
int a = 1, b = 2;
swap_wrong(a, b);
printf("swap_wrong: a=%d b=%d\n", a, b);
swap(&a, &b);
printf("swap: a=%d b=%d\n", a, b);
int arr[4] = {10, 20, 30, 40};
int *q = arr;
printf("*q=%d *(q+2)=%d q[3]=%d (q+1)-q=%td\n", *q, *(q + 2), q[3], (q + 1) - q);
return 0;
}
x=10 &x=0x16dcba578 p=0x16dcba578 *p=10
after *p = 20: x=20
sizeof x=4 sizeof p=8
swap_wrong: a=1 b=2
swap: a=2 b=1
*q=10 *(q+2)=30 q[3]=40 (q+1)-q=1
Three facts to keep:
- C passes arguments by value.
swap_wrongswapped its own copies. To let a function change your variable, pass its address. This is whyscanf("%d", &n)needs the&. - A pointer is 8 bytes here whatever it points to, while
intis 4. - Pointer arithmetic counts elements, not bytes.
q + 2moves 2 ints (8 bytes), andq[3]is defined as*(q + 3). That is why the array decay from lecture 2 works.
Where memory lives: stack, heap, static
global 0x104d00000
stack 0x16b1064a8
heap 0x1054bd970
code 0x104cf84f8
That printout (addresses differ each run) shows the three places a C variable can live:
| Storage | How you get it | Lifetime | Typical mistake |
|---|---|---|---|
| stack | local variable int local; |
until the function returns | returning &local |
| heap | malloc, calloc, realloc |
until you call free |
forgetting free, or using after free |
| static | global or static variable |
the whole program | hidden shared state |
The stack is freed for you, which is exactly why returning a pointer to a local is wrong:
int *make_counter(void) {
int count = 0;
return &count;
}
dangling.c:4:13: warning: address of stack memory associated with local variable 'count' returned [-Wreturn-stack-address]
It still printed 0 when run, because nothing had overwritten that stack slot yet. Plain ASan also stayed silent; only ASAN_OPTIONS=detect_stack_use_after_return=1 produced ERROR: AddressSanitizer: stack-use-after-return ... in main dangling.c:8. Trust the compiler warning.
malloc and free, done correctly
int n = 5;
int *a = malloc(n * sizeof *a); /* bytes = count * element size */
if (a == NULL) { perror("malloc"); return 1; }
for (int i = 0; i < n; i++) a[i] = i * i;
printf("a[4]=%d\n", a[4]); /* a[4]=16 */
free(a);
a = NULL; /* no dangling pointer left */
malloctakes a size in bytes and returns an uninitialized block, orNULLwhen it fails.calloc(n, sizeof *a)returns zeroed memory.sizeof *a(the size of whatapoints to) stays correct if you later changeinttolong.sizeof(int)does not.- Every successful
mallocneeds exactly onefree. Afterfree, the pointer still holds the old address; it is now a dangling pointer. Setting it toNULLmakes later mistakes loud (a NULL dereference crashes) or harmless (free(NULL)does nothing by definition).
Four heap bugs, with and without ASan
We put four versions into one file, selected by a command-line argument, and built it twice: plain (-g) and with -fsanitize=address -g.
if (mode == 1) { /* forgot sizeof(int) */
int *a = malloc(n);
for (int i = 0; i < n; i++) a[i] = i;
printf("a[4]=%d\n", a[4]);
free(a);
} else if (mode == 2) { /* use after free */
int *a = malloc(n * sizeof *a);
a[0] = 42;
free(a);
printf("a[0]=%d\n", a[0]);
} else if (mode == 3) { /* double free */
int *a = malloc(n * sizeof *a);
free(a);
free(a);
printf("freed twice\n");
}
| Bug | Plain build | With ASan |
|---|---|---|
malloc(n) instead of malloc(n * sizeof *a) |
a[4]=4, exit 0 |
heap-buffer-overflow, line 11 |
| use after free | a[0]=381779559 once, a[0]=7 later, exit 0 |
heap-use-after-free, line 18 |
| double free | no message, Trace/BPT trap: 5 (exit 133) or Abort trap: 6 (exit 134) |
attempting double-free, line 22 |
*hit where hit is NULL |
Segmentation fault: 11 (exit 139) |
SEGV on unknown address 0x000000000000, line 13 |
The first two are the dangerous ones: the plain build exits 0 with a plausible-looking number. The malloc(n) bug wrote 20 bytes into a 5-byte block; macOS's allocator happened to have room after it, so nothing visible happened. Here is the use-after-free report, trimmed:
==76039==ERROR: AddressSanitizer: heap-use-after-free on address 0x603000001c30 ...
READ of size 4 at 0x603000001c30 thread T0
#0 0x000100ed4ac4 in main heapbugs.c:18
0x603000001c30 is located 0 bytes inside of 20-byte region [0x603000001c30,0x603000001c44)
freed by thread T0 here:
#0 0x000101971368 in free+0x7c (libclang_rt.asan_osx_dynamic.dylib:arm64e+0x41368)
#1 0x000100ed4a74 in main heapbugs.c:17
previously allocated by thread T0 here:
#0 0x000101971274 in malloc+0x78 (libclang_rt.asan_osx_dynamic.dylib:arm64e+0x41274)
#1 0x000100ed4a08 in main heapbugs.c:15
SUMMARY: AddressSanitizer: heap-use-after-free heapbugs.c:18 in main
Three stack traces, three lines: where you read (18), where it was freed (17), where it was allocated (15). In a real program those are often in three different files, and this report saves the day of searching.
Two more things we checked. A NULL pointer and an uninitialized pointer fail differently: int *p; *p = 5; got -Wuninitialized at compile time and died with Bus error: 10 (exit 138), not a segfault. In lldb the leftover value was p = 0x00000001f5578100, which pointed into a read-only (r--) page of the dynamic loader; writing to read-only memory is reported as a bus error on macOS. A different leftover value would give a different signal.
And clang's static analyzer (clang --analyze, part of the same toolchain) needs no run at all. Over the eight bugs in this lecture it reported five: the uninitialized pointer, the returned stack address, both use-after-frees (Use of memory after it is freed [unix.Malloc]) and the double free (Attempt to free released memory). It missed the malloc(n) overflow, the NULL returned by find(), and the leak below.
Leaks: the macOS way
A leak is memory you allocated and can no longer free, because you lost the pointer:
char *greet(const char *name) {
char *buf = malloc(64);
if (!buf) return NULL;
snprintf(buf, 64, "Hello, %s!", name);
return buf; /* caller must free */
}
int main(void) {
for (int i = 0; i < 3; i++) {
char *msg = greet("leak");
printf("%s\n", msg);
/* free(msg); <- forgotten */
}
return 0;
}
On Linux you would reach for Valgrind or ASan's LeakSanitizer. On Apple Silicon macOS neither works here:
$ ASAN_OPTIONS=detect_leaks=1 ./leak_asan
==77114==AddressSanitizer: detect_leaks is not supported on this platform.
$ clang -std=c17 -g -fsanitize=leak -o leak_lsan leak.c
clang: error: unsupported option '-fsanitize=leak' for target 'arm64-apple-darwin25.6.0'
Worse, detect_leaks=1 made the program abort (exit 134) instead of ignoring the option, so do not leave it in a shared ASAN_OPTIONS. What does work is the leaks command in /usr/bin (trimmed):
$ leaks --atExit -- ./leak
Hello, leak!
Hello, leak!
Hello, leak!
Process 77064: 192 nodes malloced for 31 KB
Process 77064: 3 leaks for 240 total leaked bytes.
STACK OF 3 INSTANCES OF 'ROOT LEAK: <malloc in greet>':
2 leak 0x104684508 main + 52 leak.c:14
1 leak 0x104684478 greet + 24 leak.c:6
====
3 (240 bytes) << TOTAL >>
1 (80 bytes) ROOT LEAK: <malloc in greet 0xb14c08050> [80]
Note 80 bytes for a malloc(64): the allocator rounds up to its size class. leaks exited with status 1; after adding free(msg); it printed 0 leaks for 0 total leaked bytes. and exited 0, so it can gate a test script. Build with -g to get the leak.c:14 line numbers.
Which tool for which bug
| Symptom or bug | First tool | Notes from our runs |
|---|---|---|
returning &local, uninitialized pointer |
compiler warnings -Wall -Wextra |
caught at compile time |
| heap overflow, use-after-free, double free | -fsanitize=address -g |
exact lines for read, free, malloc |
| use-after-return | ASan + detect_stack_use_after_return=1 |
off by default |
| leak on macOS | leaks --atExit -- ./prog |
exit 1 if leaks; ASan cannot here |
| quick check without running | clang --analyze |
found 5 of our 8 bugs |
| crash in a big program | lldb (lecture 6) |
backtrace and variables |
Common mistake → what actually happens → fix
| Mistake | What we saw | Fix |
|---|---|---|
malloc(n) for n ints |
silent overflow, exit 0 | malloc(n * sizeof *p) or calloc(n, sizeof *p) |
not checking malloc's result |
NULL dereference: exit 139 | if (p == NULL) every time |
p = realloc(p, bigger) |
if it fails, the old block leaks | tmp = realloc(p, ...); if (!tmp) {...} p = tmp; |
freeing nodes while walking p = p->next |
Abort trap: 6, exit 134 (exercise 2) |
save next before free |
keeping p after free |
stale data or a later crash | free(p); p = NULL; |
Measured: how to grow a heap array
realloc(p, size) resizes a block, moving it if needed. The question is how often to call it. We appended 1,000,000 ints three ways, -O2, 5 runs, median, counting how many times the block actually moved (tmp != a):
size_t newcap = strategy == 0 ? cap + 1 : (cap ? cap * 2 : 4);
int *tmp = realloc(a, newcap * sizeof *tmp);
if (!tmp) { free(a); exit(1); }
if (tmp != a) moves++;
a = tmp;
| Strategy | realloc calls | Block moved | Median time |
|---|---|---|---|
| grow by 1 element | 1,000,000 | 42 | 20.33 ms |
| double the capacity | 19 | 14 | 0.76 ms |
one malloc of the final size |
0 | 0 | 0.61 ms |
Two surprises. Growing by one was "only" 27× slower than doubling, because macOS's allocator grew the block in place 999,958 times out of a million; it moved it just 42 times. Do not count on that: another allocator (glibc, Windows, an embedded heap) may copy far more often (not run here). And doubling was within 25% of knowing the size in advance. That is why std::vector, Python lists and most dynamic arrays grow geometrically.
Honest limits / version notes
- Run only with Apple clang 21.0.0 on arm64 macOS 26. Valgrind, gdb and Linux's LeakSanitizer are described, not run.
leaksis part of macOS developer tools; its output format is Apple's, and--atExitis the form that worked here.- On Linux x86-64,
-fsanitize=addressincludes leak detection by default (not run here). - The garbage values after use-after-free and the signal for double free (133 vs 134) changed between runs on the same machine. That variability is the point.
%tdfor pointer differences and%zuforsizeofare C99.nullptris C23 (-std=c23); this lecture usesNULLand C17.
Exercises
1. Write int *make_range(int start, int n) that returns a new heap array {start, start+1, ..., start+n-1}, and a main that prints it and frees it. Check it with ASan and with leaks --atExit.
2. This list cleanup aborts with Abort trap: 6. Explain why and fix it.
for (struct node *p = head; p != NULL; p = p->next)
free(p);
Answers
1.
int *make_range(int start, int n) {
if (n <= 0) return NULL;
int *a = malloc((size_t)n * sizeof *a);
if (a == NULL) return NULL;
for (int i = 0; i < n; i++)
a[i] = start + i;
return a; /* caller frees */
}
make_range(10, 5) printed 10 11 12 13 14; ASan and UBSan were silent and leaks reported 0 leaks for 0 total leaked bytes. The (size_t) cast does the multiplication in the type malloc expects, so a large n cannot overflow int first (lecture 1).
2. p = p->next runs after free(p), so it reads a freed node. ASan says heap-use-after-free ... READ of size 8 ... ex_free.c:15 and freed by ... ex_free.c:16; size 8 is the next pointer. Save it first:
while (head != NULL) {
struct node *next = head->next; /* read before free */
free(head);
head = next;
}
This version printed done and ran clean under ASan. Our linked list article (C++, with ASan) has more of these edge cases.
Next
Lecture 4 moves to strings: the NUL terminator, why strcpy and even strncpy bite, struct padding measured with sizeof and offsetof, and a stack buffer overflow caught by ASan.
- Previous: C lecture 2/6: loops, functions and arrays
- Next: C lecture 4/6: strings, structs and buffer overflows
- Later: undefined behaviour and optimization, cache, make and lldb; start of the series: lecture 1
- Related: a stack in C++ with ASan findings, programming learning roadmap
FAQ
How do I find memory leaks in C on macOS?
Use the leaks command that ships with macOS: leaks --atExit -- ./prog. On our program it reported "3 leaks for 240 total leaked bytes" with the line of each malloc (build with -g), and it exits with status 1 when it finds leaks, so it works in scripts. AddressSanitizer's leak detection is not available on Apple Silicon macOS: ASAN_OPTIONS=detect_leaks=1 printed "detect_leaks is not supported on this platform", and -fsanitize=leak is rejected by Apple clang 21.
What happens if I use memory after free() in C?
Without tools, usually nothing visible: our program printed a[0]=381779559 in one run and a[0]=7 in later runs, and exited 0. The memory belongs to the allocator again and can be reused at any time. Built with -fsanitize=address, it stops immediately with heap-use-after-free and shows the lines of the read, the free and the original malloc.
Is it safe to call free() twice on the same pointer?
No, it is undefined behaviour. On macOS 26 our program died with no message, sometimes with Trace/BPT trap: 5 (exit 133), sometimes with Abort trap: 6 (exit 134). ASan reports "attempting double-free". Setting the pointer to NULL after free helps, because free(NULL) is defined to do nothing.