CWE-122: Heap-based Buffer Overflow - C
Overview
In C a heap buffer is whatever malloc, calloc or realloc returned, and the pointer carries no record of how large that allocation was - sizeof on it reports the pointer's own width, not the memory behind it. Every bound therefore has to come from a size the program stored itself, which is where the weakness enters: a size computed with arithmetic that wraps, a size that forgot the NUL terminator, a size that stopped matching the allocation after a realloc, or no size at all because the copy function never asked for one.
Primary Defence: Derive the allocation size from the data that will go into it, store that size beside the pointer, and bound every write by the stored value - and let the allocator do any element-count multiplication (calloc, reallocarray) so an oversized count fails instead of wrapping.
Common Vulnerable Patterns
Unbounded Copy into a Heap Buffer
#include <stdlib.h>
#include <string.h>
char *save_name(const char *user_input) {
char *name = malloc(64);
if (name == NULL) {
return NULL;
}
// VULNERABLE - nothing bounds the copy by the 64 bytes that were allocated
strcpy(name, user_input);
return name;
}
// Attack: user_input longer than 63 bytes
// Result: the copy runs past the allocation into the next chunk on the heap
Why this is vulnerable: strcpy copies through the source's NUL terminator without checking destination capacity. An input longer than 63 bytes exceeds this allocation. Stack canaries do not protect the heap buffer; depending on the allocator layout and overwrite length, the write may corrupt another object or allocator metadata, or land in padding. A failure can surface much later.
Allocating strlen() Bytes for a String
#include <stdlib.h>
#include <string.h>
char *duplicate(const char *src) {
char *copy = malloc(strlen(src)); // VULNERABLE - no room for the NUL terminator
if (copy == NULL) {
return NULL;
}
strcpy(copy, src); // writes strlen(src) + 1 bytes into strlen(src) bytes
return copy;
}
Why this is vulnerable: strlen excludes the terminator that strcpy writes, so any copy reached after a successful allocation writes one byte too many. Depending on alignment and allocator layout, that NUL byte may alter adjacent metadata, including a chunk size field, or land in padding. Even a one-byte overwrite must be fixed.
Size Arithmetic That Wraps
#include <stdlib.h>
struct point {
int x;
int y;
};
struct point *read_points(size_t count, const struct point *src) {
// VULNERABLE - count * sizeof(struct point) wraps for a large enough count
struct point *points = malloc(count * sizeof(struct point));
if (points == NULL) {
return NULL;
}
for (size_t i = 0; i < count; i++) {
points[i] = src[i]; // the loop writes count elements regardless
}
return points;
}
Why this is vulnerable: unsigned arithmetic wraps, so a count above SIZE_MAX / sizeof(struct point) can produce a small allocation request. If it succeeds, the loop still writes the original count elements into the undersized destination. Checking for NULL detects allocation failure, not this mismatch.
Reachability depends on the widths of the operands. With a 32-bit count, 64-bit size_t, and an eight-byte struct point, this product cannot wrap; a large allocation may succeed or fail depending on available resources. With 32-bit size_t, or a count that spans the full 64-bit range, wrapping is possible. Check the actual widths and input limits, including those on other supported targets. The caller must also establish how many source elements are readable; allocating the destination correctly does not prevent a source over-read.
Growing the Allocation Without Updating the Capacity
#include <stdlib.h>
#include <string.h>
typedef struct {
char *data;
size_t len; // bytes in use
size_t cap; // bytes believed to be allocated
} buffer;
int buffer_append(buffer *b, const char *src, size_t n) {
if (b->len + n > b->cap) {
char *grown = realloc(b->data, b->cap * 2);
if (grown == NULL) {
return -1;
}
b->data = grown;
// VULNERABLE - the allocation doubled, b->cap still holds the old value
}
memcpy(b->data + b->len, src, n);
b->len += n;
return 0;
}
Why this is vulnerable: b->cap is never updated, so later growth requests keep using twice the original capacity even as b->len increases. A copy overflows when b->len + n exceeds that doubled capacity, which can happen on the first append. Both b->len + n and b->cap * 2 can also wrap: the first can bypass growth, and the second can request a smaller allocation than intended. Growth must cover the full required length, with checked arithmetic and a capacity update after success.
Secure Patterns
Size the Allocation from the Data
#include <stdlib.h>
#include <string.h>
char *duplicate(const char *src) {
size_t len = strlen(src);
// SECURE - the terminator is part of the size being requested
char *copy = malloc(len + 1);
if (copy == NULL) {
return NULL;
}
memcpy(copy, src, len + 1); // the same len + 1 that was allocated
return copy;
}
Why this works: one measurement drives both the allocation and the copy, so the two cannot disagree. memcpy with a pre-measured length is preferable to strcpy here even though the length is known to fit: it states the byte count at the call site, where a reviewer can compare it against the malloc two lines up, instead of leaving it implicit in the source data.
Where the destination genuinely has to be a fixed size rather than fitted to the input, the fix is the length check from the CWE-121 C page with the capacity read from a stored value rather than from sizeof - see Keep the Capacity Beside the Pointer below. strdup(src) does exactly what this function does, and is available on every POSIX platform and in the standard library from C23; prefer it when the only goal is a copy.
Measure First, Then Allocate, for Formatted Output
#include <stdio.h>
#include <stdlib.h>
char *format_message(const char *username, int score) {
// SECURE - snprintf with a NULL destination and size 0 returns the length
// the result would need, without writing anything
int needed = snprintf(NULL, 0, "User: %s, Score: %d", username, score);
if (needed < 0) {
return NULL;
}
size_t size = (size_t)needed + 1;
char *message = malloc(size);
if (message == NULL) {
return NULL;
}
int written = snprintf(message, size, "User: %s, Score: %d", username, score);
if (written < 0 || (size_t)written >= size) {
free(message); // the arguments changed under us, or encoding failed
return NULL;
}
return message;
}
Why this works: both calls use the same format and arguments, and the second call bounds its output by the allocated size. Checking its result rejects encoding errors and truncation. username must remain a valid, stable NUL-terminated string throughout both calls. If another thread can modify it, hold the appropriate lock or format a private snapshot; the size limit does not make a data race safe.
Let the Allocator Do the Multiplication
#include <stdlib.h>
#include <string.h>
#define MAX_POINTS 4096
struct point {
int x;
int y;
};
struct point *read_points(size_t count, const struct point *src) {
if (count == 0 || count > MAX_POINTS) {
return NULL; // reject an implausible count as invalid input
}
// SECURE - calloc computes count * sizeof(*points) itself and returns NULL
// rather than wrapping if the product is not representable
struct point *points = calloc(count, sizeof(*points));
if (points == NULL) {
return NULL;
}
memcpy(points, src, count * sizeof(*points)); // calloc succeeding proves this product fits
return points;
}
Why this works: the multiplication that used to wrap now happens inside calloc, which is required to allocate space for an array of that many objects and returns NULL when it cannot. Because the allocation succeeded, the identical product in the memcpy is known to be representable, so it is safe to repeat.
That check is the reason to prefer calloc over a hand-written multiplication here, and the mainstream implementations perform it - how plainly each one says so varies. The Linux malloc(3) page states that calloc returns an error if the multiplication of the two arguments would overflow, and OpenBSD's calloc(3) that it returns NULL and sets ENOMEM; musl's implementation rejects the request when m > SIZE_MAX / n. FreeBSD's and Microsoft's reference pages describe allocation failure in general terms without singling out the overflow case, but their allocators - jemalloc and the UCRT - reject the overflowing product as well. A reference page that does not mention the check is not evidence that it is missing. Where the size is not a plain element count times element width, calloc has nothing to check and the arithmetic is yours to guard: see the alternatives below.
The MAX_POINTS check limits resource use. The caller must still supply at least count readable source elements. Where calloc's zeroing is unwanted, reallocarray(NULL, count, sizeof *p) (glibc 2.26 or later, and the BSDs) checks the same element/size product. __builtin_mul_overflow(count, sizeof *p, &bytes) performs the check without allocating, and has been available since GCC 5 and Clang 3.8. C23 standardises the same operation as ckd_mul(&bytes, count, sizeof *p) in <stdckdint.h>, which GCC ships from 14 and Clang from 18 (Clang exposes it in pre-C23 modes as well); its result pointer is the first argument where the builtin's is the last. Both return true on overflow; allocate bytes only when the check returns false.
Keep the Capacity Beside the Pointer
#include <stdint.h>
#include <stdlib.h>
#include <string.h>
typedef struct {
char *data;
size_t len; // bytes in use
size_t cap; // bytes actually allocated - updated wherever data is
} buffer;
int buffer_append(buffer *b, const char *src, size_t n) {
// SECURE - cap is updated with every successful growth, so the bound
// the copy below relies on always describes the live allocation
if (n > SIZE_MAX - b->len) {
return -1; // the sum itself would wrap - check before computing it
}
if (n == 0) {
return 0; // no pointer arithmetic or memcpy on an empty buffer
}
size_t needed = b->len + n;
if (needed > b->cap) {
size_t new_cap = (b->cap == 0) ? 32 : b->cap;
while (new_cap < needed) {
if (new_cap > SIZE_MAX / 2) {
return -1;
}
new_cap *= 2; // grow until it covers needed, not just once
}
char *grown = realloc(b->data, new_cap);
if (grown == NULL) {
return -1; // b->data is untouched and still valid
}
b->data = grown;
b->cap = new_cap; // the record and the allocation agree again
}
memcpy(b->data + b->len, src, n);
b->len = needed;
return 0;
}
Why this works: successful growth updates both data and cap. The loop grows until capacity covers the append, checking addition and doubling before either can wrap. A temporary holds the realloc result, preserving the original buffer on failure. A zero-byte append returns before pointer arithmetic or copying, including when the buffer has no allocation yet.
Initialize with buffer b = {0} and release b.data with free when finished. Preserve len <= cap, with data pointing to a live allocation of at least cap bytes whenever cap is nonzero. For a nonempty append, src must provide n readable bytes outside that allocation: growth can invalidate a source pointer into b.data, and memcpy does not permit overlap. This is a byte buffer; it does not add a string terminator.
Testing
- Build with AddressSanitizer (
-fsanitize=address -g -O1) and exercise the allocation with content at exactly the allocated size, one byte over, and far over. A heap overflow is reported asheap-buffer-overflowwith both the faulting write and the allocation's own stack trace, which is the piece a crash dump usually cannot give you. - Do not expect
-fsanitize=undefinedto catch a wrapping size calculation. Unsigned overflow is defined behaviour in C, so UBSan is silent on it by default; Clang's-fsanitize=unsigned-integer-overflowdoes report it, but it also flags deliberate wrapping (hashes, PRNGs), so run it as a targeted experiment rather than in a general build. - Where ASan cannot be used, Valgrind's Memcheck detects the same overflows into its own redzones. The two cannot be combined - pick one per build.
- On glibc,
MALLOC_CHECK_=3orglibc.malloc.check=3enables additional checks that abort on detected corruption. From glibc 2.34 this requires preloadinglibc_malloc_debug.so.0, where the distribution supplies it.MALLOC_PERTURB_is a separate aid for exposing stale or uninitialized data and does not require that debug library. Neither option detects every heap overflow. - Fuzz the length and count fields specifically, not just the payload - the inputs that reach this weakness are the numbers that size the allocation (AFL++, libFuzzer, Honggfuzz).
- Assert on the sanitizer's verdict rather than on the program surviving. An overflow of a few bytes frequently lands in the padding the allocator added when it rounded the request up, where it corrupts nothing and changes no output.
- For
buffer_append, assert that a zero-byte append to{0}succeeds without changing the buffer, repeated appends preserve all bytes, and an append larger than twice the old capacity grows enough. Exercise allocation failure and rejected size arithmetic; both must leave the pointer, length, capacity and existing contents unchanged.
Common Pitfalls
- Using
sizeofon the heap pointer:sizeof(buf)wherebufis achar *returns the pointer's width, commonly 4 or 8 bytes. It does not query the allocation. Track the requested capacity with the pointer; compiler extensions and allocator-specific size queries are not portable substitutes. - Reading no crash as no overflow: allocators round requests up (glibc's x86-64 chunks are 16-byte aligned with a minimum of 32 bytes), so writing a few bytes past a
malloc(20)commonly lands in padding that belongs to nothing. The bug is identical to the one that corrupts a live object; only the allocation sizes around it differ, and they change with the next release. - Taking
free(): invalid next sizefor a bounds check: glibc's heap diagnostics fire when the allocator next touches the corrupted metadata, which can be millions of instructions after the write, in an unrelated call stack, and never at all if the overflow hit a neighbouring object rather than a chunk header. They are a symptom, not a control. - Assigning
realloc's result straight back to the pointer:b->data = realloc(b->data, n)loses the original pointer whenreallocreturns NULL, leaking the block (CWE-401); and after a successful realloc, any other pointer that was aliasing the old block now dangles (CWE-416). Use a temporary, and re-derive aliases after growth. - Reaching for
strncpy/strncatas the fix: the boundedstrn*family measures its size argument from a different place in each member, and swapping one in without changing where the bound comes from leaves the overflow intact. The CWE-121 C page covers each of those traps; on the heap they behave identically, with the added wrinkle that the destination's size must come from a stored value.
Additional Resources
- CWE-122: Heap-based Buffer Overflow
- CERT C Coding Standard - Memory Management (MEM)
- AddressSanitizer
- Valgrind Memcheck
- Linux man-pages: malloc(3) - the
callocoverflow check, and whymalloc(n * size)has none - C23 working draft (N3096)
- glibc memory allocation tunables