CWE-170: Improper Null Termination
Overview
Improper null termination occurs when code fails to properly add or check for null terminators ('\0') in C-style strings, causing buffer overruns, information disclosure, or unexpected behavior when string functions read beyond intended boundaries.
Risk
High: Missing or improper null terminators cause string functions (strlen, strcpy, strcat) to read/write beyond buffer boundaries, leading to buffer overflows, crashes, information leaks (reading adjacent memory), and potential code execution.
Remediation Steps
Core principle: Ensure strings are properly null-terminated and lengths tracked; never rely on implicit termination.
Locate Improper Null Termination in Your Code
When reviewing security scan results:
- Find the vulnerable operation: Identify where strings are copied, read, or manipulated without proper null termination
- Check buffer operations: Look for strncpy, memcpy, recv, read, fgets operations
- Identify the source: Determine where string data comes from (user input, files, network, external APIs)
- Trace the data flow: Review how strings are allocated, copied, and used
- Check buffer sizing: Verify if buffer allocations account for null terminator (+1)
Common vulnerable patterns:
// Missing +1 for null terminator
char *buf = malloc(strlen(input)); // Should be strlen(input) + 1
// strncpy doesn't guarantee null termination
char buffer[10];
strncpy(buffer, input, 10); // If input >= 10 chars, no null terminator
// Network/file data not null-terminated
recv(socket, buffer, 256, 0); // buffer not null-terminated
Always Null-Terminate Strings (Primary Defense)
#include <string.h>
#include <stdio.h>
// VULNERABLE - strncpy doesn't guarantee null termination
void copy_bad(const char *input) {
char buffer[10];
strncpy(buffer, input, 10);
// If input is >= 10 chars, buffer is NOT null-terminated!
printf("%s\n", buffer); // May read past buffer
}
// SECURE - explicit null termination
void copy_safe(const char *input) {
char buffer[10];
// Copy up to buffer_size - 1 characters
strncpy(buffer, input, sizeof(buffer) - 1);
// ALWAYS explicitly null-terminate
buffer[sizeof(buffer) - 1] = '\0';
printf("%s\n", buffer);
}
// SECURE - account for null terminator in allocation
void allocate_safe(const char *input) {
size_t len = strlen(input);
// CORRECT: allocate length + 1 for null terminator
char *buffer = malloc(len + 1);
if (buffer == NULL) return;
strcpy(buffer, input);
// Or safer:
memcpy(buffer, input, len);
buffer[len] = '\0'; // Explicit null termination
free(buffer);
}
// SECURE - null-terminate network/file data
void read_from_network_safe(int socket) {
char buffer[256];
// Reserve space for null terminator
ssize_t bytes_read = recv(socket, buffer, sizeof(buffer) - 1, 0);
if (bytes_read > 0) {
// Null-terminate the received data
buffer[bytes_read] = '\0';
printf("Received: %s\n", buffer);
}
}
Why this works: Null terminator ('\0') marks the end of C strings. Without it, string functions (strlen, printf, strcpy) read/write beyond buffer boundaries, causing overflows, crashes, or information leaks.
Critical rules:
- Always use
size - 1when copying to reserve space for null terminator - Explicitly set
buffer[size-1] = '\0'after bounded string operations - Allocate
strlen(str) + 1for dynamic buffers (the +1 is for '\0') - Null-terminate after network/file reads - external data is never null-terminated
Use Safe String Functions
#include <string.h>
#include <stdio.h>
// Dangerous: old unsafe functions
void unsafe_operations(const char *src) {
char dest[10];
strcpy(dest, src); // NO bounds check - buffer overflow!
strcat(dest, src); // NO bounds check - buffer overflow!
sprintf(dest, "%s", src); // NO bounds check - buffer overflow!
}
// Safe: Use bounded string functions
void safe_operations(const char *src) {
char dest[10];
// strncpy - but MUST explicitly null-terminate
strncpy(dest, src, sizeof(dest) - 1);
dest[sizeof(dest) - 1] = '\0';
// snprintf - ALWAYS null-terminates
snprintf(dest, sizeof(dest), "%s", src);
// strncat - leaves room for null, but validate first
size_t current_len = strnlen(dest, sizeof(dest));
if (current_len < sizeof(dest) - 1) {
strncat(dest, src, sizeof(dest) - current_len - 1);
}
}
// Better: Use modern safe functions (C11 Annex K)
#ifdef __STDC_LIB_EXT1__
void c11_safe_operations(const char *src) {
char dest[10];
// strcpy_s - always null-terminates, returns error if too long
errno_t err = strcpy_s(dest, sizeof(dest), src);
if (err != 0) {
fprintf(stderr, "String copy failed\n");
}
// strcat_s - safe concatenation
strcat_s(dest, sizeof(dest), " suffix");
}
#endif
// Best (BSD): strlcpy/strlcat - always null-terminate
#ifdef BSD
void bsd_safe_operations(const char *src) {
char dest[10];
// strlcpy ALWAYS null-terminates and returns total length needed
size_t result = strlcpy(dest, src, sizeof(dest));
if (result >= sizeof(dest)) {
fprintf(stderr, "String was truncated\n");
}
// strlcat - safe concatenation, always null-terminates
strlcat(dest, " suffix", sizeof(dest));
}
#endif
Safe function comparison: | Function | Null-Terminates? | Bounds Check? | Recommended? | |----------|-----------------|---------------|-------------| | strcpy | Yes | NO | Never use | | strncpy | NO (if src >= n) | Yes | Use with explicit null-term | | snprintf | Always | Yes | Recommended | | strcpy_s (C11) | Always | Yes | Good (if available) | | strlcpy (BSD) | Always | Yes | Best (if available) |
Validate String Length and Buffer Sizes
#include <string.h>
#include <stdio.h>
// Validate before operations
void validate_and_copy(const char *src, char *dest, size_t dest_size) {
// Check for null pointers
if (src == NULL || dest == NULL || dest_size == 0) {
return;
}
// Get source length
size_t src_len = strlen(src);
// Validate: ensure dest has room for src + null terminator
if (src_len >= dest_size) {
fprintf(stderr, "Source too long: %zu bytes, dest size: %zu\n",
src_len, dest_size);
return; // Reject instead of silently truncating
}
// Safe: we've validated the copy will fit
strcpy(dest, src);
}
// Use strnlen to prevent overreads
size_t safe_string_length(const char *str, size_t maxlen) {
// strnlen: like strlen but won't read past maxlen
// Prevents reading uninitialized/invalid memory
return strnlen(str, maxlen);
}
// Validate buffer has null terminator
int has_null_terminator(const char *buffer, size_t buffer_size) {
for (size_t i = 0; i < buffer_size; i++) {
if (buffer[i] == '\0') {
return 1; // Found null terminator
}
}
return 0; // No null terminator found
}
// Safe file reading
void read_file_safe(const char *filename) {
FILE *fp = fopen(filename, "r");
if (fp == NULL) return;
char buffer[256];
// fgets always null-terminates (unlike fread)
while (fgets(buffer, sizeof(buffer), fp) != NULL) {
// buffer is guaranteed to be null-terminated
printf("%s", buffer);
}
fclose(fp);
}
Use Modern C++ String Classes (Avoid Manual Null Termination)
#include <string>
#include <iostream>
#include <vector>
class SecureStringHandler {
public:
// Best: Use std::string - automatic null termination, no overflows
void process_with_string(const std::string& input) {
std::string buffer = input.substr(0, 9); // Safe truncation
std::cout << "Data: " << buffer << std::endl;
// No buffer overflow possible
buffer += " more data";
buffer.append(input);
}
// For binary data: use std::vector
void process_binary_data(const std::vector<char>& data) {
// std::vector handles sizing automatically
std::vector<char> buffer(data.begin(),
data.begin() + std::min(size_t(10), data.size()));
// Access is bounds-checked in debug builds
for (char c : buffer) {
std::cout << c;
}
}
// Converting C strings to C++ strings immediately
void process_c_string(const char *c_str) {
if (c_str == nullptr) return;
// Convert to std::string immediately - safe from null termination issues
std::string safe_str(c_str);
// All operations now safe
safe_str.append(" suffix");
std::cout << safe_str << std::endl;
}
// For C API boundaries
const char* get_c_string() {
static std::string result = "safe string";
// c_str() returns null-terminated C string
return result.c_str();
}
};
// Reading from network in C++
void read_network_cpp(int socket) {
std::vector<char> buffer(256);
ssize_t bytes_read = recv(socket, buffer.data(), buffer.size() - 1, 0);
if (bytes_read > 0) {
// Convert to std::string with explicit length
std::string data(buffer.data(), bytes_read);
std::cout << "Received: " << data << std::endl;
}
}
Why C++ strings are safer:
- Automatic memory management: No manual malloc/free
- Automatic null termination: Always properly terminated
- Bounds-checked operations:
.at()throws on out-of-bounds - No buffer overflows: Strings grow automatically
- RAII: Proper cleanup on exceptions
Test with Edge Cases
#include <string.h>
#include <stdio.h>
#include <assert.h>
// Test suite for null termination
void test_null_termination() {
char buffer[10];
// Test 1: String exactly fits (9 chars + null)
const char *test1 = "123456789";
strncpy(buffer, test1, sizeof(buffer) - 1);
buffer[sizeof(buffer) - 1] = '\0';
assert(strlen(buffer) == 9);
assert(buffer[9] == '\0');
printf("✓ Test 1: Exact fit\n");
// Test 2: String longer than buffer
const char *test2 = "1234567890ABCDEF";
strncpy(buffer, test2, sizeof(buffer) - 1);
buffer[sizeof(buffer) - 1] = '\0';
assert(strlen(buffer) == 9); // Should be truncated
assert(buffer[9] == '\0'); // Should be null-terminated
printf("✓ Test 2: Truncation\n");
// Test 3: Empty string
const char *test3 = "";
strncpy(buffer, test3, sizeof(buffer) - 1);
buffer[sizeof(buffer) - 1] = '\0';
assert(strlen(buffer) == 0);
assert(buffer[0] == '\0');
printf("✓ Test 3: Empty string\n");
// Test 4: Allocation with +1
const char *test4 = "Dynamic string";
size_t len = strlen(test4);
char *dynamic = malloc(len + 1); // Must include +1
assert(dynamic != NULL);
strcpy(dynamic, test4);
assert(dynamic[len] == '\0');
assert(strlen(dynamic) == len);
free(dynamic);
printf("✓ Test 4: Dynamic allocation\n");
// Test 5: Network data simulation
char net_buffer[20];
size_t received = 15; // Simulated bytes received
memset(net_buffer, 'X', received); // Simulate network data
net_buffer[received] = '\0'; // Must null-terminate
assert(strlen(net_buffer) == received);
printf("✓ Test 5: Network data\n");
}
int main() {
test_null_termination();
printf("All null termination tests passed!\n");
return 0;
}
Common Vulnerable Patterns
Using strncpy without null termination
#include <string.h>
#include <stdio.h>
void copy_name(const char *input) {
char buffer[10];
// VULNERABLE - strncpy doesn't guarantee null termination
strncpy(buffer, input, 10);
// Attack: input = "0123456789ABCD" (14 chars)
// strncpy copies exactly 10 bytes: "0123456789"
// buffer has NO null terminator!
// VULNERABLE - printf reads past buffer looking for '\0'
printf("Name: %s\n", buffer);
// Result: reads adjacent memory, potential info leak or crash
}
void copy_password(const char *pwd) {
char stored_pwd[32];
// VULNERABLE - no null termination
strncpy(stored_pwd, pwd, sizeof(stored_pwd));
// Attack: pwd with 32+ characters fills buffer without '\0'
// Later comparison using strcmp reads beyond buffer
// Result: authentication bypass or info disclosure
}
Network/file data without null termination
#include <sys/socket.h>
#include <stdio.h>
void read_from_network(int socket) {
char buffer[256];
// Receives raw bytes from network
int bytes_read = recv(socket, buffer, 256, 0);
// VULNERABLE - network data is NEVER null-terminated
printf("Received: %s\n", buffer);
// Attack: send 256 bytes without '\0'
// printf reads past buffer[255] looking for '\0'
// Result: reads 100+ bytes of stack memory, info leak
}
void read_file_data(FILE *fp) {
char buffer[128];
// VULNERABLE - fread doesn't null-terminate
size_t bytes = fread(buffer, 1, sizeof(buffer), fp);
// VULNERABLE - treating binary data as string
printf("Data: %s\n", buffer);
// Result: reads past buffer end if file data lacks '\0'
}
Incorrect buffer allocation (missing +1)
#include <string.h>
#include <stdlib.h>
void process_input(const char *user_input) {
size_t len = strlen(user_input); // len = 10 for "HelloWorld"
// VULNERABLE - allocates exactly 10 bytes, NO room for '\0'
char *buffer = malloc(len);
if (buffer == NULL) return;
// VULNERABLE - strcpy writes 11 bytes (10 + '\0')
strcpy(buffer, user_input); // Writes past allocated buffer!
// Attack: heap corruption, possible code execution
free(buffer);
}
void concatenate_strings(const char *str1, const char *str2) {
// VULNERABLE - forgot +1 for null terminator
size_t total = strlen(str1) + strlen(str2);
char *result = malloc(total);
strcpy(result, str1);
strcat(result, str2); // Overwrites heap metadata
// Result: heap corruption, potential RCE
}
Overwriting null terminator
void fill_buffer_wrong(char *buffer, size_t size) {
// VULNERABLE - fills entire buffer, including last byte
for (size_t i = 0; i < size; i++) {
buffer[i] = 'A';
}
// Attack: buffer[size-1] should be '\0', but it's 'A'
// strlen(buffer) reads past buffer end
}
void modify_string(char *str) {
size_t len = strlen(str);
// VULNERABLE - loop overwrites null terminator
for (size_t i = 0; i <= len; i++) { // <= is wrong!
str[i] = toupper(str[i]);
}
// Attack: when i == len, overwrites '\0'
// str is no longer null-terminated
}
Secure Patterns
Explicit null termination after strncpy
#include <string.h>
#include <stdio.h>
void copy_name_safe(const char *input) {
char buffer[10];
// Copy at most buffer_size - 1 characters
strncpy(buffer, input, sizeof(buffer) - 1);
// ALWAYS explicitly null-terminate
buffer[sizeof(buffer) - 1] = '\0';
// Safe: buffer is guaranteed null-terminated
printf("Name: %s\n", buffer);
}
void copy_password_safe(const char *pwd) {
char stored_pwd[32];
// Reserve last byte for null terminator
strncpy(stored_pwd, pwd, sizeof(stored_pwd) - 1);
// Guarantee null termination
stored_pwd[sizeof(stored_pwd) - 1] = '\0';
// Safe for strcmp, strcpy, etc.
}
Why this works: Using sizeof(buffer) - 1 as the length parameter to strncpy() reserves the last byte for the null terminator. Explicitly setting buffer[sizeof(buffer) - 1] = '\0' guarantees the string is properly terminated, even if the source was longer than the buffer. This prevents strlen(), printf(), and other string functions from reading past the buffer boundary. The explicit null termination happens before any use of the buffer as a string, preventing all operations from accessing out-of-bounds memory.
Null-terminating network and file data
#include <sys/socket.h>
#include <stdio.h>
void read_from_network_safe(int socket) {
char buffer[256];
// Reserve space for null terminator: read size - 1 bytes
ssize_t bytes_read = recv(socket, buffer, sizeof(buffer) - 1, 0);
if (bytes_read > 0) {
// Null-terminate at the position after last byte read
buffer[bytes_read] = '\0';
// Safe: buffer is now a valid C string
printf("Received: %s\n", buffer);
} else if (bytes_read == 0) {
printf("Connection closed\n");
} else {
perror("recv failed");
}
}
void read_file_data_safe(FILE *fp) {
char buffer[128];
// Read up to size - 1 bytes
size_t bytes = fread(buffer, 1, sizeof(buffer) - 1, fp);
// Always null-terminate after the data
buffer[bytes] = '\0';
// Safe: can now use as string
printf("Data: %s\n", buffer);
}
Why this works: Network data from recv() and file data from fread() are never null-terminated automatically - they just fill the buffer with raw bytes. Reading sizeof(buffer) - 1 bytes reserves the last position for manual null termination. Setting buffer[bytes_read] = '\0' explicitly adds the null terminator at the exact position after the received data, making it safe to use with string functions. This prevents reading beyond the actual data into uninitialized memory.
Proper buffer allocation with +1
#include <string.h>
#include <stdlib.h>
void process_input_safe(const char *user_input) {
size_t len = strlen(user_input);
// CORRECT: allocate length + 1 for null terminator
char *buffer = malloc(len + 1);
if (buffer == NULL) {
fprintf(stderr, "Memory allocation failed\n");
return;
}
// Safe: buffer has exactly len + 1 bytes
strcpy(buffer, user_input); // Copies len + '\0' = len+1 bytes
// Alternative explicit approach:
memcpy(buffer, user_input, len);
buffer[len] = '\0';
process(buffer);
free(buffer);
}
void concatenate_strings_safe(const char *str1, const char *str2) {
size_t len1 = strlen(str1);
size_t len2 = strlen(str2);
// CORRECT: total length + 1 for null terminator
char *result = malloc(len1 + len2 + 1);
if (result == NULL) {
fprintf(stderr, "Memory allocation failed\n");
return;
}
// Safe concatenation
strcpy(result, str1);
strcat(result, str2);
process(result);
free(result);
}
Why this works: Allocating strlen(input) + 1 bytes accounts for both the string content and the required null terminator ('\0'). When strcpy() copies the string, it writes strlen(input) characters plus the null terminator, totaling strlen(input) + 1 bytes. Without the +1, the null terminator writes one byte past the allocated buffer, causing heap corruption. The explicit memcpy() + manual null termination alternative makes the +1 requirement more obvious and provides defense-in-depth.
Using snprintf (always null-terminates)
#include <stdio.h>
void format_message_safe(const char *username, int score) {
char buffer[50];
// snprintf ALWAYS null-terminates, even on truncation
int written = snprintf(buffer, sizeof(buffer),
"User: %s, Score: %d", username, score);
// Check if truncation occurred
if (written >= sizeof(buffer)) {
fprintf(stderr, "Warning: Output was truncated\n");
}
// Safe: buffer is guaranteed null-terminated
printf("%s\n", buffer);
}
void build_path_safe(const char *dir, const char *file) {
char path[256];
// snprintf handles sizing and null termination
snprintf(path, sizeof(path), "%s/%s", dir, file);
// Always safe to use path as string
open_file(path);
}
Why this works: snprintf() writes at most size - 1 characters and always adds a null terminator, making it impossible to create a non-null-terminated buffer. Even if the formatted output would exceed the buffer size, snprintf() truncates the output and still null-terminates at buffer[size-1]. It returns the total length that would have been written (excluding null terminator), allowing detection of truncation. Unlike sprintf(), there's no way to overflow the buffer or forget null termination.
Using C++ std::string (automatic null termination)
#include <string>
#include <iostream>
#include <vector>
class SecureStringHandler {
public:
void process_input(const std::string& input) {
// std::string automatically manages null termination
std::string buffer = input.substr(0, 9);
// No buffer overflow possible
buffer += " suffix";
// c_str() always returns null-terminated string
printf("Data: %s\n", buffer.c_str());
}
void read_from_network_cpp(int socket) {
std::vector<char> buffer(256);
ssize_t bytes_read = recv(socket, buffer.data(),
buffer.size() - 1, 0);
if (bytes_read > 0) {
// Convert to std::string with explicit length
std::string data(buffer.data(), bytes_read);
// std::string is now properly null-terminated
std::cout << "Received: " << data << std::endl;
}
}
void convert_c_string(const char *c_str) {
if (c_str == nullptr) return;
// Convert to std::string immediately
std::string safe_str(c_str);
// All operations are now memory-safe
safe_str.append(" more data");
std::cout << safe_str << std::endl;
}
};
Why this works: std::string automatically manages memory allocation and null termination internally - you can never create a non-null-terminated std::string. Operations like +=, append(), and substr() maintain null termination automatically. When interfacing with C APIs, .c_str() returns a pointer to a null-terminated character array. std::string eliminates manual buffer sizing calculations, preventing the +1 allocation error. Converting C strings to std::string immediately after receiving them provides memory safety for all subsequent operations.
Security Checklist
- All string allocations include +1 for null terminator
- All strncpy calls followed by explicit null termination
- All network/file reads null-terminated before use
- No use of strcpy, strcat, sprintf (replaced with safe versions)
- Migrated to std::string in C++ where possible
- All buffer operations validated before execution
- Tests cover: exact fit, overflow, empty string, network data