Skip to content

CWE-121: Stack-based Buffer Overflow

Overview

Stack-based buffer overflow occurs when a program writes more data to a stack-allocated buffer than it can hold, corrupting adjacent memory including return addresses and local variables. This is one of the most dangerous and exploited vulnerability classes, particularly in C/C++ code.

Risk

Critical: Stack buffer overflows enable arbitrary code execution by overwriting return addresses, allowing attackers to hijack control flow, execute shellcode, bypass security controls, or crash the application. This is a primary target for exploit development and can lead to complete system compromise.

Remediation Steps

Core principle: Prevent stack overflows by enforcing buffer bounds and using safe APIs.

Locate the Stack Buffer Overflow

Review the security findings to identify the vulnerable buffer operation:

  • Find the vulnerable buffer: Identify which stack-allocated buffer is at risk
  • Identify the source: Determine where untrusted data comes from (user input, files, network, databases)
  • Locate the copy operation: Find the unsafe function (strcpy, sprintf, gets, etc.)
  • Trace the data flow: Review how data flows from source to buffer
  • Check buffer size: Determine the declared size of the stack buffer

Replace Unsafe String Functions

Replace dangerous functions with safe, bounds-checked alternatives:

  • strcpy → strncpy/strlcpy/strcpy_s: Use length-limited copy functions. See warning below about strncpy though.
  • sprintf → snprintf/sprintf_s: Use functions that respect buffer size
  • gets → fgets/getline: Never use gets() - it's always unsafe
  • strcat → strncat/strlcat: Use length-limited concatenation
  • scanf → fgets + sscanf: Validate input length before parsing
  • Use C++ std::string: Best option - no buffer overflow possible

Validate Input Lengths Before Copying

Add explicit size checking before all buffer operations:

  • Check size before copy: Ensure input_length < buffer_size before copying
  • Account for null terminator: For C strings, ensure space for \0 (use buffer_size - 1)
  • Reject oversized input: Return error instead of silently truncating
  • Use sizeof() correctly: Use sizeof(buffer) not hardcoded size
  • Explicit null termination: Always set buffer[size-1] = '\0' after bounded copy

Enable Compiler Security Protections

Use compiler flags to detect and prevent stack buffer overflows:

  • Stack canaries: Compile with -fstack-protector-strong (GCC/Clang) to detect stack corruption
  • AddressSanitizer: Use -fsanitize=address during development to catch overflows
  • Fortify Source: Enable -D_FORTIFY_SOURCE=2 for additional buffer checks
  • DEP/NX bit: Enable data execution prevention to block stack code execution
  • ASLR: Enable address space layout randomization to make exploits harder
  • Control Flow Integrity: Use -fsanitize=cfi (Clang) to prevent control flow hijacking

Consider Modern Alternatives

Migrate to safer approaches when possible:

  • Use C++ std::string: Replace char arrays with std::string (eliminates buffer overflow)
  • Use std::vector: For binary data, use std::vector<char> with bounds checking
  • Memory-safe languages: Consider Rust, Go, Java, C# for new projects
  • Use bounds-checked wrappers: Libraries like SafeInt, Microsoft's SAL annotations
  • Static analysis: Run Static Scanners to find buffer overflows

Test with Overflow Payloads

Verify the fix prevents buffer overflows:

  • Test with oversized input: Send strings larger than buffer (should be rejected or safely truncated)
  • Test exact buffer size: Send exactly buffer_size bytes (verify null terminator handling)
  • Test off-by-one: Send buffer_size - 1 bytes (should work), buffer_size bytes (should be safe)
  • Use fuzzing: Run AFL, libFuzzer, or Honggfuzz to find edge cases
  • Verify exploitation prevented: Confirm buffer overflow payloads don't achieve code execution

Common Vulnerable Patterns

Using gets() - always unsafe

#include <stdio.h>

void read_user_input() {
    char buffer[64];

    // VULNERABLE - gets() has no bounds checking whatsoever
    // It reads until newline or EOF, regardless of buffer size
    gets(buffer);  // NEVER USE THIS - removed from C11 standard

    // Attack: input 100 bytes into 64-byte buffer
    // Result: overwrites return address, achieves code execution

    printf("You entered: %s\n", buffer);
}

Using strcpy without size validation

#include <string.h>

void copy_user_data(char *user_input) {
    char buffer[64];

    // VULNERABLE - no check if user_input fits in buffer
    strcpy(buffer, user_input);

    // Attack: user_input with 200 bytes overwrites stack
    // Result: corrupts return address, potential RCE
}

void concatenate_strings(char *str1, char *str2) {
    char result[50];

    // VULNERABLE - strcat has no bounds checking
    strcpy(result, str1);
    strcat(result, str2);  // Could overflow if str1 + str2 > 50

    // Attack: str1=30 bytes, str2=30 bytes = 60 bytes total
    // Result: overflow result[50], stack corruption
}

Using sprintf without bounds checking

#include <stdio.h>

void format_message(char *username, int score) {
    char message[50];

    // VULNERABLE - sprintf doesn't check buffer size
    sprintf(message, "User: %s, Score: %d", username, score);

    // Attack: username with 100 characters overflows 50-byte buffer
    // Result: stack overflow, possible code execution
}

void build_query(char *table, char *condition) {
    char query[100];

    // VULNERABLE - multiple inputs, no size check
    sprintf(query, "SELECT * FROM %s WHERE %s", table, condition);

    // Attack: long table or condition names cause overflow
}

Off-by-one error in loop

void copy_with_loop(char *data) {
    char local[10];
    int i;

    // VULNERABLE - loop condition uses <=, accesses local[10]
    // Valid indices are 0-9, but this accesses 0-10
    for (i = 0; i <= 10; i++) {
        local[i] = data[i];
    }

    // Attack: writes to local[10], which is past the buffer end
    // Result: corrupts adjacent stack variable or return address
}

void unsafe_copy(char *src) {
    char dest[20];

    // VULNERABLE - no check if src fits in dest
    int i = 0;
    while (src[i] != '\0') {
        dest[i] = src[i];  // Could write past dest[19]
        i++;
    }
    dest[i] = '\0';
}

Secure Patterns

Using fgets instead of gets

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

#define BUFFER_SIZE 64

void read_user_input() {
    char buffer[BUFFER_SIZE];

    // Safe: fgets limits input to buffer size
    if (fgets(buffer, BUFFER_SIZE, stdin) != NULL) {
        // fgets includes newline in buffer if present
        // Remove it for cleaner processing
        size_t len = strlen(buffer);
        if (len > 0 && buffer[len-1] == '\n') {
            buffer[len-1] = '\0';
        }

        printf("You entered: %s\n", buffer);
    } else {
        fprintf(stderr, "Error reading input\n");
    }
}

Why this works: fgets(buffer, BUFFER_SIZE, stdin) reads at most BUFFER_SIZE - 1 characters (reserving one byte for the null terminator), preventing buffer overflow regardless of input length. It always null-terminates the buffer, making it safe for string operations. Unlike gets(), which was removed from C11 because it's impossible to use safely, fgets() requires you to specify the buffer size. The newline removal is optional cleanup and doesn't affect security.

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

#define BUFFER_SIZE 64

void copy_user_data(const char *user_input) {
    char buffer[BUFFER_SIZE];
    size_t input_len = strlen(user_input);

    // Best practice: validate BEFORE copying
    if (input_len >= BUFFER_SIZE) {
        fprintf(stderr, "Error: Input too long (%zu bytes, max %d)\n", 
                input_len, BUFFER_SIZE - 1);
        return;
    }

    // Safe: validated to fit with room for null terminator
    memcpy(buffer, user_input, input_len);
    buffer[input_len] = '\0';

    printf("Copied: %s\n", buffer);
}

void safe_concatenate(const char *str1, const char *str2) {
    char result[50];
    size_t len1 = strlen(str1);
    size_t len2 = strlen(str2);

    // Validate total length before copying
    if (len1 + len2 >= sizeof(result)) {
        fprintf(stderr, "Error: Combined length too long\n");
        return;
    }

    // Safe: validated to fit
    memcpy(result, str1, len1);
    memcpy(result + len1, str2, len2);
    result[len1 + len2] = '\0';
}

Why this works: Explicit length validation (input_len >= BUFFER_SIZE) before copying prevents overflow by rejecting oversized input rather than silently truncating. This fail-fast approach makes errors visible and prevents data corruption. Using memcpy() with a validated length is both safe and efficient. This pattern avoids the pitfalls of strncpy() (zero-padding when short, no null terminator when long) while providing clear error messages. Rejecting oversized input is better than silent truncation for security and correctness.

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

#define BUFFER_SIZE 64

void copy_with_strncpy(const char *user_input) {
    char buffer[BUFFER_SIZE];

    // CAUTION: strncpy has significant drawbacks
    // It was designed for fixed-width padded fields, not safe string copying
    strncpy(buffer, user_input, BUFFER_SIZE - 1);

    // CRITICAL: strncpy doesn't guarantee null termination if truncated
    // Always explicitly null-terminate
    buffer[BUFFER_SIZE - 1] = '\0';

    printf("Copied: %s\n", buffer);
}

Why this is a compromise: While this prevents buffer overflow, strncpy() has design flaws that make it error-prone.

  • Silent truncation: If user_input is longer than BUFFER_SIZE - 1, data is silently truncated without indication
  • Zero-padding waste: If user_input is shorter, strncpy() zero-fills the entire remaining buffer (performance cost and can hide bugs)
  • No null termination: Unlike strcpy(), strncpy() doesn't null-terminate if the source is too long (requires manual buffer[n-1] = '\0')
  • Designed for fixed fields: Created for space-padded database fields, not general string copying

Recommended alternatives:

  • Pre-validate length (shown above) - best for security
  • Use snprintf(buffer, size, "%s", str) - always null-terminates, no padding
  • BSD strlcpy() (if available) - designed specifically for safe string copying
  • Windows strcpy_s() - requires length, handles errors properly

Using snprintf for formatted output

#include <stdio.h>

#define MESSAGE_SIZE 50
#define QUERY_SIZE 100

void format_message(const char *username, int score) {
    char message[MESSAGE_SIZE];

    // Safe: snprintf enforces size limit and guarantees null termination
    int written = snprintf(message, MESSAGE_SIZE, "User: %s, Score: %d", 
                          username, score);

    // Check if truncation occurred
    if (written >= MESSAGE_SIZE) {
        fprintf(stderr, "Warning: Message truncated\n");
    }

    printf("%s\n", message);
}

void build_query(const char *table, const char *condition) {
    char query[QUERY_SIZE];

    // Safe: multiple inputs, but size controlled
    snprintf(query, QUERY_SIZE, "SELECT * FROM %s WHERE %s", 
             table, condition);

    // Always safe to use query - it's null-terminated and can't overflow
}

Why this works: snprintf() writes at most size - 1 characters and always adds a null terminator, making it impossible to overflow the buffer. It returns the number of characters that would have been written (excluding the null terminator), allowing detection of truncation. Unlike sprintf(), snprintf() requires the buffer size parameter, forcing developers to consider buffer limits. This makes it safe even with untrusted format strings and arguments.

Pre-validating input size before copy

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

#define BUFFER_SIZE 64

void best_practice(const char *user_input, size_t input_len) {
    char buffer[BUFFER_SIZE];

    // Validate size BEFORE copying
    if (input_len >= BUFFER_SIZE) {
        fprintf(stderr, "Error: Input exceeds buffer size (%zu >= %d)\n", 
                input_len, BUFFER_SIZE);
        return;
    }

    // Safe: we've verified input_len < BUFFER_SIZE
    memcpy(buffer, user_input, input_len);
    buffer[input_len] = '\0';  // Null terminate

    printf("Processed: %s\n", buffer);
}

void safe_copy_with_strlen(const char *user_input) {
    char buffer[BUFFER_SIZE];
    size_t input_len = strlen(user_input);

    // Check length before copying
    if (input_len >= BUFFER_SIZE) {
        fprintf(stderr, "Input too long: %zu bytes (max: %d)\n", 
                input_len, BUFFER_SIZE - 1);
        return;
    }

    // Safe: validated to fit with room for null terminator
    strcpy(buffer, user_input);  // Safe because we checked length
}

Why this works: Explicit length validation (input_len >= BUFFER_SIZE) before any copy operation provides a fail-fast approach that rejects oversized input rather than silently truncating. This prevents buffer overflows by ensuring data fits before writing. Using memcpy() with a validated length is safe and efficient. The error messages help debugging by reporting the actual size vs. limit. This pattern is particularly important when the input length is known in advance.

Using correct loop bounds

#include <string.h>

void safe_copy_loop(const char *data, size_t data_len) {
    char local[10];

    // Validate input size first
    if (data_len >= sizeof(local)) {
        fprintf(stderr, "Input too large: %zu bytes (max: %zu)\n", 
                data_len, sizeof(local) - 1);
        return;
    }

    // Safe: correct loop bounds (< not <=)
    // AND dual condition prevents overflow
    for (size_t i = 0; i < sizeof(local) && i < data_len; i++) {
        local[i] = data[i];
    }
    local[data_len] = '\0';
}

void safe_string_copy(const char *src) {
    char dest[20];
    size_t i = 0;

    // Safe: check both index AND remaining space
    while (i < sizeof(dest) - 1 && src[i] != '\0') {
        dest[i] = src[i];
        i++;
    }
    dest[i] = '\0';  // Safe: i is at most sizeof(dest) - 1
}

Why this works: Using i < sizeof(local) instead of i <= sizeof(local) prevents off-by-one errors that access one element past the array end. The dual condition (i < sizeof(local) && i < data_len) provides defense-in-depth: the first condition prevents buffer overflow, the second prevents reading past the source data. Using sizeof(dest) - 1 in loops reserves space for the null terminator. The loop counter i can be safely used for null termination because the loop conditions ensure it's within bounds.

Using C++ std::string for automatic safety

#include <string>
#include <iostream>
#include <algorithm>
#include <array>

void secure_function(const std::string& user_input) {
    // Best: std::string handles memory automatically
    // No fixed buffer size, grows as needed
    std::string buffer = user_input;

    // Optional: enforce maximum length if needed
    const size_t MAX_LEN = 64;
    if (buffer.length() > MAX_LEN) {
        buffer = buffer.substr(0, MAX_LEN);
    }

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

    // No buffer overflow possible - memory managed automatically
}

void safe_array_operations(const char* data, size_t data_len) {
    // Use std::array for fixed-size buffers
    std::array<char, 10> local{};

    // Validate size
    if (data_len >= local.size()) {
        throw std::length_error("Data too large");
    }

    // Safe copy with bounds checking
    std::copy_n(data, std::min(data_len, local.size()), local.begin());
}

void concatenate_strings(const std::string& str1, const std::string& str2) {
    // Safe: std::string handles concatenation without overflow
    std::string result = str1 + str2;

    // Works regardless of string lengths - no buffer overflow
    std::cout << "Result: " << result << std::endl;
}

Why this works: std::string automatically manages memory allocation and deallocation, eliminating fixed-size buffer overflows. It grows dynamically as needed, so concatenation and assignment operations can't overflow. The substr() method provides safe truncation without buffer overflow. std::array provides a safer alternative to C arrays with size tracking and bounds-checked access via .at(). Using C++ containers eliminates manual buffer size calculations and null terminator management, preventing an entire class of vulnerabilities while maintaining performance.

Additional Resources