Secure Coding in C · intermediate · ~20 min
- Recognise the four moves every defensive parser makes: cap the input, accept only a defined grammar, report a specific error, and leave a clean state on failure. - Replace unsafe converters like `atoi` and `gets`-style reads with strict, range-checked parsing using `strtol` and explicit end-pointer checks. - Tokenise input into explicit units instead of pattern-matching your way through it, and end every branch with a `default: reject` rule. - Free or reset all partial state on the failure path so a rejected parse never leaks memory or leaves half-built structures behind. - Understand *parser differential* attacks — why two parsers that disagree about the same bytes create real vulnerabilities like HTTP request smuggling. - Build a corpus of malformed inputs and assert that every one of them is rejected.
Almost every serious security bug starts at the boundary where your program reads bytes it did not create: a request from the network, a line from a config file, a field in a binary packet, a header in an image. The code that turns those raw bytes into structured meaning is called a parser. Parsing is where attacker-controlled data first becomes something your program acts on, which makes it the single most valuable place to be strict.
This lesson builds directly on two ideas you have already met. From State machines for parsers, you know how to model input as a sequence of states with explicit transitions — that is the skeleton a strict parser hangs on. From Input validation, you know that untrusted input must be checked against a definition of what is allowed before you use it. Safe parsing fuses those two: the parser is the validation. It does not read data and then check it; the act of parsing succeeds only when the input matches the exact grammar you defined, and fails clearly otherwise.
In plain language: a defensive parser is a bouncer with a strict guest list, not a host who tries to make everyone feel welcome. If the input is not on the list — too long, wrong shape, extra characters, out of range — the parser turns it away at the door with a clear reason, and leaves nothing half-finished inside. The technical name for this stance is strict by construction, and its opposite, be liberal in what you accept, is one of the most consistently dangerous habits in systems programming.
Parser bugs appear in every category of the CVE list (the public catalogue of known security vulnerabilities), and they tend to be severe because a parser sits at the trust boundary — a bug there is directly reachable by an attacker.
A few recurring families:
../../etc/passwd as a normal path and writes outside the intended directory.The common root cause is not exotic. It is a parser that accepted something it should have rejected. Strict parsing is the cheapest, highest-leverage defence you can add: it removes the ambiguity that these attacks exploit, and it does so with plain, testable code rather than clever heuristics.
Definition. Before doing any work, reject input that exceeds a fixed maximum length.
Why. Length is the first thing an attacker controls. An unbounded input can exhaust memory, overflow a fixed buffer, or turn an O(n^2) parse into a denial of service. Capping first means every later step operates on a known-small, known-finite buffer.
How it works. You define a constant like #define MAX_INPUT 4096 and check strlen(s) > MAX_INPUT (or better, use strnlen(s, MAX_INPUT + 1) so you never scan an unbounded string) before anything else.
When NOT to. The cap should reflect a real protocol limit, not a random guess — an HTTP header line has a sane maximum; a whole file upload does not fit the same cap. Pick the cap per field.
Pitfall. Using strlen on input that might not be NUL-terminated. If the bytes came from read() on a socket, there is no guaranteed '\0'; strlen will walk off the end. Track the length you actually received.
Untrusted bytes ──▶ [ length cap ] ──▶ [ grammar check ] ──▶ [ range check ] ──▶ typed value
│ │ │
too long? wrong shape? out of range?
└── reject ──────────┴────────────────────┘
Knowledge check: Why is strnlen(s, MAX+1) safer than strlen(s) when the input might come straight off a socket?
Definition. A token is one meaningful unit of input — a number, a keyword, a separator, a field. Tokenising means splitting input into these units with explicit rules, then interpreting the units.
Why. Ad-hoc scanning ("find the first comma, then look for a digit, then maybe a space...") hides edge cases. Explicit tokens have explicit grammars you can reason about and test one at a time.
How it works. You advance a cursor through the string, and for each position you decide which token you are reading based on the current character and state (this is exactly the state-machine model from the prerequisite lesson).
When NOT to. For genuinely trivial one-shot conversions ("is this string exactly yes or no?") a direct strcmp is clearer than a tokeniser. Reserve tokenising for structured input.
Pitfall. Regular expressions feel like tokenising but often accept more than you intended (a . that matches a newline, an unanchored pattern that matches a substring). In C, they also add a dependency and a whole class of catastrophic-backtracking DoS bugs. Prefer explicit character classification.
Knowledge check: In your own words, what is the difference between a token and a field, and why does giving each token an explicit grammar make bugs easier to find?
Definition. Every decision point ends with an explicit rejection of anything not matched. Every switch has a default: return -1;; every loop that expects to consume the whole input verifies it actually did.
Why. The safe default is "no." If new input shapes appear that you never considered, a default-reject parser refuses them; a default-accept parser silently lets them through — and that silent path is where vulnerabilities live.
How it works internally. After parsing what you expect, check that the cursor reached the end of the input (*end == '\0'). Trailing bytes you did not consume are a rejection, not a shrug.
Pitfall. strtol("12abc", ...) returns 12 and quietly leaves end pointing at "abc". If you forget to check *end, you have just accepted garbage.
| Stance | On unknown input | Typical result |
|---|---|---|
| Default accept ("be liberal") | passes it through | silent misparse, security bug |
| Default reject ("fail closed") | returns an error | caller handles it explicitly |
Knowledge check (find the bug): long v = strtol(s, &end, 10); *out = (int)v; return 0; — what two checks are missing before that return?
Definition. A parser differential is when two pieces of code parse the same bytes and reach different conclusions about what they mean.
Why it is a vulnerability. Security decisions assume one interpretation. If a proxy thinks a request ends here and the back-end thinks it ends there, an attacker can hide a second request in the gap — this is HTTP request smuggling and HTTP/2 desync. The disagreement is the exploit.
How strictness helps. The more inputs a parser accepts, the more room there is for a second parser to disagree. A strict parser that accepts exactly one interpretation of exactly one grammar shrinks that disagreement surface toward zero.
Pitfall. "Being helpful" (accepting trailing whitespace, alternate separators, mixed encodings) feels friendly but widens the differential. Helpfulness at the trust boundary is a liability.
Definition. On any error, free every partial allocation, reset any half-written output, and return a non-zero code — never continue with corrupted intermediate state.
Why. A parser often builds a structure incrementally. If it errors halfway and returns without unwinding, you leak the memory it allocated, and worse, a caller might read the half-built struct as if it were valid.
Pitfall. Multiple return -1; statements scattered through a function, each forgetting to free something different. The classic fix is a single cleanup path reached with goto fail; — one place that frees everything, so every error route unwinds identically.
The core idioms of a strict parser in C:
#define MAX_INPUT 4096
int parse_field(const char *s, int *out) {
if (!s) return -1; /* NULL guard first */
if (strnlen(s, MAX_INPUT + 1) > MAX_INPUT) /* cap before work */
return -1;
char *end;
errno = 0; /* strtol reports overflow via errno */
long v = strtol(s, &end, 10);
if (end == s) return -1; /* no digits at all */
if (*end != '\0') return -1; /* trailing garbage */
if (errno == ERANGE) return -1; /* value out of long range */
if (v < 0 || v > 255) return -1; /* out of *your* range */
*out = (int)v; /* only now: commit output */
return 0;
}
Key points: guard NULL, cap length, set errno = 0 before strtol, then reject on four independent conditions before committing to *out. The output is written only on the success path, so a rejected call never leaves a misleading value behind.
A safe parser does four things:
The opposite approach is "parse what you can, ignore the rest." That habit is the source of most parser CVEs.
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <errno.h>
#include <limits.h>
#define MAX_LINE 256
#define MAX_FIELDS 8
/* Strictly parse s as a base-10 int in [lo, hi]. Returns 0 on success. */
static int parse_int_range(const char *s, long lo, long hi, long *out) {
if (!s || !*s) return -1; /* NULL or empty is not a number */
char *end;
errno = 0;
long v = strtol(s, &end, 10);
if (end == s) return -1; /* no digits consumed */
if (*end != '\0') return -1; /* trailing garbage, e.g. "12x" */
if (errno == ERANGE) return -1; /* overflowed long */
if (v < lo || v > hi) return -1; /* outside the allowed window */
*out = v;
return 0;
}
/* Parse "name,age,score" strictly. On any error: rewind and reject. */
static int parse_record(const char *line, char *name, size_t name_cap,
long *age, long *score) {
if (!line) return -1;
if (strnlen(line, MAX_LINE + 1) > MAX_LINE) return -1; /* cap first */
/* Work on a private copy so we can tokenise in place without
mutating the caller's buffer. */
char buf[MAX_LINE + 1];
size_t n = strlen(line);
memcpy(buf, line, n + 1); /* includes the NUL terminator */
/* Tokenise on commas: expect exactly three fields, no more, no less. */
char *fields[MAX_FIELDS];
int count = 0;
char *tok = strtok(buf, ",");
while (tok && count < MAX_FIELDS) {
fields[count++] = tok;
tok = strtok(NULL, ",");
}
if (tok != NULL) return -1; /* more than MAX_FIELDS: reject */
if (count != 3) return -1; /* wrong field count: reject */
/* Field 0: name — must be non-empty and fit the caller's buffer. */
size_t name_len = strlen(fields[0]);
if (name_len == 0 || name_len >= name_cap) return -1;
/* Fields 1 and 2: strict ranged integers. Parse into locals first so a
failure on field 2 never leaves field 1 committed to the caller. */
long a, sc;
if (parse_int_range(fields[1], 0, 150, &a) != 0) return -1;
if (parse_int_range(fields[2], 0, 100, &sc) != 0) return -1;
/* All fields validated: commit outputs together. */
memcpy(name, fields[0], name_len + 1);
*age = a;
*score = sc;
return 0;
}
int main(void) {
const char *tests[] = {
"Ada,37,95", /* valid */
"Grace,109,88", /* valid */
"Bad,200,50", /* age out of range */
"Junk,37,9x", /* trailing garbage in score */
"TooFew,42", /* missing a field */
"A,B,C,D", /* too many fields */
",30,30", /* empty name */
};
char name[32];
long age, score;
for (size_t i = 0; i < sizeof tests / sizeof tests[0]; i++) {
if (parse_record(tests[i], name, sizeof name, &age, &score) == 0)
printf("OK | name=%-6s age=%3ld score=%3ld <- \"%s\"\n",
name, age, score, tests[i]);
else
printf("REJECT| \"%s\"\n", tests[i]);
}
return 0;
}
What it does. parse_record takes a comma-separated line and accepts it only if it has exactly three fields: a non-empty name that fits the caller's buffer, an age in [0, 150], and a score in [0, 100]. Every other shape is rejected. The main driver runs a small corpus of good and bad inputs.
Expected output:
OK | name=Ada age= 37 score= 95 <- "Ada,37,95"
OK | name=Grace age=109 score= 88 <- "Grace,109,88"
REJECT| "Bad,200,50"
REJECT| "Junk,37,9x"
REJECT| "TooFew,42"
REJECT| "A,B,C,D"
REJECT| ",30,30"
Edge cases worth noting. An empty string, a line longer than MAX_LINE, "12 " (trailing space — rejected because *end is the space), and "+37" (accepted by strtol as 37; reject it too if your grammar forbids a sign). strtok treats consecutive commas as one delimiter, so "a,,b" collapses — if empty fields must be distinguishable, tokenise manually instead of using strtok.
Walking through parse_record("Junk,37,9x", ...), the rejection case:
if (!line) — line is non-NULL, continue.strnlen(line, MAX_LINE + 1) > MAX_LINE — the line is 10 bytes, well under 256, so no rejection.memcpy(buf, line, n + 1) — copies "Junk,37,9x\0" into the local buf. We tokenise the copy so the caller's string is untouched.strtok loop runs three times: fields[0]="Junk", fields[1]="37", fields[2]="9x". strtok has overwritten each comma in buf with a '\0', so each field is now its own NUL-terminated string. count becomes 3.tok is now NULL and count == 3, so both structural checks pass.strlen("Junk") == 4, non-empty and less than name_cap (32) — passes.parse_int_range("37", 0, 150, &a) — strtol reads 37, end points at the '\0', errno is 0, 37 is in range. Returns 0, a = 37.parse_int_range("9x", 0, 100, &sc) — strtol reads 9, but end now points at "x". The check *end != '\0' is true, so it returns -1.parse_record, that non-zero result triggers return -1. Crucially, *age and *score were never written — a and sc are locals — so the caller's age/score keep their old values and there is no misleading half-parsed record.| Step | cursor / end points at |
decision |
|---|---|---|
field 1 "37" |
'\0' |
accept, a = 37 (local) |
field 2 "9x" |
'x' |
*end != '\0' → reject |
| return | — | -1, no outputs committed |
The key insight: parsing into local variables and committing to the caller's *out only on the final success path is what makes "fail closed" automatic. There is no partial state to clean up because nothing was committed.
Mistake 1 — trusting atoi.
int age = atoi(field); /* WRONG at a trust boundary */
atoi("abc") returns 0 and atoi("99999999999999") is undefined behaviour on overflow. It cannot report failure, so "abc" silently becomes a valid-looking 0. Fix: use strtol with an end-pointer and errno check, as in the lesson code. Recognise it: any atoi/atol/scanf("%d") on untrusted input is a red flag.
Mistake 2 — forgetting the trailing-garbage check.
long v = strtol(s, &end, 10);
if (v < 0 || v > 100) return -1; /* checks range but not *end */
This accepts "50; DROP TABLE" as 50. Fix: add if (*end != '\0') return -1; so the whole string must be consumed. Recognise it: a strtol call whose end variable is written but never read.
Mistake 3 — leaking partial state on error.
Record *r = malloc(sizeof *r);
r->name = strdup(fields[0]);
if (parse_int_range(fields[1], 0, 150, &r->age) != 0)
return -1; /* WRONG: leaks r and r->name */
Fix: unwind to a single cleanup label:
if (parse_int_range(fields[1], 0, 150, &r->age) != 0) goto fail;
return 0;
fail:
free(r->name);
free(r);
return -1;
Recognise it: more than one return in a function that has malloced something, where the returns don't all free it.
Mistake 4 — being liberal "to be nice." Accepting " 37", "37 ", "+37", "037", and "0x25" all as the same number feels friendly but creates a parser differential — the next parser downstream may read them differently. Fix: define one exact grammar and reject the rest. If you truly need to allow leading zeros or signs, allow them deliberately, documented, not by accident.
Compiler-level:
warning: implicit declaration of function 'strtol' — you forgot #include <stdlib.h>. errno/ERANGE need <errno.h>; LONG_MAX needs <limits.h>.warning: 'end' may be used uninitialized — you read *end on a path where strtol was skipped. Ensure strtol runs before every use of end.-Wall -Wextra -Wconversion — the last one flags the silent long→int narrowing that hides overflow bugs.Runtime:
-fsanitize=address,undefined — ASan pinpoints the overflow and UBSan catches signed-integer overflow in the parse itself.Logic:
end - s (bytes consumed) and compare to strlen(s). If they differ, you skipped the trailing-garbage check.Questions to ask when it doesn't work: Did I cap length first? Did the whole string get consumed? Did I set errno = 0 before strtol? Are outputs written only on success? Is there a malformed input in my corpus that I never tested?
Parsers live at the trust boundary, so their memory-safety discipline is non-negotiable.
name_len >= name_cap before memcpy is what prevents a buffer overflow here. The cap-before-parse rule is a memory-safety rule, not just a DoS rule.strlen, strtol, strtok, and strcmp all assume a '\0'. Bytes from read()/recv() are not guaranteed to have one. Either append a terminator into a sized buffer yourself, or use length-bounded operations (memchr, strnlen) and track the length explicitly.age/score. Document this contract.strtol + errno == ERANGE catches long overflow, and an explicit range check catches values too big for your narrower target type. Never compute value * 10 + digit by hand without an overflow guard.strtok returns pointers into the buffer you passed it — they are valid only as long as that buffer lives and is not modified further. Never store a strtok pointer past the lifetime of its backing buffer, and never free it.Security framing. The permissive parser below is the vulnerability; the strict one is the fix.
/* VULNERABLE: no cap, no NUL guarantee, no consumed-all check */
void handle(char *buf) { /* buf came off a socket, maybe no '\0' */
int n = atoi(buf); /* silently 0 on garbage; reads until it finds a NUL */
process(n); /* acts on attacker-shaped value */
}
/* FIXED: cap, explicit length, strict conversion, range check */
int handle_safe(const char *buf, size_t len, int *out) {
if (len == 0 || len > MAX_INPUT) return -1;
char tmp[MAX_INPUT + 1];
memcpy(tmp, buf, len);
tmp[len] = '\0'; /* guarantee termination for strtol */
long v; char *end; errno = 0;
v = strtol(tmp, &end, 10);
if (end != tmp + len || errno == ERANGE || v < 0 || v > 65535) return -1;
*out = (int)v;
return 0;
}
Defensive practices: validate before use, keep the grammar minimal (least privilege for input shapes), prefer length-bounded standard functions, and pair any demonstrated weakness with its fix rather than leaving it standing.
Concrete case. Every web server parses an HTTP request line — GET /index.html HTTP/1.1 — into a method, a path, and a version. Getting this strict matters: nginx and Apache both cap the request-line length, reject embedded control characters, and refuse ambiguous framing precisely to avoid the request-smuggling differentials described earlier. The http-parse-request-line and parse-passwd-line exercises in this track are miniatures of exactly this work. Config-file readers (sshd_config), packet decoders, ACL rules that parse dotted-quad IPs (ipv4-parse-dotted), and image loaders all follow the same shape.
Best-practice habits.
| Habit | Beginner | Advanced |
|---|---|---|
| Conversion | replace atoi with checked strtol |
write a project-wide parse_int_range helper and ban raw atoi in review |
| Length | add a MAX_INPUT cap per field |
derive caps from the protocol spec; enforce in one input layer |
| Errors | return 0/-1, check every call |
typed error codes that say why it failed, for logging and metrics |
| Cleanup | free on each error path | single goto fail: unwind; RAII-style wrappers where possible |
| Testing | hand-write a malformed-input corpus | fuzz the parser (libFuzzer/AFL++) and add every crash to the corpus |
| Naming | parse_record, not p |
names encode the grammar: parse_uint_bounded, parse_hex_byte |
The throughline: readable names, one exact grammar per field, validate-then-commit, clean unwinding, and a growing corpus of rejected inputs. Strictness is not extra work bolted on — it is the parser.
Beginner 1 — Strict bounded integer. Write int parse_uint_max(const char *s, int max, int *out) that accepts only a non-negative base-10 integer in [0, max]. Reject NULL, empty strings, signs, leading/trailing spaces, and trailing garbage. Example: parse_uint_max("42", 100, &v) → returns 0, v == 42; parse_uint_max("42x", 100, &v) → returns -1. Use: strtol, end-pointer check, errno, range check.
Beginner 2 — Yes/no keyword parser. Write int parse_bool(const char *s, int *out) that accepts exactly the strings "true" and "false" (nothing else, not "True", not "1") and sets *out to 1 or 0. Everything else returns -1. Use: strcmp, default-reject. Hint: one strcmp per accepted keyword, then fall through to reject.
Intermediate 1 — Fixed-width hex byte. Write int parse_hex_byte(const char *s, int *out) that accepts exactly two hex digits ("0f", "A3") and sets *out to the byte value 0-255. Reject one digit, three digits, "0x1f", and non-hex characters. Constraint: do not use strtol here — classify each of the two characters yourself, so the fixed width is explicit. Use: character classification, explicit length check.
Intermediate 2 — Three-field record with cleanup. Extend the lesson's parse_record to allocate a Record { char *name; long age; long score; } on the heap and return it via Record **out. Requirement: on any parse error, free every partial allocation and leave *out unchanged. Use: goto fail: single-cleanup pattern, validate-then-commit. Hint: allocate name last, or allocate the struct first and NULL its pointers so the cleanup path is uniform.
Challenge — Strict dotted-quad IPv4 parser. Write int ipv4_parse(const char *s, uint32_t *out) that converts "192.168.1.1" into its 32-bit value (big-endian: first octet in the most-significant byte). Requirements: exactly four octets separated by exactly three dots; each octet is 1-3 digits in [0, 255]; reject leading zeros ("01"), empty octets ("1..2.3"), extra dots, trailing garbage, and values over 255. Example: "192.168.1.1" → 0xC0A80101. Constraints: single pass, no sscanf. Use: a small state machine (from the state-machines lesson), per-octet range checks, and a strict consumed-all check at the end. Hint: count octets and digits-per-octet as you go; the differential-safety win comes from rejecting the "helpful" variants most naive parsers accept.
A defensive parser is strict by construction: it accepts exactly one grammar and rejects everything else, clearly. Four moves define the shape:
strnlen/an explicit length, because length is the first thing an attacker controls (this is a memory-safety rule as much as a DoS rule).*end == '\0').atoi with strtol plus end-pointer, errno == ERANGE, and an explicit range check; commit to the output only on success.The most important syntax to remember is the strtol idiom: set errno = 0, call strtol(s, &end, 10), then reject on no digits, trailing garbage, ERANGE, and out of range before writing *out. The most common mistakes are trusting atoi, forgetting the *end check, leaking on error, and "being liberal" — which widens the parser-differential surface that attacks like request smuggling exploit. When two parsers disagree about the same bytes, the disagreement is the vulnerability; strictness is how you close it. Build a corpus of malformed inputs and prove every one is rejected.