CWE-416: Use After Free - C
Overview
In C, use-after-free occurs when code dereferences a pointer after free() has already run on it. Because C has no automatic tracking of pointer validity, this is entirely a discipline problem: nothing stops a stale pointer from being dereferenced or passed to another function after the memory it referred to has been released and possibly reused.
Primary Defence: Decide which single component owns each allocation and give it a release function taking T **, so releasing nulls the owner's variable rather than a copy of it. Where a pointer has to outlive the call that hands it out - a deferred callback, a work queue - pass a handle the receiver can revalidate instead of a raw address. Nulling after free() is what makes an escaped use fail fast rather than corrupt memory; it is the mitigation, and it only ever reaches the one variable you assign to.
Common Vulnerable Patterns
Classic Use-After-Free
// VULNERABLE - writes through buf after it has already been freed
char *buf = malloc(100);
free(buf);
strcpy(buf, "data"); // USE AFTER FREE
Why this is vulnerable: buf still holds the address of the freed block. strcpy writes through it as if it were still valid, corrupting whatever the allocator has since done with that memory - which may already belong to a different, unrelated allocation.
Dangling Alias
// VULNERABLE - alias still points at obj's memory after obj is freed
struct Object *obj = malloc(sizeof(*obj));
struct Object *alias = obj;
free(obj);
alias->field = 5; // USE AFTER FREE
Why this is vulnerable: obj and alias point to the same allocation, but freeing obj doesn't affect alias - there's no way in C to invalidate every pointer that happens to hold the same address. alias is now dangling, and using it corrupts memory that may have been reallocated elsewhere.
Callback With Freed Context
// VULNERABLE - ctx may already be freed by the time the callback fires
void callback(void *ctx) {
MyData *data = (MyData*)ctx;
data->value = 42; // ctx might already be freed by the time this runs
}
Why this is vulnerable: The callback receives a raw void* with no lifetime guarantee attached. If whatever registered the callback has already freed ctx by the time the callback fires - common with asynchronous or deferred callbacks - the callback corrupts freed memory. Any API that hands out a context pointer to a deferred callback needs an explicit contract for how long that pointer stays valid, and the caller must not free it before the callback either fires or is cancelled.
Secure Patterns
NULL After Free, Check Before Use
#include <stdlib.h>
struct Session { int fd; };
void close_session(struct Session **sp) {
free(*sp);
*sp = NULL; // assigns through to whatever variable was passed
}
// serve() declares `session`, so it is serve()'s own variable that gets nulled.
void serve(int fd) {
struct Session *session = session_open(fd);
if (session == NULL) return;
if (session_expired(session)) {
close_session(&session); // one path releases early
}
// Reached on both paths. The guard is meaningful because one of them released.
if (session != NULL) {
session_touch(session);
}
close_session(&session); // no-op if it was already closed above
}
Why this works: Nulling session at the moment of release turns a later accidental use into an immediate, predictable fault at the line that has the bug, instead of a read or write into memory the allocator may have handed to something else. That is a strictly worse outcome for an attacker and a strictly better one for whoever debugs it: a null dereference faults at a known address with a stack trace pointing at the use, where a use-after-free typically produces no symptom at all until an unrelated structure misbehaves later.
Whose variable gets nulled is the part that has to be right, and & is not enough to settle it. close_session assigns through the pointer it was handed, so the fix only works when that pointer is the address of the variable the caller will go on to use. In serve it is: session is serve's own local, declared there, and the guard below genuinely reads NULL on the early-release path.
Passing ¶m from a function whose parameter is the pointer looks identical and does nothing:
// VULNERABLE - &session is the address of a local copy of the caller's pointer
void handle_request(struct Session *session) {
close_session(&session); // nulls handle_request's parameter and nothing else
} // the caller's variable is still dangling here
session here is a by-value parameter. Taking its address gives the address of that copy, which stops existing at the closing brace, so close_session nulls a variable nobody will ever read and the caller is left exactly where it started. A function that wants to invalidate its caller's pointer has to take struct Session ** itself and pass it straight down - the ** has to run the whole length of the chain, not just the last call in it.
The guard is also only worth writing where the code genuinely has an optional-session path, as serve does. An if (p != NULL) wrapped around code that cannot cope with absence converts a crash into silent wrong behaviour.
The limit is the same one that runs through every pattern here: this protects the variable whose address was passed. Every other copy of that pointer is untouched, which is the next pattern's problem.
Bound Pointer Lifetime to a Clear Owner
#include <stdlib.h>
typedef struct {
char *data;
size_t size;
} Buffer;
Buffer *buffer_create(size_t size) {
Buffer *buf = malloc(sizeof *buf);
if (buf == NULL) return NULL;
buf->data = malloc(size);
if (buf->data == NULL) { // without this, the caller gets a Buffer with no data
free(buf);
return NULL;
}
buf->size = size;
return buf;
}
void buffer_destroy(Buffer **buf) {
if (buf == NULL || *buf == NULL) return;
free((*buf)->data);
free(*buf);
*buf = NULL; // caller's pointer is nulled too - can't be used after this call
}
Why this works: One function allocates the whole object and one function releases the whole object, so no caller ever holds a half-built Buffer or has to remember which of its members it is responsible for. buffer_destroy takes Buffer ** for the reason above - it can null the caller's variable, so the one pointer the caller has left after the call is already NULL.
The second allocation failure is checked because the alternative is worse than a leak. Returning buf with buf->data == NULL hands back an object that passes the caller's if (buf != NULL) check and then faults, or silently reads nothing, at whatever line first touches data - the failure has been moved away from its cause. sizeof *buf rather than sizeof(Buffer) keeps the allocation correct if the variable's type is ever changed.
This still only protects the specific variable passed to buffer_destroy. Any other alias needs its own explicit invalidation, or the design needs to stop producing aliases - which is what the next pattern is about.
Give a Deferred Callback a Lifetime It Can Check
#include <stdint.h>
#include <stdlib.h>
#define MAX_SLOTS 256
typedef struct { int value; } MyData;
// Answers "Callback With Freed Context" above. The callback receives a handle
// it can validate, not an address it has to trust.
typedef struct {
MyData *data; // NULL once the slot is released
uint64_t generation; // incremented on every release; never reused
} Slot;
static Slot slots[MAX_SLOTS];
typedef struct { size_t index; uint64_t generation; } Handle;
#define INVALID_HANDLE ((Handle){ MAX_SLOTS, 0 })
// The table chooses the slot, so no caller can name one that is out of range
// or already occupied. Ownership of `data` passes to the table on success;
// on INVALID_HANDLE the caller still owns it and must free it.
Handle slot_acquire(MyData *data) {
for (size_t i = 0; i < MAX_SLOTS; i++) {
if (slots[i].data != NULL) continue; // never overwrite a live slot
slots[i].data = data;
return (Handle){ i, slots[i].generation };
}
return INVALID_HANDLE; // table full
}
static MyData *slot_resolve(Handle h) {
if (h.index >= MAX_SLOTS) return NULL;
Slot *s = &slots[h.index];
if (s->generation != h.generation) return NULL; // released since h was issued
return s->data;
}
// Takes a Handle rather than an index, so it inherits the same validity test
// the callback uses - a stale handle cannot release whatever now occupies
// the slot it once named.
int slot_release(Handle h) {
if (slot_resolve(h) == NULL) return -1; // out of range, stale, or already free
free(slots[h.index].data);
slots[h.index].data = NULL;
slots[h.index].generation++; // every Handle already issued is now stale
return 0;
}
// The callback takes the handle by value, so it can always ask
void callback(Handle h) {
MyData *data = slot_resolve(h);
if (data == NULL) return; // context is gone; nothing to do
data->value = 42;
}
Why this works: The callback no longer holds an address whose validity it has no way to determine. It holds an index plus a generation counter, and slot_resolve compares that counter against the slot's current one - so a handle issued before a release fails to resolve, and the callback takes the "context is gone" branch instead of writing into freed memory. The counter is what makes this work rather than the NULL check: an index alone would resolve happily if the slot had been released and reused for a different object in between, which is the same use-after-free with a different address.
uint64_t is chosen so that wraparound is not a real concern; a uint16_t generation on a hot path can wrap and make a stale handle resolve again, which reintroduces exactly the bug being closed.
The generation only rules out a handle once the generation has moved, so every path that ends a slot's occupancy has to move it. Release does, which is the case people write first. The one that gets missed is re-acquiring a slot that is still occupied: overwriting slots[i].data without touching slots[i].generation leaves every handle already issued for that slot resolving successfully - to a different object, at a different address, with no free having happened anywhere. Release-then-reacquire is the case that gets tested and it is safe; acquire-over-acquire is the one that is not.
This example refuses to be in that position rather than detecting it. slot_acquire takes no index at all: it scans for a slot whose data is NULL and returns INVALID_HANDLE if there is none, so an occupied slot is never overwritten and a caller cannot pass an index that is out of range. That is one check replacing two, and it removes a class of caller error instead of validating against it. slot_release takes a Handle rather than an index for the same reason - it reuses slot_resolve, so it inherits the range test and the generation test, and a stale handle cannot free whatever now occupies the slot it used to name.
Both of those are worth stating because a table indexed by an untrusted integer is CWE-823 sitting inside the fix for CWE-416 - slots[index] is pointer arithmetic like any other. Bounds-checking only the read path is the usual version of that mistake, because the read path is the one the security discussion is about.
Two lighter-weight alternatives are often enough and are worth preferring when they fit. If the callback is cancellable, cancel it and wait for confirmation that it is not currently running before freeing the context - the wait is the part usually missing, and without it the cancel races the dispatch. If the object is small and shareable, give it a reference count that the registration increments and the callback decrements, so the context outlives the callback by construction. Both fail in the same way if the ownership rule is left to a comment; whichever is chosen, put it in the registration function's signature.
Testing
- Compile with AddressSanitizer (
-fsanitize=address -g -O1) and run the test suite - it reports use-after-free immediately with both the free and the invalid access call stacks. RaiseASAN_OPTIONS=quarantine_size_mb=512when a test does not reproduce: ASan can only detect the access while the block is still quarantined, and a busy allocation path recycles it out of quarantine. - Run under Valgrind with
--track-origins=yesfor an independent check, and with--freelist-volraised for the same reason. - Assert that a released handle stops resolving, against a slot that has been reused rather than left empty. For the generation pattern above: acquire a handle, release it, acquire a second object (which takes the same slot, since it is the first free one), then call the first handle's callback and confirm it takes the "context is gone" branch. The reuse is the whole point - a version checking only
data != NULLpasses while the slot is empty and fails here, which is the case that matters. - Assert the same for a handle whose slot was never released. Acquire, then acquire a second object without releasing the first, and confirm the second call returns
INVALID_HANDLErather than silently taking the slot. A table that overwrites an occupied entry without moving its generation leaves the first handle resolving to the second object - a use-after-free with nofreein it, which is why this test does not fall out of the release-path one. - Assert that
slot_releaserejects a stale handle. Acquire, release, acquire again, then release through the first handle and confirm it returns non-zero and the second object is still resolvable. Releasing by bare index passes this test by accident and frees the wrong object. - Order the teardown against the callback deliberately. Free the context first and then dispatch, not the other way round, since the convenient order is the one that never exercises the fix.
- Specifically test any code that keeps more than one pointer to the same allocation, and confirm that the release function nulls the caller's variable - assert
p == NULLafter the call, not just that the program did not crash.