Secure Coding in C · intermediate · ~10 min
- Explain *why* `strcpy`, `strcat`, `sprintf`, `gets`, and `strtok` are dangerous, in terms of the destination buffer's size. - Choose the correct size-aware replacement (`snprintf`, `fgets`, `strtok_r`, and `strlcpy`/`strlcat` where available) for each unsafe function. - Read `snprintf`'s return value correctly to detect **truncation** instead of silently losing data. - Build a safe append (`strcat`-style) that respects both the existing string length and the buffer size. - Recognise the portability limits of `strlcpy`/`strlcat` and know when a hand-written helper is the safer choice. - Reason about NUL termination as an invariant your code must actively maintain, not something the library guarantees.
A C string is not really a type — it is a convention: a run of char bytes ending in a '\0' (NUL) terminator. Every string function walks bytes until it finds that terminator. The oldest string functions (strcpy, strcat, sprintf, gets) share one fatal flaw: they never know how big your destination buffer is. They copy until the source ends, and if the source is longer than the space you set aside, they keep writing past the end of your array — corrupting whatever memory sits after it.
This lesson is the practical follow-up to Bounds checking everywhere. There you learned the principle: never touch memory outside the region you own. Here you learn the everyday tools that make that principle automatic for strings. Instead of manually checking a length before every copy, you reach for functions that take the size limit as an argument and refuse to cross it.
In plain language: an unsafe function is like pouring water from a jug without looking at the glass — you stop only when the jug is empty, so a big jug overflows a small glass. A safe function is a glass with a fill line printed on it: you can never pour past the line, no matter how full the jug is. The terminology for what happens when you ignore the fill line is a buffer overflow, and it is one of the most exploited bugs in the history of software.
Buffer overflows are not a museum piece. A single unchecked strcpy into a fixed stack array is enough to overwrite the function's return address and, in the classic attack, hand control of the program to an attacker's bytes. Real, catastrophic examples — the Morris worm (1988), Code Red, SQL Slammer, and Heartbleed's cousin bugs — all trace back to code that trusted input length instead of buffer length.
Even when an overflow isn't exploitable, it is still a bug: it corrupts adjacent variables, causes crashes that reproduce only on certain inputs, and produces "impossible" behaviour that costs hours to debug. Worse, the classic functions fail silently on the happy path — your test strings fit, everything looks fine, and the overflow only appears with the one long username a user eventually types.
Using size-aware functions by default turns a whole class of security holes into a non-event. It is one of the cheapest, highest-leverage habits in C: the safe version is usually the same number of lines, and it fails loudly and safely (truncation you can detect) instead of quietly and dangerously (memory corruption you can't).
Definition. A buffer is a fixed region of memory (an array) you reserve to hold data. Its size is the number of bytes it can legally hold — decided when you declare it, e.g. char name[16]; reserves 16 bytes.
The root cause of every unsafe-string bug is that the classic functions receive a pointer to the buffer but never the size. A pointer says "start here"; it does not say "and stop after 16 bytes." The size lives in your head (or your array declaration), not in the pointer, so the function cannot honour it.
char dst[8]; // 8 bytes reserved
src = "HELLO WORLD" // 11 chars + NUL = 12 bytes needed
strcpy(dst, src); // writes 12 bytes into 8 bytes of space
dst -> [ H E L L O W ][ O R L D \0 ] <-- 4 bytes past the end!
|<---- dst[0..7] --->||<-- OTHER MEMORY -->|
(return address,
other locals...)
Everything after the box is memory you do not own. Overwriting it is undefined behaviour.
Knowledge check. A colleague says "
strcpyis fine here because I checked that the source is short." Under what change to the program could that reasoning silently become false? (Hint: who controls the source string's length at run time?)
snprintf — the size-aware Swiss army knifeDefinition. int snprintf(char *dst, size_t size, const char *fmt, ...) formats text into dst, writing at most size bytes total, always including a terminating '\0' (as long as size > 0).
How it works internally. snprintf formats the full result conceptually, but only stores up to size - 1 characters, then writes a '\0'. Crucially, it returns the number of characters it would have written if the buffer were unlimited (not counting the NUL). So:
ret < size → everything fit; the string is complete.ret >= size → the output was truncated; ret tells you how big the buffer needed to be.This return value is the whole game. Ignoring it turns snprintf into a silent truncator.
When to use it. As your default replacement for strcpy, strcat, and sprintf. It composes format strings, so it also replaces manual concatenation.
When not to. For copying raw binary data (no NUL semantics) — use memcpy with an explicit length. For extremely hot loops copying known-length strings, a checked memcpy can be faster.
Common pitfall. Treating the return value as "bytes written." It is "bytes that would be written." If you do pos += snprintf(buf + pos, size - pos, ...) in a loop without clamping, pos can run past size on truncation and your next call gets a huge (wrapped) size argument.
fgets vs gets — never read a line blindlygets(buf) reads a line from standard input into buf with no size limit at all. There is no safe way to use it; it was removed from the C11 standard. Any input line longer than the buffer overflows it.
fgets(buf, size, stdin) reads at most size - 1 characters, stops at a newline or end-of-file, and always NUL-terminates. It also keeps the '\n' if the line fit, which is a handy signal: no newline in the buffer means the line was longer than size - 1 and the rest is still waiting.
| Function | Takes a size? | NUL-terminates? | Keeps newline? | Verdict |
|---|---|---|---|---|
gets |
No | Yes* | No | Removed — never use |
fgets |
Yes | Yes | Yes (if it fit) | Safe default |
Knowledge check. After
fgets(line, sizeof line, stdin)succeeds, how can you tell whether the user typed a line longer than your buffer could hold?
strtok vs strtok_rDefinition. A function is re-entrant when it keeps no hidden internal (static) state, so overlapping or concurrent calls don't interfere.
strtok tokenises a string but remembers its position in a hidden static variable. That breaks the moment two tokenisation loops interleave — nested loops, or two threads — because they clobber each other's saved position. strtok_r (the _r is for re-entrant) takes an extra char **saveptr argument so you own the state.
strtok: [caller A] --\ /-- both write the SAME
> hidden static < hidden pointer =>
[caller B] --/ \-- corruption
strtok_r: [caller A] -> saveptr_A (independent)
[caller B] -> saveptr_B (independent)
Knowledge check. Explain in your own words why
strtokcan produce wrong results even in a single-threaded program.
strlcpy / strlcat — safe, but not portablestrlcpy(dst, src, size) and strlcat(dst, src, size) come from BSD. They always NUL-terminate and return the length the result would have had (so >= size means truncation — same detect-truncation trick as snprintf). They are cleaner than snprintf("%s") for plain copies.
The catch: they are not part of standard C. glibc historically did not ship them (newer versions do), so code that assumes them may not compile everywhere. When you need portability, either wrap them behind your own helper or use snprintf.
| Task | Unsafe | Portable-safe | BSD-safe |
|---|---|---|---|
| Copy | strcpy(d, s) |
snprintf(d, sz, "%s", s) |
strlcpy(d, s, sz) |
| Append | strcat(d, s) |
snprintf(d+len, sz-len, "%s", s) |
strlcat(d, s, sz) |
| Format | sprintf(d, ...) |
snprintf(d, sz, ...) |
— |
| Read line | gets(d) |
fgets(d, sz, stdin) |
— |
| Tokenise | strtok(...) |
strtok_r(...) |
— |
Common pitfall with append. snprintf(dst + len, sz - len, ...) is only safe if len <= sz. If len already reached or exceeded sz (from a previous truncation), sz - len underflows to a huge size_t and you're back to an overflow. Always clamp.
The signatures you will use most, with the size argument highlighted in each comment:
#include <stdio.h> /* snprintf, fgets */
#include <string.h> /* strtok_r, (strlcpy/strlcat on BSD) */
/* Formats into dst; writes at most `size` bytes incl. the NUL.
* Returns the length it WOULD have written (excluding NUL). */
int snprintf(char *dst, size_t size, const char *fmt, ...);
/* Reads at most size-1 chars from stream, then NUL-terminates.
* Returns dst on success, NULL at end-of-file/error. */
char *fgets(char *dst, int size, FILE *stream);
/* Re-entrant tokeniser: YOU keep the state in *saveptr. */
char *strtok_r(char *str, const char *delims, char **saveptr);
The reusable pattern for detecting truncation — memorise this shape:
int n = snprintf(buf, sizeof buf, "%s", src);
if (n < 0 || (size_t)n >= sizeof buf) {
/* n < 0 -> encoding error; n >= size -> truncated */
return -1; /* handle it; do NOT pretend it succeeded */
}
The classic C string functions have no idea how big your destination buffer is. They keep writing until the source runs out, even past the end of your buffer. The safer versions below take a size limit or otherwise prevent that overflow.
| Unsafe | Safer drop-in |
|---|---|
strcpy |
snprintf(dst, sz, "%s", src) (or check length first) |
strcat |
snprintf(dst+len, sz-len, "%s", more) |
sprintf |
snprintf and check the return value |
gets |
fgets(buf, sz, stdin) |
strtok |
strtok_r (re-entrant) |
A function is re-entrant when it keeps no hidden internal state, so it stays correct even when called from multiple threads or in nested loops. strtok keeps such state; strtok_r does not.
Some platforms also offer strlcpy and strlcat. These come from BSD and are not part of standard C, so they may be missing on other systems. When available, they always NUL-terminate the result and report the length that would have been needed.
#include <stdio.h> #include <string.h>
/* Size-aware copy: the safe stand-in for strcpy.
/* Size-aware append: the safe stand-in for strcat.
int main(void) { char buf[16];
/* 1. A copy that fits. */
if (safe_copy(buf, sizeof buf, "Hello") == 0)
printf("copy ok: \"%s\"\n", buf);
/* 2. An append that still fits. */
if (safe_cat(buf, sizeof buf, ", C!") == 0)
printf("append ok: \"%s\"\n", buf);
/* 3. A copy that is deliberately too long: detected, not crashed. */
if (safe_copy(buf, sizeof buf, "this string is definitely too long") != 0)
printf("copy truncated safely; buf = \"%s\"\n", buf);
/* 4. Re-entrant tokenising of a CSV line. */
char line[] = "root:x:0:0:admin";
char *save = NULL;
for (char *tok = strtok_r(line, ":", &save);
tok != NULL;
tok = strtok_r(NULL, ":", &save)) {
printf("field: %s\n", tok);
}
return 0;
}
safe_copy. First it rejects a zero-capacity buffer — snprintf with size == 0 writes nothing and can't place a terminator, so there is no valid string to return. Then snprintf(dst, dstsz, "%s", src) copies at most dstsz - 1 bytes of src and always appends '\0'. The check (size_t)n >= dstsz compares what it wanted to write against the capacity: if src was longer, n is the untruncated length, which is >= dstsz, and we report -1. The cast to size_t matters — comparing a signed int against an unsigned size can misbehave if n were negative, so we test n < 0 first.
safe_cat. strnlen(dst, dstsz) measures the current contents but never reads past dstsz, so even a malformed (unterminated) buffer can't send it off the end. If len already equals dstsz, the string isn't terminated within the buffer and there's no safe place to append — bail out. Otherwise we write starting at dst + len with the remaining capacity dstsz - len. Because we proved len < dstsz, that subtraction can't underflow.
Walking through main with buf[16]:
| Step | Call | buf after |
Bytes used / 16 | Result |
|---|---|---|---|---|
| 1 | safe_copy("Hello") |
Hello\0 |
6 | 0 (ok) |
| 2 | safe_cat(", C!") |
Hello, C!\0 |
10 | 0 (ok) |
| 3 | safe_copy("this string is definitely too long") |
this string is\0 (15 chars + NUL) |
16 | -1 (truncated) |
| 4 | strtok_r loop |
— | — | prints fields |
Expected output:
copy ok: "Hello"
append ok: "Hello, C!"
copy truncated safely; buf = "this string is "
field: root
field: x
field: 0
field: 0
field: admin
At step 3 the program does not crash: snprintf filled 15 characters plus the NUL, returned the full untruncated length (34), we saw 34 >= 16, and reported failure. The buffer is still a valid, terminated string.
The strtok_r loop. The first call passes line; every later call passes NULL to mean "continue where you left off," with the position stored in save — which we own, so a second tokeniser elsewhere couldn't disturb it. strtok_r overwrites each delimiter in line with '\0' and returns pointers into line, which is why line must be a modifiable array, not a string literal.
Mistake 1 — Ignoring snprintf's return value.
/* WRONG: assumes it fit */
snprintf(path, sizeof path, "%s/%s", dir, file);
open(path, O_RDONLY); /* silently opens a truncated, wrong path */
If dir+file were too long, path is truncated and you may open the wrong file — or a security-sensitive different one. Fix: check the return.
int n = snprintf(path, sizeof path, "%s/%s", dir, file);
if (n < 0 || (size_t)n >= sizeof path) return -1; /* handle truncation */
Recognise it: any snprintf/snprintf-family call whose result feeds a filesystem, network, or SQL operation without a length check.
Mistake 2 — sizeof on a pointer, not an array.
void f(char *dst) { /* dst is a POINTER here */
snprintf(dst, sizeof dst, "%s", src); /* WRONG: sizeof dst == 8 (or 4) */
}
Inside a function, an array parameter decays to a pointer, so sizeof dst is the pointer size, not the buffer size. Fix: pass the size explicitly: void f(char *dst, size_t dstsz).
Mistake 3 — Manual append that forgets the existing length.
/* WRONG: overwrites from the start, or uses full size again */
snprintf(dst, sizeof dst, "%s", more); /* clobbers what was there */
Fix: append at dst + strlen(dst) with the remaining capacity, and guard against len >= size before subtracting (see safe_cat).
Mistake 4 — Trusting strncpy to terminate.
char dst[8];
strncpy(dst, src, sizeof dst); /* WRONG: may NOT NUL-terminate */
printf("%s", dst); /* reads past the end */
strncpy is a common "safe" mirage: if src is at least size bytes it copies exactly size bytes and writes no terminator, leaving an unterminated buffer. Prefer snprintf; if you must use strncpy, force dst[size-1] = '\0'; afterwards.
Compiler warnings first. Modern GCC/Clang warn on many of these bugs. Build with -Wall -Wextra -Wformat -Werror. GCC's -Wformat-truncation and -Wstringop-overflow catch snprintf truncation and out-of-bounds copies at compile time. If the compiler flags a format-truncation, don't silence it — check the return instead.
Runtime sanitizers. Compile with -fsanitize=address,undefined -g. AddressSanitizer catches the actual out-of-bounds write from an strcpy/strcat overflow and points at the exact line and the buffer it overran. This is the fastest way to prove a suspected overflow.
Common symptoms and what they mean:
| Symptom | Likely cause |
|---|---|
| Crash only on long input | Overflow past a fixed buffer |
| A nearby variable changes "by itself" | Copy spilled into the adjacent local |
| Garbage printed after your string | Missing NUL terminator (strncpy trap) |
strtok returns wrong tokens in nested loop |
Non-re-entrant hidden state — use strtok_r |
snprintf output cut off |
Truncation you didn't check for |
Questions to ask when it misbehaves: What is the exact capacity of the destination, in bytes, including the NUL? Did I pass that capacity (not a pointer's sizeof)? Did I check the return value? Is the buffer guaranteed NUL-terminated before I read it? Could the source length be attacker-controlled?
This topic is memory safety, so the concerns are concrete:
Out-of-bounds writes (the overflow itself). strcpy, strcat, sprintf, and gets will write past the end of a too-small buffer. On the stack this can overwrite the saved return address — the textbook path to arbitrary code execution. The defensive rule: the only functions allowed near untrusted input are the ones that take a size.
Missing NUL termination. A buffer without a terminator is a landmine: the next strlen/print("%s") reads until it randomly finds a zero byte, leaking adjacent memory (an information-disclosure bug) or crashing. snprintf and fgets always terminate; strncpy does not. Treat "is this string terminated within its buffer?" as an invariant you must guarantee.
Integer underflow in size arithmetic. size - len for an append underflows to a gigantic size_t if len > size, re-creating the overflow you were trying to prevent. Always establish len <= size before subtracting, as safe_cat does with its if (len >= dstsz) return -1; guard.
snprintf truncation as a logic vulnerability. Truncation is memory-safe but can still be a security bug: a truncated path or command can point somewhere unintended. Detecting truncation (return >= size) and treating it as an error is the defensive habit.
Defensive checklist (lab-safe, no exploit code): validate/limit input length at the boundary; size buffers from the same constant you pass as the limit (char b[N]; snprintf(b, N, ...)); prefer snprintf/fgets/strtok_r; check every return value; and NUL-terminate explicitly if you ever fall back to strncpy. None of this requires a working exploit to justify — the overflow is the bug regardless of whether it's weaponisable.
Where this shows up. Every program that assembles paths, log lines, HTTP headers, SQL fragments, or config strings does string copying — web servers, shells, embedded firmware, database engines, and network daemons all live and die on getting this right. The historical roll-call of buffer-overflow worms (Morris, Code Red, Slammer) is a roll-call of unchecked string copies in exactly this category of code. OpenBSD introduced strlcpy/strlcat precisely to make the safe path the easy path in a security-focused OS.
Professional habits — beginner level:
snprintf/fgets/strtok_r; treat strcpy/strcat/sprintf/gets as banned.Professional habits — advanced level:
safe_copy, safe_cat) so the length arithmetic lives in exactly one place, tested once.-Wformat-truncation, -Wstringop-overflow, and -fsanitize=address,undefined in CI so overflows fail the build, not production.-D_FORTIFY_SOURCE=2, code review rules, or a linter) that rejects the unsafe functions outright.Beginner 1 — Truncation reporter.
Write a function void describe_copy(const char *src) that copies src into a local char buf[10] using snprintf and prints either "fit: <buf>" or "truncated (needed N): <buf>", where N is the length the source would have required. Requirements: use the return value; never let snprintf write past buf. Example: input "hi" → fit: hi; input "engineering" → truncated (needed 11): engineeri. Concepts: snprintf return value, truncation detection.
Beginner 2 — Line reader.
Read up to 5 lines from standard input with fgets into a char line[32]. For each line, strip a trailing '\n' if present, and print whether the line was complete (a newline was found) or too long for the buffer. Constraints: no gets; handle end-of-file cleanly. Hint: the absence of '\n' in a full buffer signals an over-long line. Concepts: fgets, NUL termination, newline handling.
Intermediate 1 — Safe path join.
Implement int join_path(char *dst, size_t dstsz, const char *dir, const char *file) that builds "dir/file" into dst, inserting exactly one /. Return 0 on success, -1 on truncation (and leave dst a valid terminated string either way). Requirements: one snprintf; check the return. Example: ("/etc", "passwd") → /etc/passwd, returns 0. Concepts: formatted composition, truncation as error.
Intermediate 2 — Re-entrant field splitter.
Write int split_fields(char *line, const char *delims, char *out[], int max) that tokenises line with strtok_r, storing up to max field pointers in out, and returns the count. Constraints: must work correctly if called twice on two different lines in a row; no global/static state of your own. Example: "a,b,c" with "," → 3 fields. Concepts: strtok_r, re-entrancy, in-place tokenising.
Challenge — A portable strlcpy/strlcat pair.
Implement size_t my_strlcpy(char *dst, const char *src, size_t size) and size_t my_strlcat(char *dst, const char *src, size_t size) matching BSD semantics: always NUL-terminate when size > 0, and return the total length the result would have had (so a caller can detect truncation with ret >= size). Requirements: no use of snprintf; handle size == 0; for my_strlcat, correctly account for the existing dst length and never read past size bytes of dst (guard the unterminated case). Constraints: no undefined behaviour on empty or truncated inputs. Hint: write the copy loop by hand and track the would-be length separately from what you actually store. Concepts: NUL termination invariant, size arithmetic, underflow-safe append.
Unsafe string functions (strcpy, strcat, sprintf, gets, strtok) share one flaw: they never know your destination buffer's size, so they can write past it — a buffer overflow, one of the most exploited bugs in C. The fix is to use size-aware replacements by default:
snprintf(dst, size, ...) for copy/format/append — and always check its return: >= size means it truncated, < 0 means an error. Never assume it fit.fgets(buf, size, stdin) instead of gets — it takes a size and always NUL-terminates.strtok_r instead of strtok — re-entrant, no hidden state, safe in nested loops and threads.strlcpy/strlcat are clean and safe but BSD, not standard C; wrap or fall back to snprintf for portability.The habits that matter: size the buffer and the limit from one constant; guard size - len against underflow before appending; keep every buffer NUL-terminated (don't trust strncpy to do it); and treat truncation as a real error, not a shrug. Turn on -Wall -Wextra, -Wformat-truncation, and -fsanitize=address,undefined so the compiler and sanitizer catch what you miss. Remember: safe string functions fail loudly and safely; the unsafe ones fail quietly and dangerously.