Skip to content

CWE-121: Stack-based Buffer Overflow - C++

Overview

C++ inherits C's raw arrays and C string functions, so a fixed-size stack array (char buffer[64]), or a local std::array accessed through unchecked operator[], carries exactly the same stack-overflow risk as C. The difference is that C++ also offers types that manage their own size and either grow automatically or check bounds on request - the fix is usually to stop using fixed-size stack buffers and manual indexing for anything whose size depends on input, rather than to write a more careful bounds check by hand.

Which container puts the write on the stack matters for this CWE. std::array stores its elements inline, so a local one is a stack buffer and overflowing it is CWE-121. std::vector and a grown std::string hold their elements in a separate heap allocation - only the small handle sits on the stack - so an out-of-range operator[] on either corrupts the heap, which is CWE-787 (Out-of-bounds Write) and specifically the heap variant CWE-122. The missing bounds check is identical and so is the fix; what differs is what gets corrupted, which decides whether stack canaries are relevant and what a crash report will look like.

Primary Defence: Use std::string for text and std::vector/std::array for other data instead of a raw stack array, and use .at() (which throws std::out_of_range) instead of operator[] whenever the index isn't already provably in range.

Common Vulnerable Patterns

Fixed-Size Stack Buffer with a C String Function

#include <cstring>

void copyUserData(const char* userInput) {
    char buffer[64];   // stack-allocated, fixed size

    // VULNERABLE - strcpy has no awareness of buffer's 64-byte capacity
    strcpy(buffer, userInput);
}

Why this is vulnerable: Declaring buffer as a C++ local variable doesn't change how strcpy() behaves - it's the same function from the C standard library, with the same lack of a size parameter. Input longer than 63 characters overflows into whatever the compiler placed after buffer on the stack, including a saved return address.

Unchecked operator[] on a Fixed-Size Container

#include <array>

void updateScore(std::array<int, 10>& scores, size_t index, int newScore) {
    // VULNERABLE - operator[] does not bounds-check, even on std::array
    scores[index] = newScore;
}

Why this is vulnerable: std::array::operator[] is specified to have undefined behavior on an out-of-range index - it matches raw-array performance characteristics by design, not safety ones. std::array stores its elements inline rather than in a separate allocation, so when the caller's scores is a local the write corrupts the same kind of adjacent stack memory a raw C array would. The function itself cannot tell: it takes a reference, and the same code writes into the heap when the caller's array is a member of a heap-allocated object.

Secure Patterns

std::string Instead of a Fixed-Size Stack Buffer

#include <string>
#include <stdexcept>
#include <iostream>

void copyUserData(const std::string& userInput) {
    // Optional: enforce a genuine upper bound by rejecting, not truncating
    constexpr size_t maxLen = 64;
    if (userInput.length() > maxLen) {
        throw std::length_error("input exceeds the permitted length");
    }

    // Safe: std::string manages its own memory and grows as needed
    std::string buffer = userInput;

    std::cout << "User: " << buffer << std::endl;
}

Why this works: std::string allocates however much memory the actual content needs and reallocates automatically when it grows - there is no fixed-size stack buffer for a copy to overflow. Where an upper bound genuinely exists, reject the oversized value rather than calling resize() on it: truncation does not refuse the input, it silently substitutes a different one, so a username that is too long becomes a valid-looking username belonging to somebody else, and a UTF-8 value can be cut mid-sequence. The C page's length check follows the same rule.

Checked Access with .at()

#include <array>
#include <stdexcept>
#include <iostream>

bool updateScore(std::array<int, 10>& scores, size_t index, int newScore) {
    try {
        scores.at(index) = newScore;   // throws std::out_of_range if index is invalid
        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 bounds check operator[] skips and throws instead of writing out of bounds. Use operator[] only where the index has already been validated or is structurally guaranteed to be in range (for example, a loop bounded by .size()); use .at() everywhere else.

The return value is part of the fix, not decoration. A void version that logs and returns leaves the caller believing the score was recorded, so the memory-safety bug is traded for a silent data-loss bug that the log line is the only evidence of. Either report the failure as this one does, or let the exception propagate to a caller that can decide - catching it at the point of the write is only worth doing if something there can act on it.

Testing

  • Compile with AddressSanitizer and UndefinedBehaviorSanitizer (-fsanitize=address,undefined -g -O1) and run with normal, boundary, and oversized/negative indices.
  • Where the standard library implementation supports it, build and test with _GLIBCXX_ASSERTIONS (libstdc++) or the equivalent hardened mode, which adds bounds checks to normally-unchecked operations like operator[] in debug/test builds.
  • Confirm .at() calls actually throw (and are caught) for out-of-range indices in a unit test, rather than assuming the change is correct by inspection.

Common Pitfalls

  • Assuming a std::array/std::vector is automatically safe: Moving from a raw C array to std::array or std::vector doesn't add bounds checking by itself - operator[] on either type is still unchecked undefined behavior on an out-of-range index. The safety comes from switching to .at() or validating the index, not from the container type alone.
  • Mixing C++ containers with a C API that still expects a raw buffer: Calling .data() on a std::string/std::vector to pass it to a C function, then having that function write back into the buffer past .size(), reintroduces exactly the overflow the C++ container was meant to prevent - the container has no way to know about writes that happen through a raw pointer it handed out.
  • Wrapping operator[] in a try/catch "just in case": operator[] does not throw on an out-of-range access (that's .at()'s behavior) - wrapping an operator[] call in a try/catch block catches nothing, because the undefined behavior has already happened by the time any exception machinery could run.

Additional Resources