Skip to content

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

for (size_t i = 0; i < count; i++) {
    array[i] = value;
}

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 -O1 and 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=3 and 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 =2 where the toolchain does not support it. Build these at -O1 or higher: glibc activates _FORTIFY_SOURCE only when __OPTIMIZE__ is set, so at -O0 it emits #warning _FORTIFY_SOURCE requires compiling with optimization (-O) and adds nothing, and -Warray-bounds is 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 -O2 as well as -O0 and 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), with offset == size and len == 0 (which must be accepted), and with values chosen to wrap a size calculation - offset = SIZE_MAX, or offset and len that each fit but whose sum does not.
  • Test with negative offsets wherever the offset parameter is signed. Include INT_MIN specifically: it is the value that breaks any code normalising a negative offset by negating it, since -INT_MIN is not representable, and it is the one that produces the largest jump when converted to size_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.

Additional Resources