Pointers & Memory · advanced · ~8 min

Dangling pointers

- Define what a dangling pointer is and explain how it differs from a `NULL` pointer and a valid pointer - Identify the two most common ways a pointer becomes dangling: returning the address of a local variable, and keeping a pointer after the underlying memory is freed - Predict what undefined behaviour can do (silent corruption, crashes, security bugs) when a dangling pointer is dereferenced - Apply defensive habits: setting freed pointers to `NULL`, never returning addresses of locals, and clarifying ownership of allocations - Use compiler flags (`-Wreturn-local-addr`, `-Wdangling-pointer`) and AddressSanitizer to catch dangling-pointer bugs before they ship

Overview

A pointer is just a variable that stores a memory address. Most of the time a pointer is useful precisely because the address it holds points at something real — an int, a struct, a buffer. A dangling pointer is a pointer whose stored address used to point at something valid, but the storage at that address is gone: it was freed, or it belonged to a variable whose lifetime ended. The address value still looks perfectly normal; nothing about the pointer itself changes. What changed is the memory it refers to.

This topic sits directly on top of two ideas you have already met. From Scope and lifetime you know that local variables live only for the duration of the block or function that declares them — when the function returns, that storage is reclaimed and may be reused. From Use-after-free you know that once you call free(), the heap block is handed back to the allocator and must not be touched again. A dangling pointer is the thing that makes both of those bugs possible: it is the stale handle you are left holding after the storage has died. Reading or writing through it is the use-after-free or use-after-scope event itself.

In C this matters more than in many languages because C gives you raw addresses and no automatic lifetime tracking. The compiler will happily let you keep and dereference a pointer to memory that no longer exists. The bug rarely announces itself at the moment you create the dangling pointer; it shows up later, often far from the original mistake, which is what makes these bugs so hard to track down.

Why it matters

Dangling pointers are one of the most common sources of crashes and security vulnerabilities in C and C++ software. When you dereference a dangling pointer you trigger undefined behaviour: the program may appear to work, may print garbage, may crash, or may let an attacker influence what the freed-and-reused memory now contains.

In real systems this class of bug shows up as use-after-free vulnerabilities in browsers, operating-system kernels, and image and document parsers. Attackers prize them because a freed block can often be reallocated with data the attacker controls; a later dereference of the dangling pointer then reads or executes attacker-chosen bytes. Many high-severity CVEs over the past decade trace back to exactly this pattern. Even when there is no attacker, dangling pointers cause intermittent crashes that are extremely expensive to debug because the symptom and the cause are separated in both time and code location.

Learning to recognise and prevent dangling pointers is therefore not a niche skill — it is fundamental to writing C that is both correct and safe.

Core concepts

1. What a dangling pointer actually is

A dangling pointer holds an address that was once valid but no longer refers to live, owned storage. The pointer's value (the numeric address) is unchanged; what changed is the status of the memory it names.

It helps to separate three distinct states a pointer can be in:

State Address value Safe to dereference?
Valid points at live storage you own Yes
NULL a defined "points at nothing" value (0) No, but a deref crashes predictably
Dangling a real-looking address to dead storage No, and a deref is undefined

The dangerous case is the third one, because nothing visibly distinguishes a dangling pointer from a valid one. NULL is safer than dangling: a NULL dereference reliably faults, while a dangling dereference may silently corrupt data.

Valid:                     Dangling (after free / scope end):
  p ──► [ 42 ] live           p ──► [ ?? ] reclaimed, maybe reused
        owned by you                no longer yours

When this concept applies: any time the lifetime of the pointed-to storage can end while a pointer to it still exists. Pitfall: assuming "the pointer still has the same address, so it must still be fine." The address is irrelevant once the storage is gone.

Knowledge check: In your own words, what is the difference between a NULL pointer and a dangling pointer, and why is dereferencing the dangling one usually more dangerous?

2. Cause A — returning the address of a local variable

Local (automatic) variables live on the stack. Their storage exists only while the function is executing. When the function returns, that stack space is reclaimed and will be overwritten by the next function call. Returning &local hands the caller a pointer to storage that dies immediately.

During foo():                 After foo() returns:
  stack:                        stack:
   ┌──────────────┐              ┌──────────────┐
   │ foo: x = 42  │◄── &x        │ (reclaimed)  │◄── caller's p still points here
   └──────────────┘              └──────────────┘
        valid                         DANGLING

When to use / not use: never return &local. If a function must hand back data that outlives it, either return the value by copy, accept a caller-provided buffer to fill, or allocate on the heap with malloc and document that the caller must free it. Pitfall: returning a pointer to a local array or local struct — the same rule applies; arrays and structs are still locals.

3. Cause B — keeping a pointer to freed heap memory

Heap memory from malloc/calloc/realloc stays valid until you free it. After free, the block is returned to the allocator. Any pointer still holding that address is now dangling. A second free of the same pointer (double free) is also undefined behaviour.

ptr ──► [ heap block ]        free(ptr)        ptr ──► [ may be handed to
        valid, yours      ───────────────►             another allocation ]
                                                        DANGLING

Note that realloc can also create dangling pointers: when it moves a block, any other pointer you kept into the old location now dangles.

When to use / not use: free a block exactly once, when no live pointer needs it any more, and clear every pointer that named it. Pitfall: two pointers (aliases) to the same block — freeing through one leaves the other dangling.

Knowledge check (find-the-bug): A function does char *p = malloc(n); ... free(p); return p;. The caller checks if (p != NULL) before using it. Why does that check fail to protect the caller?

4. What "undefined behaviour" buys the program

Dereferencing a dangling pointer is undefined behaviour (UB). UB means the standard places no constraints on what happens. In practice you may see: correct-looking output (the memory was not reused yet), garbage values, a segmentation fault, heap-metadata corruption, or — in a security context — attacker-controlled behaviour. Because UB is non-deterministic, a dangling-pointer bug can pass every test on your machine and crash in production.

Knowledge check (predict-the-output): If you run a program that returns and dereferences &x from a dead function, can you rely on it crashing every time? Explain why or why not.

Syntax notes

There is no special syntax for "a dangling pointer" — it is a state, created by ordinary operations. The patterns to recognise:

int *bad_return(void) {
    int x = 42;
    return &x;          // returns address of a local -> caller gets a dangling pointer
}

void use_after_free(void) {
    char *buf = malloc(16);
    if (!buf) return;   // always check malloc
    free(buf);          // buf is now dangling
    buf[0] = 'A';       // UB: write through dangling pointer
}

The defensive idiom is to neutralise the pointer the instant the storage dies:

free(buf);
buf = NULL;             // now a later deref faults predictably instead of UB

Some teams wrap this in a helper macro so the assignment is never forgotten:

#define FREE_AND_NULL(p) do { free(p); (p) = NULL; } while (0)

Lesson

What is a dangling pointer?

A dangling pointer is a pointer that still holds an address, but the memory at that address is no longer valid. The target has either been freed or has gone out of scope.

The pointer looks fine. The memory behind it is not.

Two common ways they happen

  1. Returning a pointer to a local variable. A function returns the address of a variable that lives only inside that function.
  2. Keeping a pointer to freed heap memory. Some other part of the program calls free() on the allocation, but you still hold a pointer to it.

Why it is dangerous

Both cases lead to use-after-free bugs: you read or write memory that no longer belongs to you. The result is unpredictable, and often a security risk.

How to catch them

  • The compiler can sometimes warn you. For local-address returns, use -Wreturn-local-addr.
  • AddressSanitizer (a runtime memory-error detector) catches the cases the compiler misses, while the program runs.

Code examples

#include <stdio.h>
#include <stdlib.h>
#include <string.h>

/* WRONG: returns the address of a local variable.
   Shown only to contrast with the safe version below; do not use this. */
int *make_value_bad(int v) {
    int x = v;
    return &x;            /* x's stack storage dies at return -> dangling */
}

/* SAFE option 1: return the value by copy (no pointer to anything local). */
int make_value_ok(int v) {
    return v;
}

/* SAFE option 2: allocate on the heap; the CALLER owns and must free it.
   Returns NULL on allocation failure. */
int *make_value_heap(int v) {
    int *p = malloc(sizeof *p);
    if (p == NULL) {
        return NULL;      /* report failure instead of dereferencing NULL */
    }
    *p = v;
    return p;             /* heap storage outlives the function -> safe */
}

int main(void) {
    /* Safe by-value usage. */
    int a = make_value_ok(42);
    printf("by value: %d\n", a);

    /* Safe heap usage with explicit ownership and cleanup. */
    int *h = make_value_heap(99);
    if (h == NULL) {
        fprintf(stderr, "allocation failed\n");
        return EXIT_FAILURE;
    }
    printf("on heap: %d\n", *h);

    free(h);              /* release the block exactly once */
    h = NULL;             /* neutralise the now-dangling pointer */

    /* A later mistaken use is now a predictable crash, not silent UB:
       if (h) *h = 1;  <-- guarded; the deref is skipped because h == NULL */

    return EXIT_SUCCESS;
}

What it does: main shows three ways a function can hand back a value. make_value_bad is the broken pattern (returning &x); it is intentionally left unused. make_value_ok returns the value by copy — there is no pointer to anything that can die. make_value_heap allocates on the heap so the storage outlives the function, and it clearly transfers ownership to the caller, who frees it.

Expected output:

by value: 42
on heap: 99

Key edge cases: malloc can fail and return NULL, so every allocation is checked before use. The block is freed exactly once, and h is set to NULL immediately afterward so any later use is caught by the if (h) guard rather than dereferencing dead memory.

Line by line

Walking through make_value_heap and its use in main:

Step Code What happens
1 int *h = make_value_heap(99); Calls into the function with v = 99.
2 int *p = malloc(sizeof *p); Requests heap storage for one int. sizeof *p is sizeof(int), so the size matches the type even if the type changes later.
3 if (p == NULL) return NULL; If the OS could not give memory, the function reports failure instead of writing through a NULL pointer.
4 *p = v; Stores 99 into the heap block. This block lives on the heap, not the stack, so it survives the return.
5 return p; Hands the heap address back. Ownership transfers to the caller.
6 if (h == NULL) { ... return EXIT_FAILURE; } The caller checks the contract before using h.
7 printf("on heap: %d\n", *h); Dereferences a valid pointer — the storage is still alive. Prints 99.
8 free(h); Returns the block to the allocator. At this instant h becomes dangling.
9 h = NULL; Overwrites the stale address. h is now in the defined "points at nothing" state.
10 if (h) *h = 1; The guard is false, so the dangerous deref never runs. Without step 9 this would be a use-after-free.

The contrast with make_value_bad: there, the returned address points into stack space that is reclaimed at step "return", so the caller would be dereferencing memory the next function call is free to overwrite.

Common mistakes

Mistake 1 — returning the address of a local.

/* WRONG */
char *greeting(void) {
    char msg[] = "hello";   /* local array on the stack */
    return msg;             /* returns a dangling pointer */
}

Why it is wrong: msg lives only inside greeting; its storage is gone when the function returns. Corrected:

/* RIGHT: string literal has static lifetime */
const char *greeting(void) {
    return "hello";         /* literal lives for the whole program */
}

Recognise/prevent it: enable -Wreturn-local-addr; the compiler usually catches this exact case.

Mistake 2 — using a pointer after free.

/* WRONG */
free(node);
printf("%d\n", node->value);   /* use-after-free through dangling node */

Why it is wrong: the block is no longer yours; the read is UB. Corrected: read before freeing, then null the pointer:

int v = node->value;
free(node);
node = NULL;
printf("%d\n", v);

Recognise/prevent it: run under AddressSanitizer; set freed pointers to NULL.

Mistake 3 — freeing one alias, using another.

/* WRONG */
int *a = malloc(sizeof *a);
int *b = a;        /* b and a name the same block */
free(a);
*b = 5;            /* b is dangling */

Why it is wrong: free(a) killed the storage b also points to. Corrected: track a single owner; do not write through stale aliases. Decide which pointer owns the block and ensure no other pointer outlives the free.

Mistake 4 — double free. Calling free(p) twice (often once in a cleanup path and again in an error path) corrupts the allocator. Set p = NULL after the first free; free(NULL) is a safe no-op.

Debugging tips

Compiler-stage help (cheapest, do this first):

  • Build with -Wall -Wextra. Add -Wreturn-local-addr (GCC/Clang) to catch returning &local. Recent GCC also has -Wdangling-pointer and -Wuse-after-free.
  • Treat these warnings as errors in CI with -Werror so they cannot be ignored.

Runtime detectors (catch what the compiler cannot):

  • Compile and link with AddressSanitizer: gcc -fsanitize=address -g prog.c -o prog, then run. ASan reports heap-use-after-free or stack-use-after-return with two stack traces — where the memory was freed and where you touched it.
  • For the stack case specifically, enable ASAN_OPTIONS=detect_stack_use_after_return=1.
  • Valgrind (valgrind ./prog) also reports invalid reads/writes and "Address ... is N bytes inside a block that has been free'd".

Common symptoms and what they mean:

Symptom Likely cause
Works in debug, crashes in release UB from a dangling deref masked by debug layout
Value is correct sometimes, garbage other times freed memory not yet reused vs. reused
Crash far from the buggy line dangling pointer dereferenced long after creation
free(): invalid pointer / heap corruption abort double free or write through dangling pointer

Questions to ask when it doesn't work: Who owns this allocation? When does the pointed-to storage die? Is there more than one pointer to it? Did I set the pointer to NULL after freeing? Does the function return a pointer to anything local?

Memory safety

Dangling pointers are squarely a memory-safety and undefined-behaviour issue. Keep these specific concerns in mind:

  • Lifetimes: a pointer must never outlive the storage it names. Stack storage dies at the end of its block; heap storage dies at free. Match the pointer's usable lifetime to the storage's lifetime.
  • Ownership: for every heap allocation, exactly one part of the code should be responsible for freeing it. Aliases that outlive that free become dangling. Document ownership in comments or naming (e.g. a function that returns a pointer the caller must free).
  • Initialise and neutralise: initialise pointers (to NULL or a valid target) so an uninitialised pointer is not mistaken for valid, and set pointers to NULL right after free so a later use faults predictably.
  • Double free and realloc: freeing twice is UB; realloc may move a block and dangle every other pointer into the old location — re-fetch the returned pointer and discard the old one.
  • Bounds still matter: even a valid pointer can read out of bounds. The related exercises (char_at, index_in_bounds) practise the bounds checks that keep an element pointer in range — a complementary memory-safety habit.

Defensive practices that prevent the whole class: never return &local; free once and null; prefer passing caller-owned buffers; build with -Wall -Wextra -Werror; and run tests under AddressSanitizer so use-after-free and use-after-return are caught automatically.

Real-world uses

Concrete real-world case: Web browser engines and OS kernels are full of long-lived objects referenced from many places. When an object is freed but some reference is not cleared, a later access through that dangling reference is a use-after-free — historically one of the most exploited bug classes in browsers and in the Linux kernel. Mitigations the industry now relies on directly target dangling pointers: clearing pointers on free, delayed/quarantined freeing, and sanitizer-based CI.

Professional best-practice habits:

Beginner rules:

  • Never return the address of a local variable.
  • Check every malloc/calloc/realloc for NULL before use.
  • free each block exactly once, then set the pointer to NULL.
  • Compile with -Wall -Wextra and read the warnings.

Advanced habits:

  • Make ownership explicit: name or document which function frees a returned pointer; prefer a single owner with borrowed (non-owning) references that never outlive the owner.
  • Run the test suite under AddressSanitizer (and Valgrind) in CI, with -Werror for memory-related warnings.
  • For structures with many references, consider clearing all aliases on teardown, or use designs (arenas, reference counting) that make lifetimes easier to reason about.
  • Keep allocation and freeing symmetric and close together where possible, so the lifetime is easy to see at a glance.

Practice tasks

Beginner 1 — Spot the dangling return. Objective: read three short functions and decide which returns a dangling pointer. Requirements: for each, state in one sentence whether the returned pointer is safe and why (consider where the storage lives). Constraints: no code changes, just analysis. Hint: ask "does this storage survive the return?" Concepts: scope/lifetime, returning &local.

Beginner 2 — Free-and-null helper. Objective: write void destroy(int **p) that frees *p and sets *p = NULL. Requirements: it must be safe to call when *p is already NULL (remember free(NULL) is fine). Input/output: after destroy(&q), q == NULL. Constraints: take a pointer-to-pointer so you can null the caller's variable. Hint: dereference once to free, once to assign. Concepts: free, neutralising dangling pointers.

Intermediate 1 — Heap factory with ownership contract. Objective: implement char *dup_upper(const char *s) that returns a newly allocated uppercase copy of s. Requirements: allocate the right length (+1 for the terminator), check malloc, copy and uppercase each byte, and document that the caller must free the result. Return NULL on allocation failure. I/O example: dup_upper("abc") -> "ABC". Constraints: no fixed-size buffer. Hint: strlen, then malloc(len + 1). Concepts: heap lifetime, ownership transfer, NULL checks.

Intermediate 2 — Catch it with a sanitizer. Objective: write a tiny program that deliberately uses a heap pointer after free, then compile it with -fsanitize=address -g and capture the report. Requirements: paste the heap-use-after-free message and identify which line freed the block and which line used it. Then fix the bug (free-and-null) and confirm the report disappears. Constraints: run only locally. Hint: ASan prints two stack traces. Concepts: use-after-free, AddressSanitizer workflow.

Challenge — Safe singly linked list teardown. Objective: implement a singly linked list of int with push, pop, and free_list, with zero dangling-pointer bugs. Requirements: each node is heap-allocated and checked; pop reads the value, unlinks the node, frees it, and never touches it afterward; free_list walks the list saving next before freeing the current node, and leaves the head NULL. Constraints: must pass cleanly under AddressSanitizer with no leaks and no use-after-free. Hint: the classic teardown bug is free(cur); cur = cur->next; — save next first. Concepts: ownership, save-before-free ordering, neutralising pointers.

Summary

A dangling pointer holds an address whose storage is no longer valid — it was freed or its scope ended. The pointer looks fine; the memory behind it is not. The two classic causes are returning the address of a local variable (stack storage dies at return) and keeping a pointer after free (heap block returned to the allocator). Aliases and realloc moves create the same hazard, and freeing twice (double free) is its own UB.

The most important habits: never return &local (return by value, fill a caller's buffer, or malloc and transfer ownership); free exactly once and immediately set the pointer to NULL; check every allocation; and make ownership explicit. Dereferencing a dangling pointer is undefined behaviour — it may work, may print garbage, may crash, or may become a security hole, and the symptom is often far from the cause.

Catch these bugs early: compile with -Wall -Wextra plus -Wreturn-local-addr/-Wdangling-pointer, treat warnings as errors, and run your tests under AddressSanitizer. Remember the key contrast: NULL fails predictably; dangling fails unpredictably — which is exactly why nulling a freed pointer is worth the one extra line.

Practice with these exercises