Arrays & Strings · intermediate · ~12 min
## 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.
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:
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.
char array) that you write into.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.
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'.)
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.)
strncpy is a trapDefinition. strncpy(dst, src, n) copies up to n bytes from src. It looks bounded, and it is — but it has two surprising behaviors.
src is shorter than n, it pads the rest of dst with '\0' bytes (wasteful for large n).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.)
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.
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.
Keep it simple:
Modern code makes this explicit by passing both the pointer and its capacity everywhere — for example, buf together with size_t cap.
#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;
}
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.
small = [hello]
rejected: input too long for small
big = [user=alice id=42]
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.src: n == 0, we memcpy one byte (the terminator) and succeed.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)
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.
strncpy to terminateWrong:
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.
snprintf's return valueWrong:
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.
sizeof on a pointer parameterWrong:
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.
-Wall -Wextra -Werror=format-security. This flags non-literal format strings and several unsafe-call patterns._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.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 ./prog) catches many of the same issues without recompiling, though it runs slower and is weaker on stack overflows than ASan.cap - 1 characters.snprintf return value.'\0'?sizeof of an array (good) or a pointer (bug)?Every string operation involves two sizes:
strlen(src) (which itself assumes src is terminated).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.
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.char buf[64]; contains garbage until written. Do not strcat onto it before placing a terminator at buf[0].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.(char *buf, size_t cap).#define LOG_LINE 256) and reuse them, so the size and the buffer cannot drift apart.argv, environment) as hostile and length-checked.Bounded string handling is mandatory anywhere external text enters a C program:
argv and environment-variable handling (your next lesson, Command-line arguments).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.
Beginner rules:
strcpy, strcat, sprintf, or gets in code you keep.snprintf/fgets and check their return values.Advanced habits:
safe_copy, safe_concat) and use it everywhere, so the safe path is the easy path.-Werror into CI so unsafe patterns fail the build, not production.Given a short C file containing strcpy, sprintf, gets, and one already-safe snprintf, list every unbounded call and name its bounded replacement.
strcpy, strcat, sprintf, gets.Replace a strcpy(dst, src) (with char dst[8];) with snprintf and prove truncation works.
"abcdef" (fits) and "abcdefghij" (does not). Print the result and the return value each time."abcdefghij" -> output dst="abcdefg", r=10 (so r >= 8 means truncated).snprintf return value, terminator reservation.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.
dst as a valid empty string.safe_join(buf, 16, "hi", "there") -> buf == "hi there", returns 0.snprintf(dst, cap, "%s %s", a, b) plus a truncation check is enough.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.
strlen (that is the bug you are guarding against). Scan at most n bytes.{'h','i','\0'}, n=3 -> 1; {'h','i','!'}, n=3 -> 0.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.
cap == 0 (write nothing, still return strlen(src)); never read or write out of bounds; one pass where possible.strlen(src), choose copy = min(len, cap - 1), memcpy, place terminator.strlcpy contract, size arithmetic with size_t, terminator guarantees.'\0'; nothing stores its length, so every operation must carry a size.N holds N-1 characters.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.sizeof on a pointer parameter, ignored snprintf returns, and treating un-terminated bytes as a string.-Werror) and you eliminate an entire class of CVEs from your code.