Skip to content

CWE-125: Out-of-bounds Read

Overview

Out-of-bounds read occurs when software reads data past the end or before the beginning of an allocated buffer or array, accessing memory that was not intended to be read. While often less severe than writes, OOB reads can leak sensitive information or cause crashes.

Risk

High: Out-of-bounds reads can expose sensitive data from adjacent memory (passwords, keys, tokens), enable information disclosure attacks (Heartbleed), cause application crashes leading to denial of service, or leak stack/heap addresses defeating ASLR.

Remediation Steps

Core principle: Prevent out-of-bounds reads by enforcing bounds checks before access.

Locate the out-of-bounds read vulnerability in your code

  • Review the flaw details to identify the specific file, line number, and code pattern
  • Identify which buffer or array is being read beyond its bounds
  • Trace the data flow: where does the read index or offset come from (user input, calculation, loop counter)
  • Understand the bounds: what is the actual size of the buffer being accessed

Validate all array/buffer indices before reading (Primary Defense)

  • Check against bounds: Ensure index >= 0 && index < size before reading
  • Validate offsets: For offset-based reads, check offset + length <= buffer_size
  • Use unsigned types: Use size_t or unsigned int for indices to prevent negative values
  • Fix loop conditions: Ensure loops use < not <= (e.g., i < size not i <= size)
  • Validate untrusted indices: Explicitly check any indices from untrusted data sources

Use bounds-checked APIs and safe containers

  • C++ at() method: Use vector.at(index) instead of vector[index] (throws exception on OOB)
  • Use safe containers: Prefer std::string, std::vector with automatic bounds checking over raw arrays
  • Manual validation in C: In C, explicitly validate before every array access (no built-in bounds checking)
  • Avoid pointer arithmetic: Use array indexing or iterators instead of manual pointer arithmetic
  • Enable runtime checks: Use compiler/library options for runtime bounds checking (debug builds)

Validate input lengths and prevent integer overflow

  • Check buffer sizes before reading: Verify buffer has enough data before read operations
  • Validate offset + length: Check offset + length <= buffer_size to prevent wraparound
  • Watch for integer overflow: Ensure size calculations don't overflow (use safe math, check before arithmetic)
  • Verify structure size fields: Don't trust length fields from untrusted data without validation
  • Check for null terminators: For strings, verify null terminator exists within buffer bounds

Use memory safety tools during development

  • AddressSanitizer: Compile with -fsanitize=address (GCC/Clang) to catch memory errors at runtime
  • Valgrind: Run tests under Valgrind to detect reads beyond allocated memory
  • Enable compiler warnings: Use -Wall -Wextra -Werror to catch potential issues at compile time
  • Static analyzers: Run Static Analysis tools regularly as part of your SDLC
  • Fuzzing: Use AFL, libFuzzer to find edge cases that trigger OOB reads

Test with boundary conditions thoroughly

  • Test with maximum valid index (should work): reading last valid element
  • Test with OOB index (should be rejected): reading one past end
  • Test with negative indices (should be rejected): if using signed types
  • Test with zero-length buffers (should handle correctly)
  • Test with malformed data: use fuzzing to find edge cases

Common Vulnerable Patterns

Reading without bounds checking

#include <string.h>

void read_element(const char *buffer, int offset) {
    // VULNERABLE - no validation that offset is within buffer bounds
    char data = buffer[offset];

    // Attack: offset = 1000 when buffer is only 100 bytes
    // Result: reads 900 bytes past buffer end, information disclosure

    process(data);
}

void read_user_data(const char *buffer, int user_index) {
    // VULNERABLE - negative index not checked
    char value = buffer[user_index];

    // Attack: user_index = -10
    // Result: reads 10 bytes before buffer start, memory disclosure
}

Off-by-one error in loop

void process_buffer(const char *buffer, size_t size) {
    // VULNERABLE - uses <= instead of <, reads buffer[size] which is out of bounds
    for (int i = 0; i <= size; i++) {
        process(buffer[i]);
    }

    // Attack: if size = 100, this reads buffer[0] through buffer[100]
    // But valid indices are 0-99, so buffer[100] is out of bounds
    // Result: reads one byte past buffer end
}

void iterate_array(int *data, int count) {
    int sum = 0;

    // VULNERABLE - loop accesses data[count] on last iteration
    for (int i = 0; i <= count; i++) {
        sum += data[i];
    }

    // Attack: reads one past array end, potential info leak or crash
}

Trusting user-supplied length (Heartbleed-style)

void heartbleed_style_bug(const unsigned char *buffer, size_t actual_size,
                          unsigned short user_length) {
    unsigned char response[1000];

    // VULNERABLE - trusts user_length without validating against actual_size
    memcpy(response, buffer, user_length);

    // Attack: buffer is 10 bytes, user_length = 1000
    // Result: reads 990 bytes past buffer end (Heartbleed)
    // Leaks sensitive data from adjacent memory

    send_response(response, user_length);
}

void read_packet(const char *packet, size_t packet_size) {
    // VULNERABLE - reads length field from packet without validation
    unsigned short claimed_length = *(unsigned short*)packet;

    // VULNERABLE - trusts claimed_length
    char *data = (char*)malloc(claimed_length);
    memcpy(data, packet + 2, claimed_length);

    // Attack: claimed_length = 5000 but packet_size = 100
    // Result: reads 4900+ bytes beyond packet, massive info leak
}

Integer overflow in offset calculation

void read_with_offset(const char *buffer, size_t buffer_size,
                      size_t offset, size_t length) {
    // VULNERABLE - no check for integer overflow
    if (offset + length <= buffer_size) {
        // Looks safe, but offset + length can overflow!
        memcpy(dest, buffer + offset, length);
    }

    // Attack: offset = 0xFFFFFFF0, length = 0x20 (on 32-bit)
    // offset + length = 0x10 (wraps around due to overflow)
    // 0x10 <= buffer_size passes check, but reads out of bounds
}

void parse_structure(const unsigned char *data, size_t data_size) {
    size_t offset = read_uint32(data);  // Untrusted offset
    size_t count = read_uint32(data + 4);  // Untrusted count

    // VULNERABLE - offset + count could overflow
    if (offset + count * sizeof(int) <= data_size) {
        for (size_t i = 0; i < count; i++) {
            process(data[offset + i * sizeof(int)]);
        }
    }

    // Attack: offset = 0xFFFFFF00, count = large value
    // Result: integer overflow bypasses check, OOB read
}

Secure Patterns

Validating indices before array access

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

void read_element(const char *buffer, size_t buffer_size, int offset) {
    // Check for negative offset
    if (offset < 0) {
        fprintf(stderr, "Negative offset not allowed: %d\n", offset);
        return;
    }

    // Validate offset is within bounds
    if ((size_t)offset >= buffer_size) {
        fprintf(stderr, "Offset %d out of bounds (size: %zu)\n", 
                offset, buffer_size);
        return;
    }

    // Safe: offset validated
    char data = buffer[offset];
    process(data);
}

void read_user_data(const char *buffer, size_t size, int user_index) {
    // Validate index is non-negative and within bounds
    if (user_index < 0 || (size_t)user_index >= size) {
        fprintf(stderr, "Invalid index: %d (valid: 0-%zu)\n", 
                user_index, size - 1);
        return;
    }

    // Safe: validated index
    char value = buffer[user_index];
    process(value);
}

Why this works: Explicit bounds checking (offset >= 0 && offset < buffer_size) before array access ensures the index is within the valid range, preventing reads past the buffer end or before its start. Checking for negative indices prevents reading before the buffer (negative offsets). Using size_t for sizes and casting for comparison prevents signedness issues. Failing fast with error messages when validation fails prevents execution from continuing with invalid indices.

Using correct loop bounds

void process_buffer(const char *buffer, size_t size) {
    // Correct: uses < instead of <=
    // Valid indices for buffer of size N are 0 to N-1
    for (size_t i = 0; i < size; i++) {
        process(buffer[i]);
    }

    // All accesses are within bounds [0, size-1]
}

void iterate_with_limit(int *data, int count, int max_iterations) {
    // Safe: dual condition prevents both OOB and excessive iterations
    for (int i = 0; i < count && i < max_iterations; i++) {
        int value = data[i];
        process(value);
    }

    // i < count ensures we don't read past data array
    // i < max_iterations provides additional safety limit
}

void safe_string_iteration(const char *str, size_t max_len) {
    // Safe: check both index AND null terminator
    for (size_t i = 0; i < max_len && str[i] != '\0'; i++) {
        process(str[i]);
    }

    // Stops at either max_len OR null terminator, whichever comes first
}

Why this works: Using i < size instead of i <= size in loop conditions prevents off-by-one errors that read one element past the array end (e.g., accessing buffer[size] when valid indices are 0 to size-1). Dual conditions (i < count && i < max_iterations) provide defense-in-depth by enforcing multiple limits. For string iteration, checking both the index limit and null terminator prevents reading past either boundary. This pattern is essential for preventing the most common form of out-of-bounds reads.

Validating user-supplied lengths

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

void secure_read(const unsigned char *buffer, size_t buffer_size, 
                 unsigned short user_length) {
    unsigned char response[1000];

    // CRITICAL: Validate user_length against actual buffer size
    if (user_length > buffer_size) {
        fprintf(stderr, "Invalid length: %u exceeds buffer size %zu\n", 
                user_length, buffer_size);
        return;
    }

    // Validate against response buffer size
    if (user_length > sizeof(response)) {
        fprintf(stderr, "Length %u exceeds response buffer %zu\n",
                user_length, sizeof(response));
        return;
    }

    // Safe: user_length validated against both buffers
    memcpy(response, buffer, user_length);
    send_response(response, user_length);
}

void read_packet(const char *packet, size_t packet_size) {
    // Validate we can read the length field
    if (packet_size < 2) {
        fprintf(stderr, "Packet too small for header\n");
        return;
    }

    unsigned short claimed_length = *(unsigned short*)packet;

    // Validate claimed length against actual packet size
    // Account for 2-byte header
    if (claimed_length > packet_size - 2) {
        fprintf(stderr, "Claimed length %u exceeds packet data %zu\n",
                claimed_length, packet_size - 2);
        return;
    }

    // Safe: validated claimed_length
    char *data = (char*)malloc(claimed_length);
    if (data == NULL) {
        fprintf(stderr, "Memory allocation failed\n");
        return;
    }

    memcpy(data, packet + 2, claimed_length);
    process(data, claimed_length);
    free(data);
}

Why this works: Validating user-supplied lengths against actual buffer sizes (user_length > buffer_size check) prevents Heartbleed-style vulnerabilities where attackers request more data than exists in the buffer. Checking claimed lengths from packet headers before using them prevents reading past packet boundaries. The dual validation (against both source and destination buffers) ensures the copy operation is safe in both directions. This pattern is critical whenever processing untrusted length fields from network packets, file headers, or user input.

Preventing integer overflow in offset calculations

#include <stdint.h>
#include <limits.h>

void read_with_offset(const char *buffer, size_t buffer_size,
                      size_t offset, size_t length) {
    // Check for integer overflow BEFORE using offset + length
    if (offset > buffer_size) {
        fprintf(stderr, "Offset %zu exceeds buffer size %zu\n",
                offset, buffer_size);
        return;
    }

    // Check if offset + length would overflow
    if (length > buffer_size - offset) {
        fprintf(stderr, "Read range [%zu, %zu) exceeds buffer size %zu\n",
                offset, offset + length, buffer_size);
        return;
    }

    // Safe: both offset and offset+length are within bounds
    // No integer overflow possible
    memcpy(dest, buffer + offset, length);
}

void parse_structure(const unsigned char *data, size_t data_size) {
    if (data_size < 8) {
        fprintf(stderr, "Data too small for header\n");
        return;
    }

    size_t offset = read_uint32(data);
    size_t count = read_uint32(data + 4);

    // Validate offset first
    if (offset >= data_size) {
        fprintf(stderr, "Offset out of bounds\n");
        return;
    }

    // Check for multiplication overflow: count * sizeof(int)
    if (count > SIZE_MAX / sizeof(int)) {
        fprintf(stderr, "Count too large, would overflow\n");
        return;
    }

    size_t total_size = count * sizeof(int);

    // Check if offset + total_size would overflow or exceed bounds
    if (total_size > data_size - offset) {
        fprintf(stderr, "Read would exceed data bounds\n");
        return;
    }

    // Safe: all overflow checks passed
    for (size_t i = 0; i < count; i++) {
        uint32_t value;
        memcpy(&value, data + offset + i * sizeof(int), sizeof(int));
        process(value);
    }
}

Why this works: Checking length > buffer_size - offset instead of offset + length <= buffer_size prevents integer overflow by avoiding the addition altogether (subtraction can't overflow when operands are valid). Validating offset first ensures the subtraction is safe. For multiplication, checking count > SIZE_MAX / sizeof(int) prevents overflow before performing the multiplication. This reordered comparison technique is the standard way to prevent integer overflow in bounds checking. These checks are essential when dealing with untrusted offset and length values from network protocols or file formats.

Using C++ containers with bounds checking

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

void read_element(const std::vector<char>& buffer, size_t offset) {
    // Safe: at() performs bounds checking automatically
    try {
        char data = buffer.at(offset);
        process(data);
    } catch (const std::out_of_range& e) {
        std::cerr << "Offset out of range: " << e.what() << std::endl;
    }
}

void process_buffer(const std::vector<char>& buffer) {
    // Safe: range-based for loop, no index arithmetic
    for (const auto& byte : buffer) {
        process(byte);
    }

    // Alternative: traditional loop with size check
    for (size_t i = 0; i < buffer.size(); i++) {
        process(buffer[i]);  // Safe: i is always < buffer.size()
    }
}

void secure_read(const std::vector<unsigned char>& buffer, 
                 unsigned short user_length) {
    // Validate user_length against buffer size
    if (user_length > buffer.size()) {
        throw std::length_error("Requested length exceeds buffer size");
    }

    // Safe: validated length, bounds-checked container
    std::vector<unsigned char> response(user_length);
    std::copy_n(buffer.begin(), user_length, response.begin());
    send_response(response);
}

void read_user_data(const std::vector<char>& buffer, int user_index) {
    // Manual validation for better error messages
    if (user_index < 0 || static_cast<size_t>(user_index) >= buffer.size()) {
        std::cerr << "Invalid index " << user_index 
                  << " (valid: 0-" << buffer.size() - 1 << ")" << std::endl;
        return;
    }

    // Safe: validated index, or use at() for automatic checking
    char value = buffer.at(user_index);
    process(value);
}

Why this works: The at() method performs automatic runtime bounds checking and throws std::out_of_range exception on invalid access, preventing silent out-of-bounds reads. std::vector tracks its own size via .size(), eliminating manual size tracking errors. Range-based for loops iterate safely without any index arithmetic, preventing off-by-one errors. The std::copy_n() algorithm with validated length provides safe copying. C++ containers provide memory safety with minimal performance overhead compared to manual bounds checking in C, and the exception-based error handling ensures OOB reads are caught rather than causing silent data leakage.

Additional Resources