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:

  1. C passes arguments by value. swap_wrong swapped its own copies. To let a function change your variable, pass its address. This is why scanf("%d", &n) needs the &.
  2. A pointer is 8 bytes here whatever it points to, while int is 4.
  3. Pointer arithmetic counts elements, not bytes. q + 2 moves 2 ints (8 bytes), and q[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 */
  • malloc takes a size in bytes and returns an uninitialized block, or NULL when it fails. calloc(n, sizeof *a) returns zeroed memory.
  • sizeof *a (the size of what a points to) stays correct if you later change int to long. sizeof(int) does not.
  • Every successful malloc needs exactly one free. After free, the pointer still holds the old address; it is now a dangling pointer. Setting it to NULL makes 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

Diagram. The correct path runs left to right: malloc and a NULL check, then use the block within its size, then free(p), then set p to NULL. Three wrong paths branch off. Forgetting free after use is a leak, found by leaks --atExit. Reading through p after free is a heap-use-after-free, reported by AddressSanitizer. Calling free on the same pointer again is a double-free, also reported by AddressSanitizer; without it the program died with a trap signal and no message.
The life of a heap block, and the three ways to break it. Each broken path shows what caught it in our runs on macOS.
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
Bar chart. Calling realloc for every new element, one million calls, took 20.33 milliseconds. Doubling the capacity, 19 realloc calls, took 0.76 milliseconds. One malloc of the final size took 0.61 milliseconds.
Appending 1,000,000 ints to a heap array, three growth strategies. Median of 5 runs, -O2, Apple Silicon Mac (M1 Pro), macOS 26.

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.
  • leaks is part of macOS developer tools; its output format is Apple's, and --atExit is the form that worked here.
  • On Linux x86-64, -fsanitize=address includes 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.
  • %td for pointer differences and %zu for sizeof are C99. nullptr is C23 (-std=c23); this lecture uses NULL and 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.

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.

  • #c
  • #intermediate
  • #pointers
  • #malloc
  • #addresssanitizer
  • #memory-leak

t.laboratory

A technical blog that records real problems from the apps, servers and services we build and run — and the code that fixed them.

About · Apps

↑