Skip to content

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 - 1 when copying to reserve space for null terminator
  • Explicitly set buffer[size-1] = '\0' after bounded string operations
  • Allocate strlen(str) + 1 for 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

Additional Resources