Skip to content

CWE-787: Out-of-bounds Write - C

Overview

In C, out-of-bounds write occurs when code writes through a pointer or array index past the end of the memory that was actually allocated for it. C performs no automatic bounds checking on array access or pointer arithmetic, so the language will not stop buf[100] from writing into memory that was never part of buf if buf was only 100 bytes long. The most common causes are copy/format functions that don't know the destination's capacity (strcpy, sprintf, gets), size calculations that overflow before an allocation or copy, and off-by-one errors in manual loops.

Primary Defence: Replace unbounded copy/format functions with their size-aware equivalents - snprintf, and strlcpy/strlcat on BSD, macOS and glibc 2.38 or later - and validate every write's offset and length against the destination buffer's real capacity before the write happens; never trust a length field taken from input. The strn* family is a fallback rather than a first choice, because each of its members measures its size argument from a different point: see the note under Size-Aware String Copy below, and the pitfalls on the CWE-121 C page.

Common Vulnerable Patterns

Unbounded String Copy

// VULNERABLE - strcpy has no destination size limit
char dest[64];
void handle_input(const char *user_input) {
    strcpy(dest, user_input);  // writes past dest if user_input is longer than 63 bytes
}

// Attack: user_input longer than 63 bytes
// Result: whatever the linker placed after dest is overwritten - here, other
// objects in static storage

Why this is vulnerable: strcpy copies until it hits a NUL terminator in the source, with no awareness of how large dest actually is. Any input longer than the destination's capacity overflows into whatever memory follows it.

What follows it depends on where the destination lives, and that is the whole difference between this weakness and its best-known variant. dest here is at file scope, so the overflow runs into whatever the linker placed next to it in static storage - other globals and function-static variables, in an order the source never states. Move the same buffer inside the function and the bytes past its end are the other locals, the saved frame pointer and the return address, which is CWE-121 and is why that case has its own hardening (stack canaries) and its own page. The missing check is identical in both; only the blast radius changes.

Unbounded Formatted Output

// VULNERABLE - sprintf has no destination size limit
char message[128];
void build_message(const char *name) {
    sprintf(message, "Hello, %s! Welcome back.", name);  // no length check on name
}

Why this is vulnerable: sprintf writes as much output as the format string and arguments produce, regardless of the destination buffer's size. A long enough name overflows message.

Off-by-One Loop Write

// VULNERABLE - writes buffer[size], one past the last valid index
void fill_buffer(char *buffer, size_t size, char value) {
    for (size_t i = 0; i <= size; i++) {   // should be i < size
        buffer[i] = value;
    }
}

Why this is vulnerable: Valid indices for a buffer of size elements are 0 to size - 1. Using <= instead of < writes one element past the end of every buffer this function touches.

Integer Overflow Before Allocation

// VULNERABLE - count * sizeof(int) can overflow, causing a too-small allocation
int *store_values(const int *values, size_t count) {
    int *buffer = malloc(count * sizeof(int));  // no overflow check
    for (size_t i = 0; i < count; i++) {
        buffer[i] = values[i];   // writes past the undersized buffer if the multiply overflowed
    }
    return buffer;
}

Why this is vulnerable: If count is large enough that count * sizeof(int) overflows size_t, malloc receives a small wrapped-around value and returns a buffer far smaller than the loop assumes, so the loop writes well past the end of the actual allocation.

Secure Patterns

Size-Aware String Copy

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

char dest[64];
void handle_input(const char *user_input) {
    size_t len = strlcpy(dest, user_input, sizeof(dest));  // BSD/glibc 2.38+; see note below
    if (len >= sizeof(dest)) {
        fprintf(stderr, "input truncated: %zu bytes needed, %zu available\n", len, sizeof(dest));
    }
}

Why this works: strlcpy always NUL-terminates within sizeof(dest) and returns the length it would have needed, so truncation is detectable instead of silent. Where strlcpy isn't available, strncpy(dest, user_input, sizeof(dest) - 1); dest[sizeof(dest) - 1] = '\0'; is the portable fallback - note that plain strncpy does not guarantee NUL-termination on its own, so the explicit terminator assignment is required.

Size-Aware Formatted Output

#include <stdio.h>

char message[128];
void build_message(const char *name) {
    int written = snprintf(message, sizeof(message), "Hello, %s! Welcome back.", name);
    if (written < 0 || (size_t)written >= sizeof(message)) {
        fprintf(stderr, "message truncated or encoding error\n");
    }
}

Why this works: snprintf never writes more than sizeof(message) bytes including the terminator, and its return value tells you the length that would have been written, so truncation is detectable rather than silent.

Correct Loop Bounds

void fill_buffer(char *buffer, size_t size, char value) {
    for (size_t i = 0; i < size; i++) {   // valid indices are 0..size-1
        buffer[i] = value;
    }
}

Why this works: Using < instead of <= stops the loop one iteration earlier, so every write lands inside the allocated range [0, size) and buffer[size] is never reached.

Overflow-Checked Allocation Size

#include <stdlib.h>
#include <stdint.h>   // SIZE_MAX

int *store_values(const int *values, size_t count) {
    if (count > SIZE_MAX / sizeof(int)) {
        return NULL;  // would overflow - reject instead of allocating an undersized buffer
    }
    int *buffer = malloc(count * sizeof(int));
    if (buffer == NULL) {
        return NULL;
    }
    for (size_t i = 0; i < count; i++) {
        buffer[i] = values[i];
    }
    return buffer;
}

Why this works: Checking count > SIZE_MAX / sizeof(int) before multiplying detects an overflow that would otherwise wrap the allocation size down to a small number, without itself risking an overflow. Where available, reallocarray()/calloc() perform this same overflow check internally and are preferable to a hand-rolled multiply.

Testing

  • Compile with AddressSanitizer (-fsanitize=address -fsanitize=undefined -g -O1) and run the test suite with normal, boundary (exactly size bytes), and oversized inputs - ASan reports the exact write and its call stack.
  • Run under Valgrind as an independent check.
  • Enable compiler warnings (-Wall -Wextra -Wformat-security -D_FORTIFY_SOURCE=3) and fix everything they flag - _FORTIFY_SOURCE adds runtime checks to several unsafe functions when the compiler can determine buffer sizes. Level 3 additionally checks buffers whose size is only known at run time, and needs Clang 9+ with glibc 2.33+, or GCC 12+ with glibc 2.35+ - distributions often backport it earlier than that; use =2 where the toolchain does not support it. Build at -O1 or higher: glibc activates it only when __OPTIMIZE__ is set, so at -O0 it adds no checks at all, and a hardening flag that has quietly done nothing looks exactly like one that found nothing.
  • Fuzz any function that parses untrusted input (AFL++, libFuzzer) - out-of-bounds writes are exactly the class of bug fuzzing is best at finding.

Additional Resources