CWE-823: Use of Out-of-range Pointer Offset - C
Overview
In C, this weakness shows up as pointer arithmetic (ptr + offset, ptr++) or array indexing (array[i]) where the offset or index isn't validated against the buffer's actual size before use. C performs no automatic bounds checking on raw pointers or arrays, so an unchecked offset silently produces a pointer to memory the program never allocated for that purpose.
Primary Defense: Validate every offset as integer arithmetic against the buffer's size before the pointer is computed - if (offset >= size) reject; and not if (buffer + offset >= buffer + size) reject;. The order matters in C, for the reason covered under "Bounds Check Performed on the Formed Pointer" below: a pointer that has already gone out of range is undefined behaviour before anything dereferences it, and a comparison written to detect that can legally be compiled away.
This page covers the offset. Where the finding is about a copy function writing past a destination - strcpy, strcat, sprintf, an unvalidated memcpy length - the weakness is CWE-787 (Out-of-bounds Write) or CWE-121 (Stack-based Buffer Overflow), which carry the replacement functions and their pitfalls.
Common Vulnerable Patterns
Unchecked Pointer Arithmetic
// VULNERABLE - offset used directly with no bounds check
void process_data(char *buffer, size_t size, int offset) {
char *ptr = buffer + offset;
*ptr = 'X'; // may write outside buffer
}
// Attack: offset = size + 1000
// Result: writes far outside the allocated buffer, corrupting adjacent memory
Why this is vulnerable: offset is added directly to buffer with no check that the result still falls inside [buffer, buffer + size). Because offset is a plain int, it can also be negative, moving the pointer before the buffer instead of past its end.
Off-by-one Loop Bound
// VULNERABLE - <= instead of <, writes one element past the end
for (int i = 0; i <= count; i++) {
array[i] = value;
}
Why this is vulnerable: Valid indices for an array of count elements are 0 to count - 1. Using <= allows the loop to execute one extra iteration, writing to array[count], which is one element past the allocation.
Bounds Check Performed on the Formed Pointer
// VULNERABLE - the check is written in terms of a pointer that is already invalid
int read_field(const char *buf, size_t size, size_t offset, size_t len) {
const char *start = buf + offset; // already UB when offset > size
const char *end = start + len; // and again here
if (end > buf + size || end < start) { // the compiler may delete both tests
return -1;
}
consume(start, len);
return 0;
}
Why this is vulnerable: This one looks like a fix, which is what makes it worth naming. The bug is not a missing check; it is that the check is expressed using values C does not permit to exist. C11 6.5.6p8 allows pointer arithmetic to produce a pointer only into the array or one element past its last element - buf + offset for an offset beyond size is undefined behaviour at the moment it is computed, with no dereference involved.
The consequence is not academic. A compiler is entitled to assume undefined behaviour does not occur, so it may reason that start is in range, that end >= start therefore always holds, and that end < start is dead code - and optimising compilers do delete overflow tests written this way. That leaves a binary whose source contains a bounds check and whose object code does not, which is the worst version: the reviewer sees a guard, the scanner sees a guard, and the running program has none.
The same reasoning applies to the end < start wraparound test, and to the familiar if (ptr + n < ptr) idiom for detecting pointer overflow. Both are self-defeating for the same reason. Note the asymmetry that trips people up: buf + size is legal - one past the end is explicitly permitted - so the pointer you are comparing against is fine, and only the one derived from the untrusted offset is not.
Secure Patterns
Validate the Offset Before Forming the Pointer
#include <stddef.h>
int write_at_offset(char *buffer, size_t size, int offset, char value) {
if (offset < 0 || (size_t)offset >= size) {
return -1; // reject: offset out of range
}
char *ptr = buffer + offset;
*ptr = value;
return 0;
}
Why this works: The offset is checked against the buffer's real size before it is used to form a pointer, so no out-of-range pointer is ever computed and there is no undefined behaviour for the optimiser to build on.
The offset < 0 test deserves an accurate account, because the usual one is wrong. With size declared size_t, offset >= size on its own would already reject a negative offset: the usual arithmetic conversions turn offset into size_t first, so -1 becomes SIZE_MAX and the comparison rejects it. The explicit sign test is not rescuing that comparison - it is stating the precondition in the code so the function stays correct if size is ever changed to an int or a long, where the same expression compares two signed values and -1 >= 100 is false, admitting every negative offset. It also makes (size_t)offset a cast whose result is knowable rather than one that quietly relies on wraparound.
The general rule this is an instance of: never let a signed value reach a bounds comparison without either testing its sign or knowing which way the conversion goes. Which of those happens depends on the other operand's type, which is usually declared in a different file.
Correct Loop Bounds
Why this works: i < count is the correct condition for zero-based indexing - the loop never reaches the out-of-range index count.
Check the Offset and Length as Integers, Never as Pointers
#include <stddef.h>
// Answers "Bounds Check Performed on the Formed Pointer" above.
int read_field(const char *buf, size_t size, size_t offset, size_t len) {
if (offset > size) return -1; // no pointer formed yet
if (len > size - offset) return -1; // subtraction cannot wrap: offset <= size
const char *start = buf + offset; // now provably in [buf, buf + size]
consume(start, len);
return 0;
}
Why this works: Every comparison is between size_t values, so nothing here depends on a pointer the standard does not allow to exist, and there is no undefined behaviour for the optimiser to reason from. The check that survives -O2 is the one written this way.
The order of the two tests is the whole trick, and writing them the other way round reintroduces the bug. len > size - offset is safe only because offset > size has already been rejected: size_t is unsigned, so if offset could exceed size, size - offset would wrap to a value near SIZE_MAX and the length test would pass for any len. The tempting single-line form, if (offset + len > size), has the mirror-image flaw - offset + len can wrap past SIZE_MAX and produce a small sum from two huge inputs. Subtracting from a value already known to be the larger is what avoids both.
offset > size rather than offset >= size is deliberate: an offset of exactly size with len == 0 is a legitimate empty read at the end of the buffer, and forming the one-past-the-end pointer is explicitly permitted. Reject it and you break callers that iterate to the end; allow it without the length test and you have a real overrun.
Testing
- Compile with
-fsanitize=address -g -O1and exercise every code path that reaches the pointer arithmetic - AddressSanitizer reports out-of-range accesses with the exact offset and call stack. - Enable
-Wall -Wextra -Warray-bounds -D_FORTIFY_SOURCE=3and treat warnings as build failures. 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 these at-O1or higher: glibc activates_FORTIFY_SOURCEonly when__OPTIMIZE__is set, so at-O0it emits#warning _FORTIFY_SOURCE requires compiling with optimization (-O)and adds nothing, and-Warray-boundsis driven by the optimizers and finds almost nothing without them. A hardening flag that has quietly done nothing looks exactly like one that found nothing. - Build the fixed function at
-O2as well as-O0and confirm the guard still rejects. This is the specific check for "Bounds Check Performed on the Formed Pointer" above: a bounds test phrased in terms of an out-of-range pointer can pass at-O0, where the compiler evaluates it literally, and be optimised away at-O2. If a test only ever runs against an unoptimised build, it cannot see the difference. - Test with offsets at the exact boundary (
size - 1,size,size + 1), withoffset == sizeandlen == 0(which must be accepted), and with values chosen to wrap a size calculation -offset = SIZE_MAX, oroffsetandlenthat each fit but whose sum does not. - Test with negative offsets wherever the offset parameter is signed. Include
INT_MINspecifically: it is the value that breaks any code normalising a negative offset by negating it, since-INT_MINis not representable, and it is the one that produces the largest jump when converted tosize_t. - Fuzz functions that take an offset or length argument from untrusted input (AFL, libFuzzer) to find edge cases manual review misses.
- After fixing the reported line, re-read the other loop conditions in the same function. Off-by-one bounds tend to be written in pairs, and the sibling loop is rarely the one the scanner flagged.