Skip to content

CWE-125: Out-of-bounds Read - C++

Overview

C++ inherits C's raw arrays and pointer arithmetic, so the same out-of-bounds reads are possible - operator[] on std::vector, std::array, and std::string performs no bounds checking by default. The fix in C++ is usually to stop indexing by hand: use the bounds-checked .at() accessor, prefer range-based iteration that never computes an index at all, and use std::span (C++20) to keep a pointer and its length bound together instead of passing them as two separate parameters that can drift out of sync.

Primary Defence: Use .at() instead of operator[] whenever the index isn't already provably in range, and prefer std::span/iterators over a raw (pointer, length) pair so the length travels with the data instead of being trusted separately.

Common Vulnerable Patterns

Unchecked operator[]

// VULNERABLE - operator[] does not bounds-check, even on std::vector
int readScore(const std::vector<int>& scores, size_t index) {
    return scores[index];   // undefined behavior if index >= scores.size()
}

Why this is vulnerable: std::vector::operator[] is specified to have undefined behavior on an out-of-range index - it does not throw or check, matching raw-array performance. An attacker-influenced index reads whatever memory follows the vector's internal buffer.

Raw Pointer and Length Passed Separately

// VULNERABLE - length is trusted independently of what buffer actually points to
void readPayload(const char *buffer, size_t bufferSize, size_t requestedLength) {
    for (size_t i = 0; i < requestedLength; i++) {   // no check against bufferSize
        process(buffer[i]);
    }
}

Why this is vulnerable: Nothing ties requestedLength to bufferSize - if a caller (or the data driving requestedLength) supplies a value larger than the buffer actually holds, the loop reads past it.

Iterating with a Miscalculated End Index

// VULNERABLE - off-by-one: valid indices are 0..size-1, but the loop reads data[size]
void sumBuffer(const int *data, size_t size) {
    int total = 0;
    for (size_t i = 0; i <= size; i++) {
        total += data[i];
    }
}

Why this is vulnerable: The <= comparison includes data[size], one element past the array - the same off-by-one mistake that occurs in C, since C++ doesn't add automatic bounds checking to raw pointer indexing.

Secure Patterns

Checked Access with .at()

#include <vector>
#include <stdexcept>
#include <iostream>

int readScore(const std::vector<int>& scores, size_t index) {
    try {
        return scores.at(index);   // throws std::out_of_range if index is invalid
    } catch (const std::out_of_range& e) {
        std::cerr << "Invalid score index: " << index << " (" << e.what() << ")\n";
        return -1;
    }
}

Why this works: .at() performs the bounds check operator[] skips and throws instead of reading out of bounds. Use operator[] only where the index has already been validated or is structurally guaranteed in range (e.g. a loop bound by .size()).

std::span Ties Pointer and Length Together (C++20)

#include <span>
#include <stdexcept>

void readPayload(std::span<const char> buffer, size_t requestedLength) {
    if (requestedLength > buffer.size()) {
        throw std::out_of_range("requested length exceeds buffer size");
    }
    for (size_t i = 0; i < requestedLength; i++) {   // safe: checked against buffer.size()
        process(buffer[i]);
    }
}

Why this works: std::span carries its length alongside the pointer, so the function can validate requestedLength against the actual size of the data it was given rather than trusting a second, independently-supplied size parameter that could disagree with it.

Range-Based Iteration (No Index at All)

#include <vector>

void sumBuffer(const std::vector<int>& data) {
    int total = 0;
    for (const auto& value : data) {   // no index arithmetic, no off-by-one possible
        total += value;
    }
}

Why this works: A range-based for loop never computes an index, so there's no boundary calculation to get wrong. Use it whenever the loop doesn't need the index itself.

Testing

  • Compile with AddressSanitizer and UndefinedBehaviorSanitizer (-fsanitize=address,undefined -g -O1) and run with normal, boundary, and oversized/negative indices.
  • Build and test with the standard library's hardened mode, which adds bounds checks to operator[] and the other normally-unchecked operations: -D_GLIBCXX_ASSERTIONS on libstdc++, -D_LIBCPP_HARDENING_MODE=_LIBCPP_HARDENING_MODE_FAST on libc++ (the fast category covers operator[] and iterator access; extensive adds unrelated checks on top and works too, but isn't required here), and on the Microsoft STL the equivalent container checks are already on in debug builds, where _ITERATOR_DEBUG_LEVEL defaults to 2. These turn an out-of-bounds subscript into a deterministic abort rather than whatever the adjacent memory happened to hold, which is what makes a failing test reproducible.
  • Run under Valgrind as an independent check.

Common Pitfalls

  • Using .at() inconsistently: Switching the top-level accessor to .at() but leaving a helper function nearby (or an internal loop within the same class) still indexing the same container with operator[] re-opens the same unchecked read - the fix has to reach every access point, not just the one the finding pointed at.
  • Validating requestedLength against a stale or assumed size instead of span.size()/vector.size(): A separately-tracked size variable can drift from the container's actual size after a resize, a move, or a reallocation, and the check then passes a length that reads past the end.
  • Assuming std::string/std::vector are always safe because they're not raw arrays: Both still use unchecked operator[] by default - the safety comes specifically from using .at() or iterators, not from the container type alone.

Additional Resources