Skip to content

CWE-823: Use of Out-of-range Pointer Offset - C++

Overview

C++ inherits C's raw pointer arithmetic and array indexing, so the same unchecked-offset problem applies wherever code uses new[], raw pointers, or operator[]. The fix in modern C++ is largely structural: replace manual pointer arithmetic with containers and views that track their own bounds and can enforce a check on every access.

Primary Defense: Use std::vector, std::array, or std::span instead of raw pointers and new[]/delete[], so the size travels with the data rather than in a second variable that can go stale. Reach for .at() where an out-of-range index is a condition to handle, with one exception: std::span::operator[] is unchecked and std::span has no .at() before C++26, so a span's caller writes the check itself.

Common Vulnerable Patterns

Manual Pointer Arithmetic Over a Raw Array

// VULNERABLE - manual pointer arithmetic, no bounds check
int* data = new int[size];
for (int i = 0; i <= size; i++) {   // off-by-one: writes one past the end
    *(data + i) = value;
}
delete[] data;

Why this is vulnerable: data + i is raw pointer arithmetic with no bounds checking, and the loop condition (<=) makes it write one element past the allocation even before considering an attacker-controlled offset.

Unchecked operator[] on a Container

// VULNERABLE - operator[] performs no bounds checking
std::vector<int> data(size);
data[user_index] = value;  // no validation that user_index < size

Why this is vulnerable: std::vector::operator[] is defined to skip bounds checking for performance, matching raw array semantics. An out-of-range user_index is undefined behavior, not a caught error.

Secure Patterns

Bounds-Checked Containers

#include <vector>
#include <array>

std::vector<int> data(size);
data.at(user_index) = value;  // throws std::out_of_range if user_index >= size

std::array<int, 10> fixed_array{};
fixed_array.at(5) = 42;  // bounds-checked

Why this works: .at() validates the index against the container's actual size on every call and throws std::out_of_range instead of silently reading or writing outside the allocation. Catch the exception at a boundary that can reject the request safely.

Iterating Without Manual Offsets

std::vector<int> data(size);
for (auto& element : data) {
    element = value;
}

Why this works: A range-based for loop never computes an offset by hand, so there's no arithmetic to get wrong - the iterator is bounded by the container itself.

std::span for Bounds-Aware Views (C++20)

#include <cstddef>
#include <span>
#include <stdexcept>

void process(std::span<int> view, std::size_t offset, int value) {
    if (offset >= view.size()) {
        throw std::out_of_range("offset out of range");
    }
    view[offset] = value;
}

Why this works: std::span carries its size alongside the pointer it views, so code that receives a span instead of a raw pointer-and-length pair can check offset against view.size() without needing the caller to pass the size separately or trust it.

Turn On the Standard Library's Own Bounds Checking

# libstdc++ (GCC, and Clang using libstdc++)
g++ -std=c++20 -D_GLIBCXX_ASSERTIONS -O2 ...

# libc++ (LLVM 18 and later)
clang++ -std=c++20 -D_LIBCPP_HARDENING_MODE=_LIBCPP_HARDENING_MODE_FAST -O2 ...

# MSVC: level 2 is the debug-build default; level 1 keeps the checks in release
cl /std:c++20 /D_ITERATOR_DEBUG_LEVEL=1 ...

Why this works: These macros make operator[], front(), back() and iterator operations check their preconditions and abort on violation, so the unchecked-operator[] pattern above becomes a controlled crash instead of undefined behaviour - across every call site at once, with no source changes. That is the property .at() cannot give you on a large codebase: .at() fixes the call sites you edit, and this fixes the ones you have not found.

It is a backstop and not a substitute for validating the index, because it turns an out-of-range access into termination rather than an error the caller can handle. That is the right outcome for a bug and the wrong one for untrusted input, which should be rejected at the boundary. Use both: validate where the value arrives, and build with hardening so an index that gets past the boundary check stops rather than corrupts.

Unlike -fsanitize=address these are cheap enough to leave on in production - libc++'s fast mode is designed for exactly that, and _GLIBCXX_ASSERTIONS costs a comparison per access. Measure under load before deciding. Apply the setting uniformly across every translation unit and every prebuilt library in the binary: MSVC's _ITERATOR_DEBUG_LEVEL must match or the link fails with a diagnostic, and libstdc++'s far more intrusive _GLIBCXX_DEBUG (a different macro, not this one) changes container ABI outright, so mixing it produces corruption rather than a link error.

Considerations

Keep one source of truth for each bounds check, and know which one it is. Where a container performs the check - .at(), or a range-based loop - a manual if (index >= size) in front of it is redundant, and redundant checks drift out of step with the code they guard. Where the container does not check, std::span::operator[] being the case on this page, the caller's check is the only one there is and must stay. The distinction is not stylistic: deleting a check because "the container handles it" is correct for .at() and introduces the bug for span.

Testing

  • Compile with -fsanitize=address -g -O1 and exercise boundary and out-of-range inputs - AddressSanitizer reports raw-pointer accesses that land outside an allocation.
  • Run the suite a second time with _GLIBCXX_ASSERTIONS or _LIBCPP_HARDENING_MODE enabled. It catches what AddressSanitizer structurally cannot: an index one past the end of a std::vector often lands inside the allocation the vector reserved, so the access is in-bounds as far as ASan is concerned and out of bounds as far as the container is concerned.
  • Confirm .at() calls throw std::out_of_range for out-of-range indices with a unit test, not just a manual check.
  • Test with indices at the exact boundary (size() - 1, size(), size() + 1) and with negative values cast to an unsigned index type.

Additional Resources