Secure Coding in C · beginner · ~20 min
## What you will learn - Recognise the **unbounded** string functions (`gets`, `strcpy`, `strcat`, `sprintf`, `scanf("%s")`) and why each one is dangerous. - Replace every unbounded write with a **bounded** equivalent (`fgets`, `snprintf`, `strlcpy`, `strlcat`) that takes the destination size. - Explain why `strncpy` is *not* a safe drop-in replacement, and detect the missing-NUL trap it creates. - Use `snprintf`'s return value to detect and handle **truncation** correctly. - Build the defensive habit: always pass `sizeof dst`, always confirm NUL termination. - Turn on compiler and runtime defences (`-D_FORTIFY_SOURCE=2`, `-Wformat-security`, `-fsanitize=address`) that catch buffer bugs automatically.
Every C string lives inside a fixed block of memory called a buffer — usually a char array. The size of that block is decided when the array is declared (char name[32];) or allocated (malloc(n)). Buffer safety is one rule applied without exception: every write into a buffer must be limited by the size of the destination, not by the length of the source.
This lesson builds directly on what you saw in C strings (a string is just bytes ending in a '\0') and strcpy and its dangers (the classic function that copies until the source runs out, never asking how big the destination is). Here we generalise that single problem into a complete, mechanical discipline you can apply everywhere.
Why does this matter so much? When a program writes past the end of a buffer, it corrupts whatever memory sits next door — another variable, a saved function-return address, heap bookkeeping. At best the program crashes; at worst an attacker who controls the input controls what gets written, and can hijack the program. This single bug class — the buffer overflow — is the most exploited weakness in the history of C.
In plain terms: a bounded function is one you can hand a 1000-byte input and a 16-byte buffer, and it will safely store the first 15 bytes plus a terminator and stop. An unbounded function will happily write all 1000 bytes, smashing 984 bytes of memory it was never given.
Authorization and ethics: every vulnerable snippet here is a training example for understanding defence. Practise only on your own machine, in a container, or in an authorised lab. Never test these techniques against systems you do not own or have written permission to test.
Buffer overflows are not a historical curiosity — they are still among the most common and most severe vulnerabilities found in C and C++ software today. A large share of the memory-corruption CVEs reported each year trace back to an unbounded write into a fixed buffer.
The reason this lesson is high-leverage is that the fix is mechanical and cheap. You do not need a redesign; you swap one function for its bounded cousin and pass the destination size. That single habit eliminates an entire class of vulnerabilities — not one bug, but every bug of that shape.
The stakes:
Because the failure mode is so severe and the fix is so simple, security auditors, fuzzers, and code-review tools all hunt for these functions first. Writing bounded code from the start means your name never shows up in that report.
Definition. An unbounded string function copies bytes from a source until it reaches the source's terminating '\0', with no knowledge of how large the destination is. A bounded function takes the destination's capacity as an argument and never writes beyond it.
How it works internally. A C string has no length stored anywhere. The only marker of "the end" is the '\0' byte. So strcpy(dst, src) is literally a loop: copy src[0], src[1], ... until it copies a '\0'. If src is longer than dst, the loop keeps writing into memory past dst.
char dst[8]; strcpy(dst, "HELLO WORLD");
memory: [ H ][ E ][ L ][ L ][ O ][ ][ W ][ O ] | [ R ][ L ][ D ][\0]
\___________ dst (8 bytes) ___________/ \__ OVERFLOW! __/
overwrites whatever
lives after dst
The 8-byte buffer holds 8 of the bytes; the remaining R L D \0 are written into neighbouring memory. That is the overflow.
When to use which. Use bounded functions always on data whose length you do not 100% control. In practice: treat all input as untrusted, so use bounded functions everywhere.
Pitfall. Thinking "this input is short, it will fit." Inputs change; an attacker chooses the length. Code that is "safe today" because the caller happens to be polite becomes a hole the moment the caller changes.
Knowledge check (predict the output):
char b[4]; strcpy(b, "abcd");— how many bytes doesstrcpywrite, and how many doesbhave room for? Why is this undefined behaviour even though "abcd" is only four letters?
Never call these on data you do not control the length of:
| Forbidden | Why it is dangerous | Bounded replacement |
|---|---|---|
gets(buf) |
No size argument at all; reads a whole line into buf. Removed from C11. |
fgets(buf, sizeof buf, stdin) |
strcpy(d, s) |
Copies until s ends; ignores size of d. |
snprintf / strlcpy |
strcat(d, s) |
Appends until s ends; ignores remaining room in d. |
snprintf / strlcat |
sprintf(d, fmt, ...) |
Formats with no size cap on d. |
snprintf(d, sizeof d, fmt, ...) |
scanf("%s", buf) |
%s reads an unbounded token into buf. |
scanf("%31s", buf) (width) or fgets |
Each of these takes the destination size and (with the noted caveat for strlcpy/strlcat) always writes the terminating NUL:
fgets(buf, sizeof buf, stdin) — reads at most sizeof buf - 1 bytes, then NUL-terminates. (Keeps the trailing '\n' if it fit.)snprintf(buf, sizeof buf, fmt, ...) — the workhorse. Formats into buf, never writes more than sizeof buf bytes total (including the NUL), and always terminates when size > 0.strlcpy(dst, src, sizeof dst) — bounded copy that always NUL-terminates (BSD/macOS; available via libbsd on Linux).strlcat(dst, src, sizeof dst) — bounded append that always NUL-terminates.Knowledge check (concept): All four safe functions share one argument the forbidden ones lack. What is it, and why is its presence the whole point?
strncpy is NOT strlcpyDefinition. strncpy(dst, src, n) copies at most n bytes — but it does not guarantee a terminating NUL. If src is n bytes or longer, dst ends up with no '\0'.
char d[4]; strncpy(d, "abcd", sizeof d); /* n == 4 */
result: [ a ][ b ][ c ][ d ] <- NO terminating '\0' !
the next str* call reads past the end of d
It also has a second surprise: if src is shorter than n, strncpy pads the rest of dst with '\0' bytes — wasting time on large buffers. So strncpy gives you the danger of a missing terminator and a performance quirk. Avoid it as a string copy. The safe choices are snprintf or strlcpy.
Pitfall. strncpy(dst, src, sizeof dst) looks like the safe version of strcpy, and reviewers skim right past it. It is the single most common false sense of security in this whole topic.
Knowledge check (find-the-bug): A colleague writes
char user[16]; strncpy(user, input, sizeof user); printf("%s\n", user);. Wheninputis exactly 16 characters long, what goes wrong at theprintf, and what one extra line fixes it?
Definition. Bounded functions truncate rather than overflow: if the source is too long, they store as much as fits and drop the rest. Truncation is memory-safe, but it can still be a logic bug (a filename that gets cut off can point at the wrong file).
snprintf returns the number of characters that would have been written had the buffer been big enough (excluding the NUL). So:
int n = snprintf(dst, cap, ...);
n < 0 -> encoding error
n >= cap -> output was TRUNCATED (n is the length it wanted)
0 <= n < cap -> full output fit; n is the real length
Pitfall. Ignoring the return value. A bounded write that silently truncates a security-relevant string (a path, a command, an allow-list entry) can be just as harmful as an overflow — just in a different way.
#include <stdio.h> /* snprintf, fgets */
#include <string.h> /* strlcpy/strlcat where available */
char dst[32];
/* Bounded format-and-copy. sizeof dst is the cap INCLUDING the NUL. */
int n = snprintf(dst, sizeof dst, "%s", src);
if (n < 0 || (size_t)n >= sizeof dst) {
/* n < 0 : output error
n >= cap: truncated — decide how to handle */
}
/* Bounded line read from stdin. Reads at most sizeof buf - 1 bytes. */
char buf[64];
if (fgets(buf, sizeof buf, stdin) != NULL) {
buf[strcspn(buf, "\n")] = '\0'; /* strip trailing newline if present */
}
/* Bounded copy/append (BSD/macOS; libbsd on Linux). Always NUL-terminate. */
strlcpy(dst, src, sizeof dst);
strlcat(dst, more, sizeof dst);
Key rule: the size argument is the total capacity of the destination, and sizeof dst only gives that when dst is a real array in scope — not a pointer. After a pointer decays (e.g. inside a function that takes char *dst), sizeof dst is the size of the pointer, not the buffer, so you must pass the capacity in explicitly.
C's classic string functions write until they hit a NUL byte in the source. They never check the size of the destination.
This includes strcpy, strcat, sprintf, gets, and scanf("%s").
Every CVE in the OWASP Top 10 memory-safety bucket traces back to one of these functions.
The fix is mechanical: use the bounded equivalent everywhere.
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
/* Build a greeting safely into a caller-provided buffer.
* cap is the buffer's total capacity (we cannot recover it from dst alone).
* Returns 0 on success, -1 if the result was truncated. */
static int make_banner(char *dst, size_t cap, const char *name) {
if (dst == NULL || cap == 0 || name == NULL)
return -1; /* validate inputs before touching memory */
int n = snprintf(dst, cap, "Welcome, %s!", name);
if (n < 0)
return -1; /* output/encoding error */
if ((size_t)n >= cap)
return -1; /* would not fit -> truncated */
return 0;
}
int main(void) {
char line[32];
printf("Enter your name: ");
if (fgets(line, sizeof line, stdin) == NULL) { /* bounded read */
fprintf(stderr, "no input\n");
return 1;
}
line[strcspn(line, "\n")] = '\0'; /* drop trailing newline */
char banner[24]; /* deliberately small */
if (make_banner(banner, sizeof banner, line) != 0) {
/* Truncation is handled, not ignored. We refuse rather than show
* a chopped-off, possibly misleading banner. */
fprintf(stderr, "name too long for banner\n");
return 1;
}
printf("%s\n", banner);
return 0;
}
What it does. It reads a name with fgets (bounded), strips the newline, then formats a greeting into a small fixed buffer with snprintf (bounded) and explicitly checks whether the result fit. No strcpy, strcat, or sprintf appears anywhere.
Expected output. With input Sam, it prints Welcome, Sam!. With a long input such as Alexandria-the-Magnificent, the formatted string exceeds the 24-byte banner, snprintf reports truncation, and the program prints name too long for banner to stderr and exits with status 1 — instead of corrupting memory.
Edge cases. Empty input (just Enter) yields Welcome, !, which fits. A name containing the literal % is harmless here because it travels through the %s argument, not the format string. fgets returning NULL (EOF / read error) is handled. There are no malloc/free calls, so there is nothing to leak; if you switched to a heap buffer you would free it before returning.
Walkthrough of the key example, assuming the user types Sam then Enter:
char line[32]; reserves 32 bytes on the stack for the raw input line.fgets(line, sizeof line, stdin) reads up to 31 bytes plus a NUL. After it returns, line holds Sam\n\0 (the newline that the user pressed is included).strcspn(line, "\n") returns 3 — the index of the first '\n'. Writing line[3] = '\0' replaces the newline, so line is now Sam\0.char banner[24]; reserves the destination. We pass its real capacity, sizeof banner (24), into make_banner.make_banner, the input checks pass (none are NULL, cap is 24).snprintf(dst, 24, "Welcome, %s!", "Sam") writes Welcome, Sam!\0 — 14 bytes — and returns 13 (length excluding the NUL).13 < 24, so neither error branch triggers; make_banner returns 0.printf("%s\n", banner) prints Welcome, Sam!.Now trace the long input Alexandria-the-Magnificent (26 chars):
| Step | snprintf would-be length |
Compared to cap (24) | Outcome |
|---|---|---|---|
format Welcome, Alexandria-the-Magnificent! |
36 | 36 >= 24 |
truncated |
make_banner return |
— | — | returns -1 |
main check |
— | — | prints error, exits 1 |
The crucial point: in the long case snprintf still only writes 24 bytes into banner (23 chars + NUL). The number it returns (36) is what tells us the rest was dropped. Memory was never corrupted; we caught the truncation and refused.
strcpy/strcat into a fixed buffer/* WARNING: Intentionally vulnerable training example — use only in a
local, isolated, authorized lab. Do not deploy. */
void render(const char *user_input, char *banner) {
strcpy(banner, "Welcome, ");
strcat(banner, user_input); /* unbounded: length is attacker-chosen */
}
Why it is wrong. Neither call knows the size of banner. A long user_input writes past the end, corrupting the stack frame — the classic overflow primitive.
Corrected version.
int render(char *banner, size_t cap, const char *user_input) {
int n = snprintf(banner, cap, "Welcome, %s", user_input);
return (n < 0 || (size_t)n >= cap) ? -1 : 0; /* report truncation */
}
How to recognise it. Search your code for strcpy(, strcat(, sprintf(, gets(. Any hit on data you did not generate yourself is a finding.
strncpychar u[16];
strncpy(u, input, sizeof u); /* WRONG: may leave u un-terminated */
printf("%s\n", u); /* reads past the buffer */
Why it is wrong. When input is 16+ bytes, u has no NUL, so printf("%s") keeps reading into neighbouring memory. Corrected: use snprintf(u, sizeof u, "%s", input); (always terminates), or if you must keep strncpy, force the terminator: u[sizeof u - 1] = '\0';.
+1 for the NULsize_t len = strlen(src);
char *copy = malloc(len); /* WRONG: no room for the '\0' */
strcpy(copy, src); /* writes len+1 bytes -> 1-byte overflow */
Why it is wrong. A string of length len occupies len + 1 bytes. Corrected: char *copy = malloc(len + 1); and check it for NULL, then memcpy(copy, src, len + 1); and free(copy) when done.
sizeof on a pointer parametervoid copy_in(char *dst, const char *src) {
snprintf(dst, sizeof dst, "%s", src); /* WRONG: sizeof dst == 8 (a pointer) */
}
Why it is wrong. Inside the function dst is a pointer, so sizeof dst is the pointer's size (often 8), not the buffer's. Corrected: pass the capacity in: void copy_in(char *dst, size_t cap, const char *src) { snprintf(dst, cap, "%s", src); }.
Compiler errors / warnings
-Wall -Wextra -Wformat-security -Werror. Modern GCC/Clang warn on gets, on sprintf overflow it can prove, and on format strings that are not literals.-D_FORTIFY_SOURCE=2 -O2: this adds runtime checks to several string/format functions and aborts with *** buffer overflow detected *** instead of corrupting memory.Runtime errors
-fsanitize=address (AddressSanitizer). The moment a copy writes one byte too far, ASan stops the program and prints the exact line, the buffer it belongs to, and the overflow size. This is the fastest way to localise a buffer bug.valgrind before guessing.Logic errors (no crash, wrong behaviour)
snprintf return value.printf("%s") prints garbage or runs forever, the buffer is likely not NUL-terminated (the strncpy trap). Inspect the bytes in a debugger: x/16xb buf in gdb.Questions to ask when it does not work
sizeof of a pointer)?The threat this lesson defends against is the buffer overflow: writing past the end of a buffer into adjacent memory. Depending on where the buffer lives, the consequences differ:
Threat model for a fixed-size input buffer
ENTRY POINT TRUST BOUNDARY ASSET
user / network ──────► | bounded copy | ──────► stack frame
(untrusted, | snprintf/ | (return addr,
any length) | fgets/ | locals)
| strlcpy |
+--------------+
Unbounded copy = no boundary: untrusted length reaches the asset directly.
Defensive practices (in order of leverage):
sizeof of a pointer.strncpy to do it.-D_FORTIFY_SOURCE=2, -Wformat-security, stack canaries via -fstack-protector-strong) and test under -fsanitize=address.Detection and logging. Log the fact of a rejected or truncated input ("input exceeded N bytes, rejected") with a length and a request identifier, so you can spot probing. Never log the raw oversized payload verbatim into an unbounded buffer (that is just another overflow), and never log secrets, passwords, or tokens.
Testing the fix (mitigation verification). After replacing an unbounded copy, prove it: feed the function an input longer than the buffer under AddressSanitizer. Before the fix, ASan reports a stack-buffer-overflow write. After the fix, the input is safely truncated or rejected and ASan stays silent — that contrast is your verification.
Where this shows up. Anywhere C handles externally supplied text:
Many of the most famous historical vulnerabilities — and a steady stream of current CVEs — are a single unbounded copy of attacker-controlled input. It is the single most-audited bug class in C, which is why static analysers, fuzzers, and reviewers look for it first.
Professional best-practice habits
Beginner rules:
strcpy, strcat, sprintf, gets, or bare scanf("%s"). Build the reflex of reaching for snprintf/fgets/strlcpy instead.sizeof dst (for a real array) or an explicit capacity (for a pointer).Advanced habits:
make_banner above) so the capacity and truncation check live in one audited place, and use it project-wide.sizeof checks.1. Swap the unsafe call. Given char out[20]; and a const char *name, write code that copies name into out safely using snprintf. Print out. Requirement: no strcpy/strcat. Hint: the format string is just "%s". Concept: bounded copy.
2. Bounded line reader. Read one line of input into char buf[16] using fgets, strip the trailing newline, and print the line and its length. Input example: hello -> output hello (5). Constraint: input may be longer than 15 chars — it must not overflow. Hint: strcspn(buf, "\n"). Concepts: fgets, NUL handling.
3. Truncation detector. Write int safe_copy(char *dst, size_t cap, const char *src) that returns 0 if src fit in dst and -1 if it was truncated, always leaving dst NUL-terminated. Demonstrate with a dst smaller than src. Hint: compare snprintf's return value with cap. Concept: truncation as a result.
4. Expose the strncpy trap. Write a tiny program with char d[8]; that fills d from an 8-character source with strncpy(d, src, sizeof d), then prints d. Observe the misbehaviour under -fsanitize=address. Then add the one line that fixes it and confirm ASan goes quiet. Hint: d[sizeof d - 1] = '\0';. Concept: missing terminator.
5. Implement your own strlcpy. Write size_t my_strlcpy(char *dst, const char *src, size_t cap) matching the BSD contract: copy at most cap - 1 bytes, always NUL-terminate when cap > 0, and return strlen(src) (so the caller can detect truncation by comparing the return value to cap). Requirements: handle cap == 0 without writing anything; do not read past src's NUL. Test it under AddressSanitizer with sources shorter than, equal to, and longer than the buffer. Constraint: do not call strcpy/strncpy internally. Hint: find strlen(src) first, then copy min(srclen, cap - 1) bytes and place the terminator. Concepts: everything in this lesson combined.
gets, strcpy, strcat, sprintf, scanf("%s") — they ignore the destination size and overflow.fgets, snprintf, strlcpy, strlcat — each takes a capacity and (with the strlcpy/strlcat contract) NUL-terminates.snprintf(dst, sizeof dst, "%s", src); check n < 0 (error) and n >= cap (truncated).strncpy is not safe: it may skip the terminating NUL — use snprintf/strlcpy, or force dst[cap-1] = '\0'.strncpy, forgetting the +1 for the NUL in malloc, and using sizeof on a pointer parameter instead of passing the real capacity.-D_FORTIFY_SOURCE=2 -Wformat-security, test under -fsanitize=address, and treat any forbidden-function hit as a build failure.