Skip to content

CWE-170: Improper Null Termination - C++

Overview

std::string manages its own length and always null-terminates internally, so most of CWE-170 disappears once code is fully converted to it - .c_str() is guaranteed to return a null-terminated buffer regardless of the string's content. The weakness reappears at the boundaries where C++ still touches raw buffers: a std::vector<char> or raw char* used to receive data from a C API such as recv() has no terminator guarantee at all, and older code that predates full std::string adoption may still build C-style buffers by hand.

Common Vulnerable Patterns

Receiving raw bytes into a buffer without terminating it

void read_network_cpp(int socket) {
    std::vector<char> buffer(256);
    ssize_t bytes_read = recv(socket, buffer.data(), buffer.size(), 0);
    // VULNERABLE - recv() fills raw bytes; buffer.data() is not null-terminated
    printf("Received: %s\n", buffer.data());
    // if 256 bytes were received, this reads past the vector's storage
}

Why this is vulnerable: std::vector<char>::data() returns a pointer to the underlying storage, with no null-termination guarantee - recv() writing exactly buffer.size() bytes leaves nothing reserved for a terminator, so passing buffer.data() to a C string function reads past the vector's actual storage.

Copying a std::string into a fixed-size C buffer

void build_legacy_header(const std::string &name) {
    char header[64];
    // VULNERABLE - strncpy's non-termination guarantee applies here too,
    // even though the source happens to be a std::string
    strncpy(header, name.c_str(), sizeof(header));
    legacy_c_api(header);
}

Why this is vulnerable: std::string::c_str() itself is always safely terminated, but copying it into a fixed C buffer with strncpy reintroduces the same non-termination behavior as plain C - if name is sizeof(header) bytes or longer, header ends up unterminated exactly as it would with a char* source.

Secure Patterns

Use std::string end-to-end and only touch C APIs at the boundary

#include <string>

void process_input(const std::string &input) {
    std::string buffer = input.substr(0, 9);   // safe truncation, always terminated
    buffer += " suffix";                        // still safely terminated

    // Convert to a C string only at the point of the actual C API call
    legacy_c_api(buffer.c_str());
}

Why this works: Every std::string operation (substr, +=, append) maintains the class's own internal termination guarantee - there is no operation that can produce an unterminated std::string. Calling .c_str() only at the point where a C API actually needs a raw pointer keeps the unsafe boundary as small and as late as possible, rather than manually managing a C buffer through the whole function.

Terminate raw buffers explicitly at the actual C API boundary

#include <vector>
#include <string>

std::string read_network_cpp(int socket) {
    std::vector<char> buffer(256);
    ssize_t bytes_read = recv(socket, buffer.data(), buffer.size(), 0);

    if (bytes_read <= 0) {
        return {};
    }
    // Construct a std::string with an explicit length - no reliance on a terminator
    // ever being present in the raw buffer at all
    return std::string(buffer.data(), static_cast<size_t>(bytes_read));
}

Why this works: Constructing std::string from a pointer and an explicit length (rather than from a presumed-terminated char*) never depends on the source buffer containing a terminator - the resulting std::string is correctly sized and safely terminated regardless of what recv() wrote, which sidesteps the raw buffer's null-termination question instead of trying to satisfy it.

Convert C strings to std::string immediately at the interop boundary

void handle_c_string(const char *c_str) {
    if (c_str == nullptr) return;
    // Convert once, immediately - every operation after this line is memory-safe
    std::string safe_str(c_str);
    safe_str.append(" more data");
}

Why this works: Converting a raw const char* to std::string at the moment it crosses into C++ code means only that single conversion needs to trust the C side's termination contract - every subsequent operation works against the safe std::string type instead of the original raw pointer.

Filling a Fixed-Size C Buffer for a Legacy API

#include <cstring>
#include <string>

// SECURE - when a C API insists on a fixed-size char[], apply the same
// explicit-termination discipline as plain C rather than trusting strncpy.
char dest[64];
const std::string source = get_value();

if (source.size() >= sizeof(dest)) {
    return error::value_too_long;   // decide, do not silently truncate
}
std::memcpy(dest, source.data(), source.size());
dest[source.size()] = '\0';        // terminate explicitly, always

Why this works: strncpy writes no terminator whenever the source is at least as long as its size argument - filling the buffer exactly is only the first of those cases - which is the defect this CWE describes. Copying an explicitly checked length and writing the terminator yourself removes the ambiguity, and rejecting an oversized value is a decision rather than a silent truncation that corrupts the data. The same reasoning, and the surrounding C idioms, are covered on the C page.

Testing

  • Test buffers filled by recv()/fread() with input that completely fills the raw buffer's capacity, then confirm the std::string constructed from it is correctly sized rather than reading past the buffer.
  • Build with AddressSanitizer and exercise every raw-buffer-to-C-API path with boundary-length input.
  • Grep for strncpy/sprintf/strcat calls in otherwise-modern C++ code as a signal that a legacy C buffer pattern - and its termination requirements - is still present.

Common Pitfalls

  • Assuming a modified buffer's null-termination is preserved automatically: since C++11, data() and c_str() both return a null-terminated view of the string's current content, but code that writes directly into that buffer through a non-const pointer, or code written against a pre-C++11 standard where data() carried no termination guarantee, can break that assumption - never rely on a terminator being present unless it was written through std::string's own API.
  • Treating a std::vector<char> like a std::string: std::vector has no null-termination concept at all - calling a C string function on vector.data() is exactly as unsafe as calling it on a raw C buffer, however modern the surrounding code is.
  • Holding onto a pointer from .c_str() past the string's next mutation: any non-const operation on the std::string (including one that triggers reallocation) can invalidate a previously obtained .c_str() pointer - a terminator that was valid at the call site isn't guaranteed to still be valid later.

Additional Resources