Out-of-bounds writes (CWE-787) are the single most dangerous class of memory corruption vulnerabilities on the SANS/CWE Top 25, and they’ve held that position for years. The reason is clear once you dig into the mechanics: writing past the end of a buffer can overwrite return addresses, function pointers, vtable entries, and adjacent heap metadata, giving attackers arbitrary code execution. Unlike higher-level languages where the runtime catches array index violations, C and C++ silently corrupt memory, and the consequences may not manifest until thousands of instructions later. Even Rust, with its ownership model, is vulnerable when unsafe blocks bypass the borrow checker. In this post I’ll dissect out-of-bounds writes in C, C++, and Rust, from the classic strcpy overflow to the subtle off-by-one in pointer arithmetic that can survive expert review.

Why Out-of-Bounds Writes Are So Dangerous

An out-of-bounds write corrupts memory that belongs to a different variable, structure, or allocation. The impact depends on what gets overwritten, and the exploitation techniques are well-documented:

  • Stack buffer overflow: Overwrites the return address, redirecting execution to attacker-controlled code. This is the classic exploitation path, mitigated (but not eliminated) by stack canaries and ASLR.
  • Heap buffer overflow: Overwrites heap metadata or adjacent allocations. This can be used to corrupt function pointers, vtable pointers, and application data structures.
  • Global/BSS overflow: Overwrites global variables, potentially changing program behaviour (e.g., overwriting an is_admin flag). This is one of the most intuitive examples to demonstrate because the impact is so direct.
  • Off-by-one: Writes exactly one byte past the buffer. Often overwrites a null terminator or a least-significant byte of an adjacent pointer, enabling more subtle exploitation.

Modern mitigations (ASLR, DEP/NX, stack canaries, FORTIFY_SOURCE) raise the bar but do not eliminate the vulnerability. The research shows attackers routinely bypass these protections through information leaks, ROP chains, and heap grooming.

The Easy-to-Spot Version

C: Classic Stack Buffer Overflow

#include <stdio.h>
#include <string.h>

void process_username(const char *input) {
    char username[32];
    strcpy(username, input);
    printf("Processing user: %s\n", username);
}

int main(int argc, char *argv[]) {
    if (argc > 1) {
        process_username(argv[1]);
    }
    return 0;
}

strcpy copies bytes until it hits a null terminator, with no regard for the destination buffer size. If argv[1] is longer than 31 bytes, the write overflows username and corrupts the stack frame, the saved frame pointer, the return address, and potentially the caller’s local variables. With a crafted input, an attacker controls the return address and achieves arbitrary code execution.

Every SAST tool and compiler warning flags strcpy. It still shows up in production code more often than you’d expect, usually in legacy C codebases that nobody wants to touch.

C++: Unchecked Array Index

#include <iostream>
#include <cstring>

class UserProfile {
public:
    char name[64];
    int role;  // 0 = user, 1 = admin

    void setName(const char* input) {
        // No bounds check
        std::strcpy(name, input);
    }

    void display() {
        std::cout << "Name: " << name
                  << ", Role: " << (role == 1 ? "admin" : "user")
                  << std::endl;
    }
};

int main() {
    UserProfile profile;
    profile.role = 0;

    char input[256];
    std::cout << "Enter name: ";
    std::cin.getline(input, 256);
    profile.setName(input);
    profile.display();

    return 0;
}

The name field is 64 bytes, but setName uses strcpy with no length check. Because role is laid out immediately after name in the struct, an input longer than 64 bytes overwrites role. An attacker provides a 65-byte string where byte 65 is \x01 followed by a null terminator, the role field becomes 1 (admin). This is a data-only attack that doesn’t require code execution. What I find compelling about this example is how clearly it shows why struct layout matters, it’s a great one for demonstrating the concept to developers who haven’t thought about memory layout before.

The Hard-to-Spot Version

C: Off-by-One in Loop Bound

#include <stdio.h>
#include <stdlib.h>
#include <string.h>

typedef struct {
    char buffer[128];
    int flags;
} Record;

Record* parse_records(const char *data, int count) {
    Record *records = malloc(count * sizeof(Record));
    if (!records) return NULL;

    const char *ptr = data;
    for (int i = 0; i <= count; i++) {  // Off-by-one: should be i < count
        size_t len = strlen(ptr);
        if (len >= sizeof(records[i].buffer)) {
            len = sizeof(records[i].buffer) - 1;
        }
        memcpy(records[i].buffer, ptr, len);
        records[i].buffer[len] = '\0';
        records[i].flags = 0;
        ptr += len + 1;
    }
    return records;
}

The loop condition is i <= count instead of i < count. The allocation is count * sizeof(Record), so valid indices are 0 through count - 1. The final iteration writes to records[count], which is one Record past the end of the allocation. This overwrites whatever is adjacent on the heap, potentially heap metadata, another allocation’s header, or a function pointer in a nearby object.

This bug is notoriously hard to spot in review because <= vs < is a single character difference, and the loop body looks correct. I’ve caught myself missing this pattern, and it taught me to always double-check loop bounds against allocation sizes. The off-by-one only manifests when count records are provided, making it intermittent in testing, which is exactly why it survives into production.

C++: Iterator Invalidation Leading to Out-of-Bounds Write

#include <vector>
#include <string>
#include <algorithm>

class MessageQueue {
    std::vector<std::string> messages;
    std::vector<std::string>::iterator current;

public:
    MessageQueue() {
        messages.reserve(16);
        current = messages.begin();
    }

    void addMessage(const std::string& msg) {
        messages.push_back(msg);
        // current iterator may be invalidated if vector reallocated
    }

    void processNext() {
        if (current != messages.end()) {
            transform(*current);
            ++current;
        }
    }

    void transform(std::string& msg) {
        for (size_t i = 0; i < msg.size(); i++) {
            msg[i] = toupper(msg[i]);
        }
    }

    void bulkInsertAndProcess(const std::vector<std::string>& batch) {
        current = messages.begin();
        for (const auto& msg : batch) {
            addMessage(msg);
            processNext();  // Uses potentially invalidated iterator
        }
    }
};

When push_back causes the vector to reallocate (exceeding the reserved capacity of 16), all existing iterators are invalidated. The current iterator now points to freed memory. The subsequent processNext() call dereferences the dangling iterator and writes through it (the toupper transformation), corrupting whatever now occupies that memory address. This is an out-of-bounds write through a dangling pointer, a combination of CWE-787 and CWE-416.

What makes this pattern particularly insidious is that the bug only triggers when the vector grows past its capacity, making it dependent on the batch size. Small batches work fine in testing; large batches corrupt memory in production. This class of bug, interleaving container mutations with iteration, is a recurring source of problems in C++ codebases, and it’s always a nightmare to debug because the crash happens far from the corruption site.

Rust: Unsafe Pointer Arithmetic

use std::alloc::{alloc, dealloc, Layout};
use std::ptr;

struct RingBuffer {
    data: *mut u8,
    capacity: usize,
    write_pos: usize,
}

impl RingBuffer {
    fn new(capacity: usize) -> Self {
        let layout = Layout::array::<u8>(capacity).unwrap();
        let data = unsafe { alloc(layout) };
        RingBuffer {
            data,
            capacity,
            write_pos: 0,
        }
    }

    fn write(&mut self, bytes: &[u8]) {
        unsafe {
            for &b in bytes {
                let offset = self.write_pos % self.capacity;
                ptr::write(self.data.add(offset), b);
                self.write_pos += 1;
            }
        }
    }

    fn write_at(&mut self, offset: usize, bytes: &[u8]) {
        unsafe {
            // Missing bounds check: offset + bytes.len() could exceed capacity
            ptr::copy_nonoverlapping(
                bytes.as_ptr(),
                self.data.add(offset),
                bytes.len(),
            );
        }
    }
}

impl Drop for RingBuffer {
    fn drop(&mut self) {
        let layout = Layout::array::<u8>(self.capacity).unwrap();
        unsafe { dealloc(self.data, layout) };
    }
}

The write method correctly wraps around using modulo. But write_at takes an arbitrary offset and length with no bounds check inside the unsafe block. If offset + bytes.len() > capacity, the ptr::copy_nonoverlapping writes past the allocation. Rust’s safety guarantees do not apply inside unsafe, the programmer is responsible for upholding invariants, and this code fails to do so.

I pay extra attention to unsafe blocks in Rust code reviews. This pattern tends to appear in performance-critical Rust code where developers use unsafe for zero-copy operations and forget that they’ve opted out of bounds checking. The unsafe keyword is supposed to be a promise that you’ve manually verified the invariants, but reading through Rust CVE reports, that promise gets broken more often than you’d hope.

Detection Strategies

Static Analysis

Tool Language What It Catches Limitations
GCC -Wall -Wextra C/C++ strcpy warnings, some buffer size mismatches Cannot track dynamic sizes or heap allocations
Clang -fsanitize=address C/C++ Runtime detection of heap/stack overflows Requires test execution with triggering inputs
cppcheck C/C++ Buffer overflows, array index out of bounds Limited interprocedural analysis
clang-tidy C/C++ bugprone-* checks for unsafe string functions Cannot detect all off-by-one errors
Clippy Rust Warns about unsafe blocks, suggests safe alternatives Cannot verify correctness inside unsafe
Miri Rust Runtime detection of undefined behavior in unsafe code Requires test execution, slow
Semgrep All Pattern matching for unsafe functions Cannot reason about buffer sizes

What I’ve found is that the static analysis tools catch the easy cases, strcpy, obvious unbounded copies, but miss the off-by-one errors and iterator invalidation bugs that actually make it to production. That’s where runtime tools like AddressSanitizer and Miri earn their keep.

Compiler Hardening Flags

Flag Compiler Effect
-fstack-protector-strong GCC/Clang Stack canaries for functions with local arrays
-D_FORTIFY_SOURCE=2 GCC/Clang Compile-time and runtime bounds checking for string functions
-fsanitize=address GCC/Clang AddressSanitizer: runtime detection of out-of-bounds access
-fsanitize=undefined GCC/Clang UBSan: runtime detection of undefined behaviour
-fPIE -pie GCC/Clang Position-independent executable for full ASLR

Enabling -fsanitize=address during development and CI is one of the highest-value things you can do for a C/C++ project. It catches out-of-bounds writes at the exact point of corruption, not thousands of instructions later when the corrupted data is finally used. The performance overhead is significant (2-3x), so it’s not for production, but it’s invaluable for testing.

Manual Review Indicators

Here’s what’s worth looking for when reviewing code for out-of-bounds writes:

  1. Any use of strcpy, strcat, sprintf, gets, these are unbounded and should be replaced with their n-variants or safer alternatives. These are the first things to grep for in any C/C++ review.
  2. Loop bounds using <= with array/allocation size, the classic off-by-one pattern. It’s worth pausing on every <= in a loop condition and verifying it against the allocation.
  3. Pointer arithmetic without bounds validation, especially ptr + offset where offset comes from external input.
  4. memcpy/memmove where the size parameter is not validated against the destination buffer size.
  5. Iterator use after container modification in C++, any push_back, insert, or erase can invalidate iterators. Look for interleaved mutation and iteration patterns.
  6. unsafe blocks in Rust that perform raw pointer operations, each one needs manual verification of bounds. Treating every unsafe block as a potential vulnerability until proven otherwise is a reasonable default.

Remediation

C: Bounded String Operations

#include <stdio.h>
#include <string.h>

void process_username(const char *input) {
    char username[32];
    size_t input_len = strlen(input);
    if (input_len >= sizeof(username)) {
        fprintf(stderr, "Username too long: %zu bytes\n", input_len);
        return;
    }
    strncpy(username, input, sizeof(username) - 1);
    username[sizeof(username) - 1] = '\0';
    printf("Processing user: %s\n", username);
}

Use strncpy with explicit size limits, and always null-terminate. Better yet, use snprintf for formatted strings, which guarantees null termination and returns the number of characters that would have been written. snprintf is generally preferable over strncpy in most cases because strncpy has its own quirks (it doesn’t null-terminate if the source is too long, and it zero-fills the rest of the buffer).

C: Fixed Loop Bound

Record* parse_records(const char *data, int count) {
    Record *records = malloc(count * sizeof(Record));
    if (!records) return NULL;

    const char *ptr = data;
    for (int i = 0; i < count; i++) {  // Fixed: < instead of <=
        size_t len = strlen(ptr);
        if (len >= sizeof(records[i].buffer)) {
            len = sizeof(records[i].buffer) - 1;
        }
        memcpy(records[i].buffer, ptr, len);
        records[i].buffer[len] = '\0';
        records[i].flags = 0;
        ptr += len + 1;
    }
    return records;
}

C++: Safe Container Access

#include <vector>
#include <string>
#include <algorithm>

class MessageQueue {
    std::vector<std::string> messages;
    size_t currentIndex = 0;

public:
    void addMessage(const std::string& msg) {
        messages.push_back(msg);
    }

    void processNext() {
        if (currentIndex < messages.size()) {
            std::transform(messages[currentIndex].begin(),
                           messages[currentIndex].end(),
                           messages[currentIndex].begin(),
                           ::toupper);
            ++currentIndex;
        }
    }

    void bulkInsertAndProcess(const std::vector<std::string>& batch) {
        currentIndex = 0;
        for (const auto& msg : batch) {
            addMessage(msg);
        }
        while (currentIndex < messages.size()) {
            processNext();
        }
    }
};

Replace the raw iterator with an index. Indices remain valid across reallocations. Separate the insert and process phases to avoid interleaving mutations with iteration. This separation of concerns is the single most effective pattern for avoiding iterator invalidation bugs.

Rust: Bounds-Checked Unsafe Operations

impl RingBuffer {
    fn write_at(&mut self, offset: usize, bytes: &[u8]) -> Result<(), &'static str> {
        if offset.checked_add(bytes.len()).map_or(true, |end| end > self.capacity) {
            return Err("write_at: out of bounds");
        }
        unsafe {
            ptr::copy_nonoverlapping(
                bytes.as_ptr(),
                self.data.add(offset),
                bytes.len(),
            );
        }
        Ok(())
    }
}

The fixed write_at checks that offset + bytes.len() does not exceed capacity, using checked_add to prevent integer overflow in the bounds check itself. The function returns a Result so callers must handle the error. The unsafe block only executes after the bounds have been verified. The pattern here is: validate first, then enter unsafe. Never the other way around.

Here’s the key principle that keeps coming up across all of these: prefer safe abstractions. In C, use snprintf over sprintf, strncat over strcat. In C++, use std::string and std::vector with .at() for bounds-checked access. In Rust, minimise unsafe blocks. Enable all compiler warnings and sanitizers during development. And pay special attention to off-by-one errors, they’re the most common variant, and the difference between < and <= has caused more security vulnerabilities than anyone can count.