CWE-824: Access of Uninitialized Pointer - C
Overview
In C, a pointer with automatic storage duration that isn't explicitly assigned has an indeterminate value - there is no automatic default, and no guarantee about what it holds. In practice it is whatever bit pattern the stack slot last contained, so using it before assignment - dereferencing it, freeing it, or passing it to a function that does either - reads or writes through an address the program did not choose.
The standard is stricter than "it holds garbage", and the difference matters when reasoning about what an optimizing compiler will do. Reading an indeterminate value is undefined behaviour, not merely unspecified, so the compiler is not obliged to load anything at all: it may assume the read never happens and optimise the surrounding branch accordingly. That is why the same source can fault in a debug build and take a different path entirely in a release build.
Primary Defense: Declare each pointer at the point where it is first assigned, so there is no window in which it holds an indeterminate value. Where the variable genuinely has to exist before that - a goto cleanup epilogue, a struct field, a value set in one branch and used after - initialize it to NULL at the declaration and check before dereferencing. Considerations covers why the order of those two matters and what the blanket = NULL habit costs.
Not This Weakness: Unchecked Return Values
An unchecked fopen result is the pattern most often filed here by mistake, and
it is a different weakness:
FILE *fp = fopen(filename, "r");
char buffer[100];
fgets(buffer, sizeof buffer, fp); // fp may be NULL - but it is not uninitialized
fp is initialized. It holds the value fopen returns to report failure, so
this is CWE-252 (Unchecked Return Value) reaching
CWE-476 (NULL Pointer Dereference), and CWE-476's page
carries the guidance. MITRE scopes CWE-824 to a pointer that was never assigned
at all.
The distinction changes almost nothing about the fix - both want a check before the dereference - and quite a lot about the severity, which is why it is worth getting right when recording a finding rather than when closing one. A null dereference faults at address zero on any hosted platform that leaves the zero page unmapped: predictable, immediate, a denial of service. An uninitialized pointer holds whatever the program last left in that stack slot, so it may point at live, writable memory and cause no fault at all - the reason CWE-824 is rated High to Critical and CWE-476 Medium. Treating the two as interchangeable imports the wrong risk rating in whichever direction you happen to guess.
Common Vulnerable Patterns
Conditionally Assigned Pointer
// VULNERABLE - buffer is only assigned on one branch
void process(int condition, const char *user_input) {
char *buffer;
if (condition) {
buffer = malloc(100);
}
// if condition is false, buffer is never assigned
strcpy(buffer, user_input); // dereferences a garbage pointer
}
Why this is vulnerable: When condition is false, buffer is never assigned, so strcpy writes through whatever address happened to already be in that stack slot. The result ranges from an immediate crash to a write at an attacker-influenced address if that stack memory was previously under attacker control.
Uninitialized Struct Members
// VULNERABLE - pointer members have no default value in C
struct Config {
char *name;
int *values;
};
struct Config cfg;
printf("%s", cfg.name); // reads an uninitialized pointer
Why this is vulnerable: C does not zero-initialize automatic-storage struct members. Declaring struct Config cfg; on the stack leaves cfg.name and cfg.values pointing at whatever the stack previously held.
Secure Patterns
Restructure So the Pointer Is Never Unassigned
#include <stdlib.h>
#include <string.h>
#define BUFFER_SIZE 100
// Returns 0 on success, -1 on failure. The caller is told which happened.
int process(int condition, const char *user_input) {
if (!condition) {
return -1; // nothing to process - an outcome, not a skipped block
}
char *buffer = malloc(BUFFER_SIZE); // declared at its first assignment
if (buffer == NULL) {
return -1; // allocation failure, reported rather than absorbed
}
strncpy(buffer, user_input, BUFFER_SIZE - 1);
buffer[BUFFER_SIZE - 1] = '\0'; // strncpy does not terminate on truncation
consume(buffer);
free(buffer);
return 0;
}
Why this works: There is no window in which buffer is unassigned, because the declaration is the assignment - the strongest version of the fix, and the one to reach for first. Rejecting !condition up front is what makes it possible: with the early return, everything below runs under exactly one precondition, so buffer needs no guard before its use and no = NULL that a reader has to check is still true.
That last point matters more than it looks. char *buffer = NULL; at the top followed by a conditional malloc would be safe, but it leaves the compiler nothing to diagnose: if a later maintainer adds a branch that reaches the strncpy without allocating, the version with the initializer dereferences NULL at runtime and the version without it is reported at build time. Declaring at first assignment keeps that diagnostic. C99 onward permits it anywhere in a block.
Compare also with wrapping the work in if (buffer != NULL) { ... } and returning void. Both avoid the uninitialized read; only this version tells the caller that nothing happened. The guarded one silently succeeds at doing nothing when the allocation fails - the failure mode described under Considerations below, and the shape that turns a crash into a wrong answer.
The buffer[BUFFER_SIZE - 1] = '\0' is required, not defensive: strncpy copies at most n bytes and appends no terminator when the source is at least that long. Sizing both the strncpy bound and the terminator index from one named constant keeps them from drifting apart, which is how this pairing usually breaks.
Initialize at Declaration Where the Variable Must Span Branches
#include <stdlib.h>
#include <stdio.h>
// The goto-cleanup idiom: one exit path, so every pointer it touches must be
// readable from every point that can jump to it - including the first one.
int load_record(const char *path) {
char *buffer = NULL; // load-bearing: cleanup can be reached before this is set
FILE *fp = NULL; // same
int rc = -1;
fp = fopen(path, "rb");
if (fp == NULL) goto cleanup; // buffer is still NULL here
buffer = malloc(BUFFER_SIZE);
if (buffer == NULL) goto cleanup;
if (fread(buffer, 1, BUFFER_SIZE, fp) == 0) goto cleanup;
consume(buffer);
rc = 0;
cleanup:
free(buffer); // free(NULL) is a no-op
if (fp != NULL) fclose(fp); // fclose(NULL) is NOT - the check is required
return rc;
}
Why this works: Here the initializers are doing real work rather than restating a default. cleanup is reachable from three places, including one before either resource exists, so both pointers must hold a defined value from the first line of the function - not from the line that assigns them. Without = NULL, the first goto cleanup passes an indeterminate pointer to free, which is the reported weakness arriving through the error path rather than the happy one.
Note the asymmetry in the cleanup block, because it is easy to normalise away. free(NULL) is defined to do nothing, so free(buffer) needs no guard. fclose(NULL) has no such guarantee and is undefined behaviour, so fp does need one. Two release calls, one line apart, with different rules - and a maintainer tidying the "inconsistent" guard is how this becomes a crash. Check each release function's contract rather than applying one habit to both.
This is the shape where = NULL at declaration earns its place. Where the function has no shared exit path, prefer the previous pattern.
Zero-Initialize Structs
struct Config cfg = {0}; // all members, including pointers, start NULL
if (cfg.name != NULL) {
printf("%s", cfg.name);
}
Why this works: = {0} initializes the first member explicitly and every remaining member as if it had static storage duration, which for a pointer means a null pointer - not merely all-bits-zero. The NULL check afterwards therefore tests something the standard guarantees. C23 allows the shorter = {} with the same effect.
memset(&cfg, 0, sizeof cfg) is the plausible alternative and is not equivalent. It writes all-bits-zero, and C does not require a null pointer to be represented that way - nor does it require a floating-point zero to be. Every mainstream platform does represent NULL as all-bits-zero, so memset works in practice and fails only where the failure would be hardest to debug; use the initializer, which is guaranteed and is also visible to the compiler's own uninitialized-use analysis in a way a memset through a pointer is not.
Considerations
"Initialize every pointer to NULL" is the right rule with the wrong priority,
and blanket application costs you a compiler diagnostic. An uninitialized read
is undefined behaviour that GCC and Clang can diagnose at build time. A pointer
explicitly set to NULL and then dereferenced is a well-defined runtime fault
the compiler has nothing to say about. So = NULL on everything converts a class
of bug the toolchain finds before shipping into one that surfaces as a crash in
production - a real cost, paid quietly.
The ordering that keeps both properties is:
- Declare at the point of first assignment. C99 onward allows a declaration
anywhere in a block, so most locals never need a placeholder at all. There is
no window to get wrong, and a future branch that reaches the use without
assigning is still reported by
-Wuninitialized. = NULLwhere the variable genuinely spans branches - agoto cleanuptarget, a value set in one arm of a conditional and released in a common epilogue, a struct field. Here the initializer is load-bearing rather than decorative, and the lost diagnostic buys a defined value on a path that really can reach the use unassigned.
Do not read step 1 as a reason to leave a spanning variable uninitialized in the
hope of a warning. The analysis is incomplete - it goes quiet across function
boundaries and behind a switch the compiler cannot prove exhaustive - so where
there is any doubt, take the initializer and recover the coverage from the
sanitizer and the branch tests instead.
A NULL check is not automatically the right handling. if (p != NULL)
around code that has no meaningful behaviour when p is absent converts a crash
into silently doing nothing, which is harder to notice and can be worse: a
configuration that failed to load, an authorization record that was not fetched,
a decryption key that is missing. Decide what absence means at that point -
return an error, take a documented default, or abort() if the state is
impossible - and write that. The guard is a place to put the decision, not a
substitute for it.
Whether to check depends on who can reach the path. A pointer assigned unconditionally three lines above needs no guard, and adding one implies a doubt that sends the next reader looking for the missing assignment. A pointer whose assignment sits behind a condition derived from input needs one on every use. The reported line is a sample: check the other pointers in the same function that were declared the same way.
Testing
- Compile with
-Wall -Wextra -Wuninitialized -Winit-self -O2and treat the warnings as build failures. The-O2is not optional: GCC's uninitialized analysis runs on the optimizers' dataflow, so at-O0it reports almost nothing and a clean build says nothing about the code. Clang's is CFG-based and does report at-O0, so a build that is clean under one compiler is worth repeating under the other. - Run under
valgrind --track-origins=yes, which reports where the uninitialized value was created rather than only where it was used. Memcheck complains when an undefined value reaches a branch, an address computation, or a syscall - which covers every dereference on this page - but stays silent while such a value is merely copied around, so the report points at the use and--track-originsis what gets you back to the declaration. - MemorySanitizer (
clang -fsanitize=memory -fsanitize-memory-track-origins=2) is the more precise of the two and has a prerequisite worth checking before trusting a clean run: it requires every piece of code in the process to be instrumented, including the C library. Any uninstrumented dependency produces false positives, it is Linux/x86-64 and a few other targets only, and it cannot be combined with AddressSanitizer in one binary. Where the dependencies cannot all be rebuilt, Valgrind is the tool that works. - Exercise every branch that could previously skip initialization, including error paths and early returns. Where a test cannot reach an allocation-failure path, force it -
mallocinterposition, or a build-time hook returning NULL on the nth call - because those paths are where the uninitialized pointer survives.