Secure Coding in C · intermediate · ~20 min

Safe parsing — the defensive parser shape

- 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.

Overview

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.

Why it matters

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:

  • HTTP request smuggling / desync — a front-end proxy and a back-end server parse the same request differently, so an attacker can smuggle a second request past access controls.
  • Integer parsing overflow — a length field parsed without bounds becomes a huge allocation size or a negative index.
  • Zip-slip / path traversal — an archive extractor parses ../../etc/passwd as a normal path and writes outside the intended directory.
  • Format escapes — an image or document loader accepts a malformed structure and reads past the end of a buffer.

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.

Core concepts

1. Cap before you parse

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?

2. Tokenise, don't pattern-match by hand

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?

3. Default reject (fail closed)

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?

4. Parser differentials (the pentester mindset)

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.

5. Fail closed and clean up partial state

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.

Syntax notes

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.

Lesson

A safe parser does four things:

  1. Caps the input length.
  2. Refuses anything not in its grammar.
  3. Reports failure with a specific error code.
  4. Leaves the program in a clean state on failure.

The opposite approach is "parse what you can, ignore the rest." That habit is the source of most parser CVEs.

Code examples

#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.

Line by line

Walking through parse_record("Junk,37,9x", ...), the rejection case:

  1. if (!line)line is non-NULL, continue.
  2. strnlen(line, MAX_LINE + 1) > MAX_LINE — the line is 10 bytes, well under 256, so no rejection.
  3. 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.
  4. The 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.
  5. tok is now NULL and count == 3, so both structural checks pass.
  6. Name check: strlen("Junk") == 4, non-empty and less than name_cap (32) — passes.
  7. 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.
  8. 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.
  9. Back in 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.

Common mistakes

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.

Debugging tips

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.
  • Compile with -Wall -Wextra -Wconversion — the last one flags the silent longint narrowing that hides overflow bugs.

Runtime:

  • Crashes on long input usually mean a missing length cap; a fixed buffer overflowed. Run under -fsanitize=address,undefined — ASan pinpoints the overflow and UBSan catches signed-integer overflow in the parse itself.
  • A hang on certain inputs suggests a cursor that isn't advancing — a loop that reads a character but forgets to move past it on one branch.

Logic:

  • Parser accepts something it shouldn't? Print end - s (bytes consumed) and compare to strlen(s). If they differ, you skipped the trailing-garbage check.
  • Parser rejects something it should accept? Log which of the four checks fired (NULL, no-digits, trailing, range) so you know why, not just that, it rejected.

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?

Memory safety

Parsers live at the trust boundary, so their memory-safety discipline is non-negotiable.

  • Bounds. Never index or copy based on a length the input controls without checking it against your buffer size first. 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.
  • NUL-termination. 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.
  • Initialisation. Do not read an output parameter the parser may not have written. In the lesson code, outputs are committed only on success, so the caller must check the return code before reading age/score. Document this contract.
  • Overflow. Parsing a number is a prime overflow site: 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.
  • Ownership / lifetimes. 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.

Real-world uses

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.

Practice tasks

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.

Summary

A defensive parser is strict by construction: it accepts exactly one grammar and rejects everything else, clearly. Four moves define the shape:

  1. Cap the input length first — before any work, using 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).
  2. Tokenise into explicit units and default-reject — end every branch with a rejection, and confirm the whole input was consumed (*end == '\0').
  3. Convert strictly — replace atoi with strtol plus end-pointer, errno == ERANGE, and an explicit range check; commit to the output only on success.
  4. Fail closed — free partial state through a single cleanup path and return a specific error; never continue on corrupted intermediate state.

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.

Practice with these exercises