Skip to content

CWE-170: Improper Null Termination

Overview

Improper null termination happens when C-style string code fails to add, or wrongly relies on, a terminating null byte (\0) at the true end of the data. There are two usual causes: a bounded copy function that does not guarantee termination, used without an explicit terminator afterward; and data read from a network socket or file that is treated as a string without ever being terminated. MITRE lists C and C++ under Applicable Platforms, and that is where the weakness lives: their conventional string representation is a byte array that relies on the terminator to mark its own end. A string type that carries its own length cannot be improperly terminated, so the weakness does not carry over into managed languages, but it does reappear at their interop boundaries, wherever a managed runtime hands a buffer to a C API or fills one from a native read.

Relationship to Other CWEs

A finding that really is about a missing or misplaced terminator belongs here rather than against CWE-20 (Improper Input Validation): that page covers any input accepted without being checked, while this one is about the single byte at the end of a buffer that nothing wrote. A scanner reporting CWE-20 for it is following an older CWE view which files this page under CWE-20; MITRE's current parent is CWE-707 (Improper Neutralization), which has no page here.

The pages around it differ by whether the finding is the missing terminator itself or the thing that produced it:

  • CWE-170 (this page) - a buffer used as a C string does not end with a terminator inside its own bounds
  • CWE-193 (Off-by-one Error) - the arithmetic mistake that most often produces it, where a length is used to size or bound a buffer without the + 1 the terminator needs. MITRE records CWE-170 as something that can follow CWE-193, so a finding on either is worth checking against the other
  • CWE-477 (Use of Obsolete Function) - where the replacement chosen for a withdrawn function is what leaves the string unterminated. Swapping strcpy for strncpy and stopping there closes the obsolete-function finding and opens this one
  • CWE-676 (Use of Potentially Dangerous Function) - the same trap for functions the standard still ships. strncpy is bounded but not terminating, so it clears a dangerous-function scanner while producing exactly the defect this page describes

Risk

High: A missing or misplaced null terminator causes string functions (strlen, printf, strcpy) to keep reading past the buffer's actual end looking for a terminator that isn't there, or to write one where it corrupts adjacent memory. Consequences include out-of-bounds reads that leak adjacent memory contents and crashes; when the terminator write itself lands out of bounds, the result is memory corruption that can be a path to code execution.

Remediation Steps

Core Principle: Every code path that produces a value destined to be used as a C string must guarantee it ends with a null terminator inside the buffer's real bounds - never assume a copy, read, or allocation did this for you.

Trace the Data Path

  • Source: Where the string's bytes actually come from - a bounded copy (strncpy), a network recv(), a file fread(), or a manually filled buffer.
  • Sink: Where the buffer is later used as a string - strlen(), printf("%s", ...), strcmp(), or any function that scans for the terminator.
  • Data Flow / Missing Controls: Confirm the terminator is set explicitly, inside the buffer, after every source operation that doesn't guarantee it itself - most bounded C functions do not.

Always Terminate Explicitly After a Bounded Copy (Primary Defense)

  • Copy at most buffer_size - 1 bytes, reserving the last byte for the terminator, then set that last byte to \0 explicitly - do not assume the copy function did it.
  • Prefer a copy/format function that guarantees termination on every path, including truncation, over one that only terminates when the input happens to fit.

Terminate Data That Was Never a String to Begin With

  • Network and file reads return raw bytes with no terminator at all - always write the terminator yourself at the position immediately after the last byte actually read, using a buffer sized with room for it.
  • Never treat the untouched remainder of a fixed-size buffer as "probably zero" - only a byte your own code explicitly wrote is reliable.
  • Any buffer sized from a string's length must add space for the terminator (length + 1), and every copy into that buffer must respect the same accounting - fixing the allocation without checking the copy logic, or the reverse, leaves the mismatch in place.

Prefer a String Type That Manages This Automatically

  • Where the language and API surface allow it, use a string type that tracks its own length and guarantees termination internally, so the terminator can't be omitted, misplaced, or overwritten by a later operation.

Test with Edge Cases

  • Test with input exactly at the buffer's capacity, one byte over, and empty input.
  • Test network/file reads with data that fills the buffer completely (no bytes left for a terminator without truncating the read).
  • Build with a memory sanitizer and re-scan to confirm the finding is resolved.

Common Vulnerable Patterns

A bounded copy that does not write a terminator

// VULNERABLE - the bound is respected; the terminator is not guaranteed
copy_bounded(buffer, input, size_of(buffer))
// if input is >= size_of(buffer) bytes, buffer has no terminator at all
print_string(buffer)   // reads past the buffer looking for a '\0' never written

Why this is vulnerable: the copy succeeds. The bound was honoured, nothing was written outside buffer, and a reviewer checking this line for an overflow finds none - the defect is created here and paid for somewhere else, by the next piece of code that treats buffer as a string and reads until it finds a zero byte. Where that byte turns up is decided by whatever the memory after the buffer happens to hold.

The defect survives testing because a bounded copy of this kind writes the terminator whenever the source is shorter than the destination and omits it only when the source fills or exceeds the buffer. The buffer is therefore correctly terminated for every input except the long one, which is the input an attacker sends. Testing with realistic data confirms the code works, and switching an unbounded copy to a bounded one to close an overflow finding produces exactly this state unless a terminator is written explicitly afterwards.

Bytes read from a source that does not terminate them

// VULNERABLE - a read returns a count, not a string
bytes_read = read_from_socket(socket, buffer, size_of(buffer))
print_string(buffer)   // buffer holds bytes_read raw bytes, no terminator

Why this is vulnerable: the length arrives separately from the data. bytes_read is the only record of where the payload ends, and passing buffer on as a string throws it away - the receiving code then determines the end by searching the buffer's leftover contents. Nothing in the type or the variable name marks the difference between a byte array with a known length and a string that carries its own.

Two things hide it. A freshly allocated buffer often happens to contain zeroes, so early runs terminate by accident and the code looks correct until the buffer is reused with longer data in it. And a read of this kind returns a short count whenever less data has arrived than was asked for, which is normal rather than exceptional, so the length varies from call to call and the failure is intermittent from the first day.

Secure Patterns

// SECURE - explicit termination reserved and set after every bounded copy
copy_bounded(buffer, input, size_of(buffer) - 1)
buffer[size_of(buffer) - 1] = terminator
print_string(buffer)

// SECURE - terminator written explicitly at the position after the data actually read
bytes_read = read_from_socket(socket, buffer, size_of(buffer) - 1)
if bytes_read < 0:
    handle_error(); return        // nothing was written - do not use buffer at all
buffer[bytes_read] = terminator   // bytes_read == 0 terminates at offset 0: an empty string
print_string(buffer)

Why this works: Reserving the last byte of the destination (size - 1 as the copy/read length) guarantees there is always room left for the terminator, and writing it explicitly - rather than assuming the copy or read function did - means the buffer is a valid string on every path, including truncation.

The two halves get there differently, and the difference matters when picking a copy primitive. The read case writes the terminator at buffer[bytes_read], which is the actual end of the data, so nothing beyond it can be read as content. The copy case writes at a fixed offset, size - 1, which is a backstop against the copy not terminating at all - it is not the end of the data, and on a short input it sits some distance past it.

That backstop is only sufficient because a bounded copy of this kind terminates or pads on short input, which is what puts a terminator at the real end. Substitute a primitive that does neither, such as a raw n-byte memory copy, and the fixed-offset write leaves whatever the buffer previously held between the copied data and the final slot, so the string is terminated and still wrong. If the copy does not guarantee a terminator on short input, write it at the number of bytes actually copied, the way the read case does, rather than at the end of the buffer.

The zero-byte case is worth writing out rather than folding into a > 0 guard, because it is the one the fix usually misses. A closed connection or an empty file returns a count of zero, and a guard of if bytes_read > 0 then skips the terminator write entirely - leaving the buffer exactly as unterminated as it was before the fix, on the one input a reviewer is least likely to try. Terminating unconditionally on any non-negative count costs nothing and turns that case into an empty string.

Common Pitfalls

  • Enlarging the buffer without checking the copy logic: making a buffer bigger reduces how often the missing terminator causes visible corruption, but if the copy path itself never writes a terminator byte, a large-enough input still produces an unterminated string - the fix addressed the buffer's size, not the actual defect.
  • Switching to a "safer" bounded function without adding the terminator: replacing an unbounded copy with a bounded one stops the overflow but doesn't by itself guarantee termination - some bounded functions still leave the buffer unterminated when the source is longer than the destination, so the explicit terminator write is still required afterward.
  • Assuming a buffer is zero-initialized so termination "already happened": relying on a stack or heap buffer's prior contents, or on freshly allocated memory being zeroed, to supply the terminator is not guaranteed - only an explicit write is reliable, and the assumption fails silently until the buffer happens to contain non-zero leftover data.
  • Fixing the read but not the write side of the same value: null-terminating data on the way in while a separate code path later re-copies or re-slices that same buffer without preserving the terminator reintroduces the bug one step downstream.

Language-Specific Guidance

  • C - strncpy/recv/fread termination gaps, missing +1 allocations, and safe function alternatives (snprintf, strlcpy)
  • C++ - std::string/std::vector<char> boundary handling at C API interop points

Additional Resources