Skip to content

CWE-122: Heap-based Buffer Overflow - C++

Overview

C++ buffers can live on the heap even when nothing in the code says new. With the default allocator, a nonempty std::vector holds its elements in separately allocated storage. std::string commonly stores short text inside the object and allocates longer text separately. An invalid indexed write can exceed either the container's logical size or its allocation; these are different boundaries. The containers manage storage, but portable code must validate indices or use checked access.

The destination's actual storage determines the CWE variant. An automatic local std::array stores its elements inline, usually on the stack; see CWE-121's C++ guidance. An array inside a heap-allocated object is on the heap. A vector's default allocator uses dynamic storage, but a custom allocator may use a caller-supplied arena. Check where the affected buffer resides before classifying the write.

Primary Defence: Use .at() or a validated index wherever the index is not already provably in range, size a container with resize() or its constructor rather than reserve() before writing into it, and hand .data() to a C API only together with .size().

Common Vulnerable Patterns

Unchecked operator[] on a vector

#include <cstddef>
#include <vector>

void updateScore(std::vector<int>& scores, std::size_t index, int newScore) {
    // VULNERABLE - operator[] performs no bounds check
    scores[index] = newScore;
}

// Attack: index >= scores.size()
// Result: invalid element access; index >= capacity() also exceeds the storage

Why this is vulnerable: an index must be below scores.size(), and updateScore never checks. Without library hardening, violating that requirement has undefined behaviour. If size() <= index < capacity(), the access is invalid but still within reserved storage; at or beyond capacity() it exceeds that storage and can corrupt adjacent heap data.

reserve() Mistaken for resize()

#include <cstddef>
#include <vector>

std::vector<int> copyScores(const std::vector<int>& src) {
    std::vector<int> out;
    out.reserve(src.size());   // allocates capacity - out.size() is still 0

    for (std::size_t i = 0; i < src.size(); ++i) {
        // VULNERABLE - out has no elements, so every one of these is out of range
        out[i] = src[i];
    }

    return out;   // and size() is still 0, so the caller receives an empty vector
}

Why this is vulnerable: reserve() provides capacity without creating elements. For a nonempty source, every out[i] violates the destination's bounds even though the write is within its reserved storage. The invalid writes do not update size(), so the function may appear to return an empty vector without crashing. Once undefined behaviour occurs, that outcome is not guaranteed.

This illustrates a related container-bound violation, not a demonstrated write beyond the heap allocation. Keep that distinction when interpreting a scanner or sanitizer report.

new[] Sized from the Source String

#include <cstring>

char* copyMessage(const char* message) {
    // VULNERABLE - strlen does not count the terminator that strcpy writes
    char* buffer = new char[std::strlen(message)];

    std::strcpy(buffer, message);
    return buffer;
}

Why this is vulnerable: new char[n] is a heap allocation of exactly n bytes, and std::strcpy writes strlen(message) + 1 of them, so this overflows by one byte on every call regardless of input. Using new[] rather than malloc changes nothing about the arithmetic. The raw owning pointer is a second problem - every caller now has to delete[] it on every path - which is why the fix is to stop returning one.

Secure Patterns

Bounds-Checked Access with .at()

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

bool updateScore(std::vector<int>& scores, std::size_t index, int newScore) {
    try {
        scores.at(index) = newScore;   // SECURE - throws std::out_of_range rather than writing out of range
        return true;
    } catch (const std::out_of_range& e) {
        std::cerr << "Invalid index: " << index << " (" << e.what() << ")\n";
        return false;   // report the failure - the write did not happen
    }
}

Why this works: .at() performs the check operator[] skips, against the container's own size(), and throws instead of writing outside the allocation. Keep operator[] for indices that are already validated or structurally in range - a loop bounded by .size(), or a range-based for - and use .at() everywhere the index arrives from outside.

Return failure to the caller or let the exception propagate to a handler that can act on it. Logging alone does not tell the caller whether the score was stored.

Size the Container Before Writing Into It

#include <vector>

std::vector<int> copyScores(const std::vector<int>& src) {
    std::vector<int> out;
    out.reserve(src.size());   // still worth doing: one allocation instead of several

    for (int value : src) {
        out.push_back(value);   // SECURE - push_back grows size(), so every element written is in range
    }

    return out;
}

Why this works: push_back is the operation that turns reserved capacity into actual elements, so size() tracks what has been written and the returned vector holds the results. The reserve() call keeps its real benefit - the allocation happens once instead of growing repeatedly - without being mistaken for something that makes indices valid. Where the elements are going to be overwritten anyway, out.resize(src.size()) followed by indexed assignment is equally safe, because resize() does create the elements; the rule is that only size() makes an index valid, never capacity().

Return an Owning String for Text

#include <string>

std::string copyMessage(const char* message) {
    // SECURE - std::string owns the copy and provides its terminator
    return std::string(message);
}

Why this works: std::string manages the character storage and terminator, fixing the undersized new[] example. message must point to a valid NUL-terminated string; for input delivered with a byte length, construct from that validated readable range instead. Returning an owning string also removes the caller's delete[] obligation.

Pass data() and size() Together to a C API

#include <cstddef>
#include <string>
#include <vector>

// A C API that fills at most cap bytes and returns how many it wrote,
// or a negative value on error.
extern "C" int render_report(char* out, std::size_t cap);

std::string buildReport() {
    std::vector<char> buffer(4096);   // constructed with a size, not just a capacity

    // SECURE - the C API is told the buffer's real size, read from the buffer itself
    int written = render_report(buffer.data(), buffer.size());
    if (written < 0 || static_cast<std::size_t>(written) > buffer.size()) {
        return {};   // the callee disagreed with the contract - do not trust the content
    }

    return std::string(buffer.data(), static_cast<std::size_t>(written));
}

Why this works: buffer.size() describes the writable elements supplied to the C API. The API must honor that bound, including any terminator it writes; checking its return value cannot undo an overflow inside it. The returned count is validated before constructing the string, so that construction cannot read beyond the vector. This contract returns bytes actually written; an API such as snprintf that returns bytes required needs its own truncation check. The C API must not retain the pointer after buildReport returns.

Testing

  • Build with AddressSanitizer and UndefinedBehaviorSanitizer (-fsanitize=address,undefined -g -O1). Exercise valid indices, size(), and capacity(). A write beyond the allocation can produce heap-buffer-overflow; an invalid write within unused capacity needs container annotations or library bounds checks to be detected.
  • Test the reserve() case with annotations enabled. For libstdc++, compile with -D_GLIBCXX_SANITIZE_VECTOR and ASan consistently across translation units that create, destroy or modify the vectors, including relevant libraries. Other standard libraries have their own annotation support. Confirm that a deliberate reserved-capacity write produces container-overflow; rebuilding everything with ASan alone does not ensure annotations are enabled. Mixing annotated and unannotated operations can cause false positives as well as missed errors.
  • Turn on the standard library's own hardened mode in test builds: _GLIBCXX_ASSERTIONS for libstdc++, _LIBCPP_HARDENING_MODE=_LIBCPP_HARDENING_MODE_FAST for libc++ 18 or later, or _ITERATOR_DEBUG_LEVEL=1 for MSVC. Each adds bounds checking to operator[] and similar normally-unchecked operations.
  • Assert that an out-of-range .at() actually throws and is handled, in a unit test, rather than taking the change as correct by inspection.
  • Assert that copyScores preserves the size and values of empty and nonempty inputs, and that copyMessage preserves empty and long strings. For buildReport, test zero bytes, exactly 4096 bytes, a negative result and an excessive reported count against a stub that respects the write bound.

Common Pitfalls

  • Reading capacity() as size(): reserve(), and the capacity left behind by clear(), allocate memory without creating elements. Only size() determines which indices are valid, so a loop bounded by capacity(), or any index into reserved space, is out of range even though the bytes are inside the allocation.
  • Assuming a container type is a bounds check: moving from new char[] to std::vector or std::string manages ownership, but arbitrary indices still require validation. Arithmetic performed before calling a container can also overflow. Use .at(), a validated index, or an iterator-based loop, and check size calculations before passing them to the container.
  • Writing at s[s.size()] on a string: indexing a std::string at exactly its size is legal and yields a reference to the terminating null character, which makes it look like a usable slot. Storing anything other than '\0' there is undefined behaviour, and appending is what push_back or += are for.
  • Keeping a pointer from .data() across a growth: push_back, resize, insert and reserve may reallocate, after which every pointer, reference and iterator into the old block dangles. A write through one of those is CWE-416 (Use After Free) rather than an overflow, and it is the failure mode that replaces this one when a raw pointer is cached to avoid repeated .data() calls.

Additional Resources