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
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 -O1and 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_ASSERTIONSor_LIBCPP_HARDENING_MODEenabled. It catches what AddressSanitizer structurally cannot: an index one past the end of astd::vectoroften 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 throwstd::out_of_rangefor 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.