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_sizebefore copying - Account for null terminator: For C strings, ensure space for
\0(usebuffer_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=addressduring development to catch overflows - Fortify Source: Enable
-D_FORTIFY_SOURCE=2for 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.
Validating input length before copying (Recommended)
#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.
Using strncpy (Not Recommended - See Warning)
#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_inputis longer thanBUFFER_SIZE - 1, data is silently truncated without indication - Zero-padding waste: If
user_inputis 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 manualbuffer[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.