CWE-125: Out-of-bounds Read - C
Overview
In C, an out-of-bounds read occurs when code reads through a pointer or array index past the end (or before the start) of the memory that was actually allocated. C performs no automatic bounds checking on array access or pointer arithmetic, so buffer[offset] will happily read whatever memory follows buffer if offset is larger than the buffer's real size, or whatever precedes it if offset is negative. The most common causes are unvalidated indices from untrusted data, off-by-one loop bounds, and trusting a length field (from a packet, file header, or protocol) without checking it against the buffer that actually backs it - the exact bug behind Heartbleed (CVE-2014-0160).
Primary Defence: Validate every index and offset against the buffer's actual allocated size - both the lower bound (>= 0) and the upper bound (< size) - before the read happens, and never trust a length field taken from input without checking it against the real buffer size.
Common Vulnerable Patterns
Unvalidated Index
// VULNERABLE - no check that offset is within buffer bounds, and no check for negative values
char read_element(const char *buffer, int offset) {
return buffer[offset];
}
// Attack: offset = 1000 when buffer is only 100 bytes -> reads 900 bytes past the end
// Attack: offset = -10 -> reads 10 bytes before the buffer start
Why this is vulnerable: A signed offset that is never checked against zero or against the buffer's size can walk in either direction past the allocation, disclosing whatever adjacent memory holds - stack data, heap metadata, or another object's fields.
Off-by-One Loop Read
// VULNERABLE - uses <= instead of <, reads buffer[size] which is one past the end
void process_buffer(const char *buffer, size_t size) {
for (size_t i = 0; i <= size; i++) {
process(buffer[i]);
}
}
Why this is vulnerable: Valid indices for a buffer of size bytes are 0 to size - 1. The <= comparison reads one byte past the allocation on the final iteration.
Trusting a User-Supplied Length (Heartbleed-style)
// VULNERABLE - copies user_length bytes from buffer without checking it against actual_size
void heartbeat_response(const unsigned char *buffer, size_t actual_size, unsigned short user_length) {
unsigned char response[1000];
memcpy(response, buffer, user_length); // user_length can exceed actual_size
send_response(response, user_length);
}
// Attack: buffer holds 10 real bytes, user_length = 1000
// Result: memcpy reads 990 bytes past the real payload, leaking adjacent heap memory
Why this is vulnerable: The function trusts a caller-supplied length instead of the size of the data actually present. memcpy has no awareness of the source buffer's real capacity - it copies exactly the length it's told, whatever memory that spans.
The single missing check costs twice over, and the second cost is a different weakness. user_length is an unsigned short, so it runs to 65535 while response holds 1000 bytes: any value above 1000 overruns the destination as well, which is CWE-787 rather than this page's over-read. The attack above uses exactly 1000 because that is the largest value that leaks cleanly, filling response to the byte without overrunning it; nothing stops an attacker sending 60000 and getting the stack smash as well. A length arriving from outside has to be checked against both buffers, and the secure version below does exactly that.
Integer Overflow in Offset Calculation
// VULNERABLE - offset + length can wrap around before the comparison runs
void read_with_offset(const char *buffer, size_t buffer_size, size_t offset, size_t length,
char *dest) {
if (offset + length <= buffer_size) { // offset + length can overflow size_t
memcpy(dest, buffer + offset, length);
}
}
// Attack (32-bit): offset = 0xFFFFFFF0, length = 0x20 -> offset + length wraps to 0x10,
// which passes the check, but buffer + offset is a wild pointer
Why this is vulnerable: Checking offset + length <= buffer_size performs the addition before validating either operand, so a large enough offset wraps the sum around to a small value that passes the check while the actual pointer arithmetic (buffer + offset) is nowhere near the buffer.
Secure Patterns
Validating Indices Before Access
#include <stdio.h>
int read_element(const char *buffer, size_t buffer_size, int offset, char *out) {
if (offset < 0 || (size_t)offset >= buffer_size) {
fprintf(stderr, "offset %d out of bounds (size: %zu)\n", offset, buffer_size);
return -1;
}
*out = buffer[offset]; // safe: offset validated against both bounds
return 0;
}
Why this works: Checking the lower bound (offset < 0) and the upper bound ((size_t)offset >= buffer_size) separately catches both directions an out-of-bounds read can go, and asks each question where it can still be answered. The sign has to be tested while offset is still signed: once it has been converted to size_t, a negative value and a genuinely enormous one are the same bit pattern, and nothing downstream can tell -1 from SIZE_MAX. Keeping the parameter signed also lets the diagnostic name the offset the caller actually sent.
Correct Loop Bounds
void process_buffer(const char *buffer, size_t size) {
for (size_t i = 0; i < size; i++) { // valid indices are 0..size-1
process(buffer[i]);
}
}
Why this works: < instead of <= keeps every read inside [0, size); there is no iteration where buffer[size] is read.
Validating a Length Against the Real Buffer
#include <string.h>
#include <stdio.h>
int heartbeat_response(const unsigned char *buffer, size_t actual_size, unsigned short user_length,
unsigned char *response, size_t response_capacity) {
if (user_length > actual_size) {
fprintf(stderr, "requested length %u exceeds actual payload %zu\n", user_length, actual_size);
return -1;
}
if (user_length > response_capacity) {
fprintf(stderr, "requested length %u exceeds response buffer %zu\n", user_length, response_capacity);
return -1;
}
memcpy(response, buffer, user_length); // safe: validated against both source and destination
return 0;
}
Why this works: The length is checked against the source's real size, not just its claimed size, and separately against the destination buffer's capacity. Both checks are required - validating only one side still allows the copy to overrun the other.
Overflow-Safe Range Check
#include <string.h>
#include <stdio.h>
int read_with_offset(const char *buffer, size_t buffer_size, size_t offset, size_t length,
char *dest, size_t dest_size) {
if (offset > buffer_size) {
fprintf(stderr, "offset %zu exceeds buffer size %zu\n", offset, buffer_size);
return -1;
}
if (length > buffer_size - offset) { // rearranged so the subtraction can't overflow
fprintf(stderr, "read range exceeds buffer size %zu\n", buffer_size);
return -1;
}
if (length > dest_size) { // the same length also governs the write
fprintf(stderr, "read range exceeds destination size %zu\n", dest_size);
return -1;
}
memcpy(dest, buffer + offset, length); // safe: bounded at both ends of the copy
return 0;
}
Why this works: Validating offset first, then checking length > buffer_size - offset instead of offset + length > buffer_size, avoids the addition that could overflow. Subtraction of two already-validated, in-range values can't wrap around, so the comparison is reliable regardless of how large offset or length are individually.
dest_size is checked for the same reason it appears in the heartbeat example: length governs both ends of a memcpy, and a range check that only proves the source is readable leaves the destination write unbounded. Passing a raw dest with no size is what makes that check impossible to write, which is why the parameter is there rather than being assumed large enough.
Testing
- Compile with AddressSanitizer (
-fsanitize=address -fsanitize=undefined -g -O1) and run with normal, boundary (size - 1, exactlysize), and oversized/negative inputs - ASan reports the exact read 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. 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=2where the toolchain does not support it. Build at-O1or higher: glibc activates_FORTIFY_SOURCEonly when__OPTIMIZE__is set, so at-O0it 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 reads are exactly the class of bug fuzzing excels at finding.
Common Pitfalls
- Checking the upper bound only:
if (index < size)stops reads past the end, but whether it also stops a negativeindexdepends on the type of the bound. Against asize_t size, the usual arithmetic conversions promote a negativeint indexto a value nearSIZE_MAXand the check rejects it; against a signed bound - a bare literal such asif (index < 100), or a length kept in anint- the negative passes and the read lands before the buffer starts. Write the lower bound explicitly rather than leaving it to whichever type the surrounding code happens to use. - Casting to
size_tbefore validating the sign:if ((size_t)offset < buffer_size)turns a negativeoffsetinto a value nearSIZE_MAX, which does fail the comparison - so the read is refused, but by accident of the conversion rather than by design, and the accident does not survive the next edit. Widen it to a range check and it inverts:(size_t)offset + length <= buffer_sizewithoffsetof-1evaluatesSIZE_MAX + length, which wraps back down tolength - 1, passes, and then reads throughbuffer + (size_t)offset. Askoffset < 0while the value is still signed, which is the only point at which it can be asked, then cast. - Validating the claimed length instead of the real one: Checking a length field against another length field that also came from the same untrusted source (e.g. a header's own
total_sizefield) doesn't verify anything - validate against the buffer's actual allocated or received size, not another attacker-controlled number. - Trusting
offset + length <= buffer_sizeas written: This form performs the addition before the check runs, so a large enoughoffsetcan wrap the sum aroundsize_t's maximum value and pass the check. Rearranging tolength > buffer_size - offset(after confirmingoffset <= buffer_size) avoids the overflow instead of just moving it.