Arrays & Strings · intermediate · ~12 min

String safety and bounded operations

## What you will learn - Recognize the unbounded string functions (`strcpy`, `strcat`, `sprintf`, `gets`) and explain exactly why each one is dangerous. - Replace them with bounded equivalents (`snprintf`, `fgets`, and a hand-written `safe_copy`) that always take a destination capacity. - Apply the core rule: *bound every write by the destination size, never by the input length.* - Reserve room for the `'\0'` terminator in every buffer and verify the result is NUL-terminated. - Read the return value of `snprintf` to detect silent truncation instead of ignoring it. - Explain why `strncpy` is not the safe choice most beginners assume it is.

Overview

What "safe strings" means in C

A C string is just an array of char ending in a '\0' (NUL) byte. There is no length stored anywhere — the only way to know where a string ends is to scan for that terminator. This single design choice is the root of nearly every string bug in C.

Safe string handling comes down to two habits:

  • Always know the size of the destination buffer at every operation.
  • Never trust the length of the input.

C's traditional functions break the second rule. strcpy, strcat, sprintf, and gets take no size argument. They copy bytes until they hit the source's '\0' — even if that means writing far past the end of the destination. The result is a buffer overflow: a write that lands outside the memory you actually own.

This lesson builds directly on strcpy and its dangers, which showed you one specific unsafe function. Here we generalize that lesson into a repeatable discipline you can apply to every string operation, and we introduce the bounded replacements you should reach for instead. After this you will move on to Command-line arguments, where argv strings are a classic source of untrusted, attacker-controlled input — exactly the kind this lesson teaches you to handle.

Key terms

  • Buffer: a fixed region of memory (often a char array) that you write into.
  • Capacity: how many bytes the buffer can hold, including the terminator.
  • Bounded function: one that takes a size limit and refuses to write past it.
  • Truncation: when output is cut short because it did not fit. Bounded functions truncate; unbounded functions overflow.

Why it matters

The cost of getting this wrong

The buffer overflow is the single most common historical security flaw in C. A buffer overflow happens when a write runs past the end of an allocated buffer into memory that belongs to something else.

Consider one strcpy that copies an attacker-controlled string into a fixed-size local buffer. Local buffers live on the stack, right next to bookkeeping data — including the function's return address, the location the CPU jumps to when the function ends. If the input is long enough, the copy overwrites that return address. An attacker who controls the input can choose where execution jumps next, turning a simple copy into arbitrary code execution.

Modern compilers and operating systems add mitigations — stack canaries (a guard value checked before return), ASLR (randomized memory layout), and non-executable stacks. These make exploitation harder, but they are defense in depth, not a cure. A canary turns a silent compromise into a crash; the program still dies. The only real fix is to never write the unbounded call in the first place.

This is not historical trivia. Memory-safety bugs, dominated by buffer overflows, still account for a large share of the critical CVEs reported each year in C and C++ codebases. Learning the bounded pattern removes an entire category of those bugs from anything you write.

Core concepts

Concept 1: The two families of string functions

Definition. C's string functions split into an unbounded family that trusts the input's length, and a bounded family that takes an explicit size limit.

Unbounded (avoid) Bounded replacement What the limit protects
strcpy(dst, src) snprintf(dst, cap, "%s", src) dst capacity
strcat(dst, src) snprintf into remaining room dst capacity
sprintf(dst, fmt, ...) snprintf(dst, cap, fmt, ...) dst capacity
gets(buf) fgets(buf, cap, stdin) buf capacity

How it works internally. An unbounded copy is essentially while ((*dst++ = *src++)) ; — it keeps going until it reads a '\0' from the source, with no idea how big dst is. A bounded copy also tracks a counter and stops at the limit, then writes a terminator. The size you pass is the contract: "do not write more than this many bytes here."

When to use / when not to. Use bounded functions for all string output, especially anything touching external input (files, network, argv, environment variables). The only time an unbounded copy is arguably fine is copying a string literal of known length into a buffer you sized for it — and even then, snprintf costs nothing extra and survives later edits.

Pitfall. Beginners assume "the input is usually short, so a fixed buffer is fine." Attackers do not send usual input. Size for the worst case or reject oversized input explicitly.

Unbounded copy of a too-long source:

  dst[8]:  [ . . . . . . . . ]  return addr  saved regs ...
  src:     "AAAAAAAAAAAAAAAAAAAA\0"
           |------ fits ------|->>>> keeps writing >>>>
                                  ^ corrupts memory past dst

Knowledge check. In char buf[8];, how many visible characters can the string hold if you still want a valid C string? (Answer: 7 — one byte is reserved for '\0'.)

Concept 2: Always reserve room for the terminator

Definition. Every C string needs one extra byte for the '\0'. A buffer of capacity N holds at most N-1 characters plus the terminator.

Why it matters. A buffer that is full of characters with no terminator is not a string — any function that scans for '\0' (like strlen or printf("%s")) will run off the end reading random memory until it happens to find a zero byte. That is an out-of-bounds read, which can crash or leak data.

The well-behaved bounded functions (snprintf, fgets) always include the terminator inside the size you give them. If you pass sizeof buf, you are safe. The misbehaving one is strncpy (next concept).

Pitfall. Passing sizeof buf - 1 to snprintf "to leave room" is a mistake — snprintf already reserves that room. You would be wasting one byte and possibly truncating earlier than necessary.

Knowledge check (predict the output). What does this print?

char buf[4];
int r = snprintf(buf, sizeof buf, "%s", "hello");
printf("[%s] r=%d\n", buf, r);

(Answer: [hel] r=5. Only 3 chars fit plus the terminator; r is the length it wanted to write, which is how you detect truncation: r >= sizeof buf.)

Concept 3: Why strncpy is a trap

Definition. strncpy(dst, src, n) copies up to n bytes from src. It looks bounded, and it is — but it has two surprising behaviors.

  1. If src is shorter than n, it pads the rest of dst with '\0' bytes (wasteful for large n).
  2. If src is exactly n bytes or longer, it copies n bytes and writes no terminator at all. dst is left un-terminated.

How it works internally. strncpy was designed in the 1970s for fixed-width fields in early Unix directory entries, not for safe string copying. The "no terminator when full" behavior is faithful to that original purpose and is a footgun today.

When to use / when not to. Avoid strncpy for general string copying. Prefer snprintf(dst, cap, "%s", src), which always terminates, or platform strlcpy(dst, src, cap) where available (BSD, macOS, and glibc since 2.38), which copies and always terminates and tells you the source length so you can detect truncation.

strncpy(dst, "ABCD", 4) into char dst[4]:

  dst: [ A | B | C | D ]   <-- NO '\0'  -> not a valid C string!
        strlen(dst) reads past the end -> undefined behavior

Knowledge check (find the bug). A reviewer sees strncpy(name, input, sizeof name); and approves it as "bounded." What can still go wrong? (Answer: if input is >= sizeof name, name has no terminator; later printf("%s", name) reads out of bounds.)

Syntax notes

Syntax: the bounded patterns

char dst[64];

/* snprintf: the workhorse. Always terminates; returns length it WANTED. */
int r = snprintf(dst, sizeof dst, "User: %s", username);
if (r < 0 || (size_t)r >= sizeof dst) {
    /* r < 0  -> encoding error
       r >= cap -> output was truncated; handle it, don't ignore it */
}

/* fgets: bounded line input. Replaces the never-safe gets(). */
char line[128];
if (fgets(line, sizeof line, stdin)) {
    /* line is NUL-terminated; may include a trailing '\n' */
}

/* strlcpy: copy + always terminate (BSD/macOS, glibc >= 2.38). */
/* size_t need = strlcpy(dst, src, sizeof dst);  // truncated if need >= sizeof dst */

/* AVOID: strcpy(dst, src);  strcat(dst, x);  sprintf(dst, ...);  gets(dst); */

Note the idiom sizeof dst: it asks the compiler for the array's real size, so the size and the buffer can never drift apart. This only works when dst is an array in scope — if dst is a pointer parameter, sizeof dst is the size of a pointer (usually 8), not the buffer, so pass the capacity in explicitly.

Lesson

Two families of functions

The unsafe family: strcpy, strcat, sprintf, gets.

These write into the destination assuming there is room. They never check.

The bounded family: strncpy, strncat, snprintf, fgets.

These take a size limit — but each one has its own quirk, so you still need to know how it behaves.

The mental model

Keep it simple:

  • Never write into a buffer without knowing its size.
  • Never copy without checking the bounds.

Modern code makes this explicit by passing both the pointer and its capacity everywhere — for example, buf together with size_t cap.

Code examples

#include <stdio.h>
#include <string.h>

/*
 * safe_copy: copy src into dst without ever overflowing.
 * Returns 0 on success, -1 if src would not fit (dst is left empty but valid).
 * dst_sz is the FULL capacity of dst, including room for the terminator.
 */
int safe_copy(char *dst, size_t dst_sz, const char *src) {
    if (dst == NULL || src == NULL || dst_sz == 0)
        return -1;                       /* no room even for the NUL */
    size_t n = strlen(src);              /* length of src, excluding its '\0' */
    if (n >= dst_sz) {                   /* would not fit with terminator */
        dst[0] = '\0';                   /* leave a valid empty string */
        return -1;                       /* fail safely, do not truncate-and-pretend */
    }
    memcpy(dst, src, n + 1);             /* copy src plus its '\0' */
    return 0;
}

int main(void) {
    char small[8];
    char big[64];

    if (safe_copy(small, sizeof small, "hello") == 0)
        printf("small = [%s]\n", small);

    /* This source is 20 bytes and cannot fit in an 8-byte buffer. */
    if (safe_copy(small, sizeof small, "this is way too long") != 0)
        printf("rejected: input too long for small\n");

    /* snprintf composes safely and reports truncation via its return value. */
    int r = snprintf(big, sizeof big, "user=%s id=%d", "alice", 42);
    if (r < 0 || (size_t)r >= sizeof big)
        printf("warning: output truncated\n");
    else
        printf("big = [%s]\n", big);

    return 0;
}

What it does

safe_copy is a minimal, correct, always-terminating copy. It checks for NULL, computes the source length once, refuses to copy if the result (characters + terminator) would not fit, and otherwise copies the bytes plus the terminator with a single memcpy. main then shows three cases: a copy that fits, a copy that is correctly rejected, and snprintf formatting a line while we check for truncation.

Expected output

small = [hello]
rejected: input too long for small
big = [user=alice id=42]

Edge cases

  • dst_sz == 0: nothing can be written safely, including the terminator, so we return -1 immediately.
  • src exactly dst_sz - 1 long: fits, because n (= dst_sz - 1) is < dst_sz.
  • Empty src: n == 0, we memcpy one byte (the terminator) and succeed.

Line by line

Walkthrough of safe_copy(small, 8, "hello")

Step Code What happens
1 dst==NULL ... small is valid, src valid, dst_sz is 8 (not 0) — checks pass.
2 n = strlen(src) "hello" has 5 characters, so n = 5.
3 n >= dst_sz 5 >= 8 is false, so we skip the rejection branch.
4 memcpy(dst, src, n + 1) Copies 6 bytes: h e l l o \0.
5 return 0 Success. small now holds the valid string "hello".

Memory after the copy:

small: [ h | e | l | l | o | \0 | ? | ? ]
         0   1   2   3   4   5    6   7
         ^---- the string ----^   ^ untouched bytes (still in bounds)

Walkthrough of safe_copy(small, 8, "this is way too long")

Step Code What happens
1 guards pass.
2 n = strlen(src) The source is 20 characters, so n = 20.
3 n >= dst_sz 20 >= 8 is true.
4 dst[0] = '\0' We write a single terminator so small is a valid empty string, not garbage.
5 return -1 Caller sees the failure and prints "rejected". No byte was written out of bounds.

The key contrast: strcpy(small, "this is way too long") would have written 21 bytes into an 8-byte buffer, smashing 13 bytes of adjacent memory. safe_copy writes one byte and reports failure.

Common mistakes

Mistake 1: Trusting strncpy to terminate

Wrong:

char name[8];
strncpy(name, input, sizeof name);   /* looks bounded... */
printf("%s\n", name);                 /* ...but may read out of bounds */

Why it is wrong. If input is 8 bytes or longer, strncpy fills all 8 bytes and writes no terminator. printf("%s") then scans past the end of name until it stumbles on a zero byte.

Fixed:

char name[8];
snprintf(name, sizeof name, "%s", input);   /* always terminates */

Recognize it: any strncpy whose third argument equals the destination size and whose source can be attacker-controlled is suspect.

Mistake 2: Ignoring snprintf's return value

Wrong:

char path[16];
snprintf(path, sizeof path, "/home/%s/data", user);
open_file(path);   /* silently uses a truncated, wrong path */

Why it is wrong. If user is long, path is truncated to something like "/home/longna" and you operate on the wrong file with no warning.

Fixed:

int r = snprintf(path, sizeof path, "/home/%s/data", user);
if (r < 0 || (size_t)r >= sizeof path)
    return error("path too long");

Recognize it: an snprintf whose return value is discarded, where truncation would change program meaning.

Mistake 3: Using sizeof on a pointer parameter

Wrong:

void log_line(char *buf, const char *msg) {
    snprintf(buf, sizeof buf, "%s", msg);   /* sizeof buf == 8, not the buffer size! */
}

Why it is wrong. Inside the function, buf is a pointer; sizeof buf is the pointer's size, not the array's. You will almost always truncate to 7 characters.

Fixed:

void log_line(char *buf, size_t cap, const char *msg) {
    snprintf(buf, cap, "%s", msg);   /* capacity passed in explicitly */
}

Recognize it: any sizeof applied to a function parameter of pointer type.

Debugging tips

Compiler errors and warnings

  • Build with -Wall -Wextra -Werror=format-security. This flags non-literal format strings and several unsafe-call patterns.
  • Many compilers and the _FORTIFY_SOURCE macro warn outright when they can prove strcpy/sprintf overflow a known-size buffer. Treat such warnings as errors.
  • gets is removed from C11; trying to use it is a compile error on modern toolchains. That is intentional — use fgets.

Runtime errors

  • Compile with AddressSanitizer: cc -fsanitize=address -g prog.c. An overflow then aborts with a precise report (which buffer, which byte, which line) instead of a vague crash.
  • valgrind (valgrind ./prog) catches many of the same issues without recompiling, though it runs slower and is weaker on stack overflows than ASan.

Logic errors

  • Off-by-one: confirm capacity includes the terminator. The string holds cap - 1 characters.
  • Silent truncation: if output is mysteriously short, check whether you ignored an snprintf return value.

Questions to ask when it does not work

  1. What is the capacity of this destination, and does it include room for '\0'?
  2. Where does this input come from, and what is its maximum length?
  3. Is every result NUL-terminated? Did I check the bounded function's return value?
  4. Am I taking sizeof of an array (good) or a pointer (bug)?

Memory safety

Memory-safety concerns for string operations

Every string operation involves two sizes:

  • The source length, discovered with strlen(src) (which itself assumes src is terminated).
  • The destination capacity, known from sizeof dst (when dst is an array in scope).

The destination capacity is the one you can trust — it is fixed and known at compile time. The source length is whatever an attacker hands you. Always bound the operation by the destination size.

Specific hazards

  • Out-of-bounds write (overflow): the classic. Caused by unbounded copies. Prevent by bounding every write.
  • Out-of-bounds read: caused by calling strlen/printf("%s") on a buffer with no terminator (the strncpy trap, or raw bytes from a file/socket). Before treating a byte buffer as a string, verify a '\0' exists within the known length — exactly what the related exercise "Has a terminator in range?" builds.
  • Uninitialized reads: a stack char buf[64]; contains garbage until written. Do not strcat onto it before placing a terminator at buf[0].
  • Integer/size mistakes: size_t is unsigned, so dst_sz - 1 when dst_sz == 0 wraps to a huge number. Guard dst_sz == 0 first (as safe_copy does) before any subtraction.

Defensive habits

  • Pass capacity alongside every buffer pointer across function boundaries: (char *buf, size_t cap).
  • Define buffer sizes with named macros (#define LOG_LINE 256) and reuse them, so the size and the buffer cannot drift apart.
  • Treat every byte that crosses a trust boundary (file, network, argv, environment) as hostile and length-checked.

Real-world uses

Where this shows up in real software

Bounded string handling is mandatory anywhere external text enters a C program:

  • Network servers and parsers — HTTP header parsing, URL handling, protocol decoders.
  • Command-line toolsargv and environment-variable handling (your next lesson, Command-line arguments).
  • Config and log handling — reading config files, formatting log lines.
  • Embedded and firmware — routers, IoT devices, where a single overflow can be a remote root.

Real, exploited bugs of this exact class include OpenSSH (CVE-2002-0083) and wu-ftpd (CVE-1999-0368), plus a long tail of router-firmware overflows that are still reported today.

Professional best-practice habits

Beginner rules:

  • Never use strcpy, strcat, sprintf, or gets in code you keep.
  • Use snprintf/fgets and check their return values.
  • Size buffers for the worst case, and always count the terminator.

Advanced habits:

  • Adopt or write a small bounded-string utility layer (safe_copy, safe_concat) and use it everywhere, so the safe path is the easy path.
  • Wire AddressSanitizer and -Werror into CI so unsafe patterns fail the build, not production.
  • Document each public function's buffer contract: who owns the buffer, what capacity is expected, what happens on truncation.
  • Prefer rejecting oversized input over silently truncating it when truncation could change meaning (paths, security tokens, identifiers).

Practice tasks

Practice

Beginner 1 — Spot the unsafe calls

Given a short C file containing strcpy, sprintf, gets, and one already-safe snprintf, list every unbounded call and name its bounded replacement.

  • Requirements: identify each call, state what input could overflow it, and give the replacement signature.
  • Hint: the unbounded family is strcpy, strcat, sprintf, gets.
  • Concepts: the two function families.

Beginner 2 — Replace and verify truncation

Replace a strcpy(dst, src) (with char dst[8];) with snprintf and prove truncation works.

  • Requirements: copy "abcdef" (fits) and "abcdefghij" (does not). Print the result and the return value each time.
  • I/O example: input "abcdefghij" -> output dst="abcdefg", r=10 (so r >= 8 means truncated).
  • Concepts: snprintf return value, terminator reservation.

Intermediate 1 — Bounded join

Write int safe_join(char *dst, size_t cap, const char *a, const char *b) that builds a + " " + b (a space between) without overflowing. Return 0 on success, -1 if it would not fit.

  • Requirements: always NUL-terminate; on failure leave dst as a valid empty string.
  • I/O example: safe_join(buf, 16, "hi", "there") -> buf == "hi there", returns 0.
  • Hint: snprintf(dst, cap, "%s %s", a, b) plus a truncation check is enough.
  • Concepts: bounded composition, truncation detection.

Intermediate 2 — Validate before treating bytes as a string

Write int is_c_string(const char *buf, size_t n) that returns 1 only if a '\0' appears within the first n bytes of buf, else 0.

  • Requirements: do not call strlen (that is the bug you are guarding against). Scan at most n bytes.
  • I/O example: {'h','i','\0'}, n=3 -> 1; {'h','i','!'}, n=3 -> 0.
  • Concepts: out-of-bounds-read prevention, terminator checking.

Challenge — A truncation-aware copy that reports need

Write size_t safe_copy2(char *dst, size_t cap, const char *src) that copies as much of src as fits, always NUL-terminates (when cap > 0), and returns the total length of src (like strlcpy). Then the caller can detect truncation with result >= cap.

  • Requirements: handle cap == 0 (write nothing, still return strlen(src)); never read or write out of bounds; one pass where possible.
  • Hint: compute strlen(src), choose copy = min(len, cap - 1), memcpy, place terminator.
  • Concepts: the strlcpy contract, size arithmetic with size_t, terminator guarantees.

Summary

Key takeaways

  • A C string is bytes ending in '\0'; nothing stores its length, so every operation must carry a size.
  • Bound every write by the destination capacity, never by the input length. Always count the terminator: a buffer of size N holds N-1 characters.
  • Replace the unbounded family — strcpy, strcat, sprintf, gets — with snprintf and fgets, and check their return values to catch silent truncation.
  • strncpy is not the safe choice: it fails to terminate when the source fills the buffer. Prefer snprintf or platform strlcpy.
  • Watch for the common traps: sizeof on a pointer parameter, ignored snprintf returns, and treating un-terminated bytes as a string.
  • Build the bounded pattern into a habit (and into CI with AddressSanitizer and -Werror) and you eliminate an entire class of CVEs from your code.

Practice with these exercises