Secure Coding in C · intermediate · ~10 min

Secure error handling

- Check the return value of *every* call that can fail — allocation, I/O, and system calls - Design code that **fails closed**: on error, refuse the operation rather than continuing with bad data - Split diagnostics into two audiences — terse, generic messages for users and rich detail for private logs - Use `errno` correctly to turn a raw failure into a precise, human-readable reason - Clean up resources (memory, files, sockets) on *every* exit path, including error paths - Recognise the classic bugs (ignored return values, use-after-error, `errno` clobbering, information leaks) and fix them

Overview

Every non-trivial program asks the outside world for things: memory from the allocator, bytes from a file, a connection from the network, permission from the operating system. Any of those requests can be refused. Error handling is the discipline of assuming that refusal will happen and deciding, in advance, exactly what your program does when it does.

Beginners often write only the happy path — the sequence of steps that runs when nothing goes wrong. That code looks correct in a demo and breaks the moment a disk fills up, a file is missing, or an attacker sends a malformed request. Worse, in C the language will not stop you: if you ignore a failure, execution simply continues with a null pointer, an uninitialised buffer, or a half-open file. The result is a crash at best and an exploitable vulnerability at worst.

This lesson builds directly on errno and error reporting. There you learned that many C functions signal failure through a return value and set the global errno to say why. Here we turn that reporting mechanism into a policy: how to react to failure so the program stays correct, stays safe, and tells the right people the right amount. In plain terms — check everything, stop safely when something breaks, and never let an error message become a map for an attacker.

Why it matters

In ordinary software, unchecked errors show up as mysterious crashes and corrupted files. In security-sensitive software, they show up as vulnerabilities. A missed check on malloc becomes a null-pointer dereference an attacker can trigger on demand. A function that returns partial data on error, and a caller that trusts it, becomes a way to bypass authentication. An error message that echoes a file path, a SQL fragment, or a stack address hands the attacker exactly the reconnaissance they need.

The pattern is consistent enough to have a name in secure-coding circles: most real-world flaws are not exotic. They come from testing only the success path and assuming the failure path "can't happen". Getting error handling right is one of the highest-leverage habits a C programmer can build — it prevents whole categories of bugs before they exist, and it is what separates code that merely runs from code you can trust with untrusted input.

Core concepts

1. Check every return value

Definition. After any call that can fail, inspect what it returns before you use its result.

In C there are no exceptions. A function that fails tells you by its return value — a NULL pointer from malloc or fopen, -1 from read, write, open, or close, a non-zero code from many library calls. If you do not look, you never find out; the program simply marches on with a broken value.

A system call (syscall) is a request your program makes to the operating system — open a file, read a socket, allocate a page. Because the OS can refuse for reasons entirely outside your program (permissions, full disk, killed process, resource limits), syscalls are the failures most worth checking.

  p = malloc(n);
            |
     +------+------+
     |             |
  success       failure
  p = valid     p = NULL
     |             |
  use *p     dereference NULL -> CRASH / UB

When to check: always, for calls that can fail. When not to obsess: a few calls essentially never fail in normal use (printf to stdout is often left unchecked in small programs), but even there, security-critical code checks.

Pitfall: checking with the wrong comparison. read returns the number of bytes read; 0 means end-of-file, -1 means error. Writing if (read(fd, buf, n)) treats both EOF and success as "truthy" and error (-1, also truthy) slips through. Compare explicitly.

Knowledge check: char *p = malloc(1000); — you never check p. Under what real-world condition does the next line p[0] = 'x'; crash, and why can't the compiler warn you reliably?

2. Fail closed

Definition. When an operation fails, take the safe default action — deny, drop, refuse — rather than proceeding.

Security code makes decisions: is this user allowed? is this token valid? did the signature verify? If the check itself errors out (the database is down, memory ran out, the file is unreadable), you have two choices. Fail closed means you treat "I couldn't decide" as "no". Fail open means you treat it as "yes" — and that is how error handling becomes a breach.

  verify_token(t)
        |
   +----+-----------------+
   |         |            |
  == 1      == 0        error (< 0)
  valid    invalid      couldn't check
   |         |            |
  ALLOW     DENY     -> DENY  (fail closed)
                     -> ALLOW (fail OPEN = bug)
Situation Fail closed (safe) Fail open (dangerous)
Auth check errors Deny access Grant access
Config file missing Refuse to start / use safe defaults Run with no restrictions
Allocation fails mid-request Abort the request cleanly Continue with partial buffer
Cert verification errors Reject connection Accept connection

When NOT to fail hard-closed: availability-critical, non-security paths sometimes prefer a safe degraded mode over a total stop — but the degraded mode must itself be safe, never a bypass.

Pitfall: a function that returns int where 0 means success, but a caller writes if (check() == 1) deny();. If check() returns a negative error code, the == 1 is false and the code silently allows. Handle the error branch explicitly.

Knowledge check: A login function returns 1 for valid, 0 for invalid, -1 if the user database couldn't be opened. A caller writes if (result) grant_access();. Explain in your own words why this fails open.

3. Do not leak internals

Definition. Give users a short, generic message; save the detailed diagnostics to a private log.

An error message has two possible audiences with opposite needs. You (or an operator reading logs) want everything: the exact errno, the file path, the line, the failing input. A remote user — who might be an attacker — should get almost nothing, because every detail is reconnaissance. A message like open("/var/app/secret/keys.db") failed: Permission denied tells an attacker your directory layout, your filenames, and that the file exists.

  error occurs
       |
   +---+-----------------------------+
   |                                 |
  to the log (private)         to the user (public)
  "open /var/app/keys.db:       "Something went wrong.
   errno 13 EACCES at auth.c:88"  Please try again. (ref 4F2A)"

A CVE (Common Vulnerabilities and Exposures) is a publicly listed security flaw; "information exposure through an error message" is a recognised weakness class (CWE-209) precisely because leaked diagnostics enable later attacks.

When to be verbose: in logs, in development builds, behind authentication for trusted operators. When NOT to: anything crossing a trust boundary to an untrusted party.

Pitfall: passing strerror(errno) or the raw failing path straight into an HTTP response or a user-visible string. Pair a public reference id (to correlate with the log) with a generic message instead.

Knowledge check: Why is "Invalid password for user admin" a worse message to show at a login prompt than "Invalid username or password"? (Think about what each one confirms.)

Syntax notes

The recurring shape of a checked call, with cleanup and layered messaging:

#include <stdio.h>
#include <string.h>   // strerror
#include <errno.h>    // errno

FILE *f = fopen(path, "r");
if (f == NULL) {                       // 1. check the return value
    // 2. detailed message -> private log (stderr here stands in for a log)
    fprintf(stderr, "fopen(%s): %s\n", path, strerror(errno));
    return -1;                          // 3. fail closed: stop, signal failure
}
// ... use f ...
if (fclose(f) == EOF) {                // even close() can fail (flush errors)
    fprintf(stderr, "fclose: %s\n", strerror(errno));
    return -1;
}

Key points: capture the return value into a variable, compare it against the documented failure value (NULL, -1, EOF), read errno immediately (the next call may overwrite it), and make the error branch return/goto-cleanup rather than falling through. strerror(errno) converts the numeric errno to a readable string — great for logs, but keep it out of user-facing output.

Lesson

Three rules for secure error handling

1. Check every return value

Check the result of any function that can fail. That includes malloc, read, open, and every system call.

A system call (syscall) is a request your program makes to the operating system, for example to open a file or read from the network. These calls can fail at any time, so you must inspect what they return.

2. Fail closed

On error, stop safely. Deny access or drop the request.

Never continue with partial or unverified data. "Failing closed" means the safe default on failure is to refuse, not to proceed.

3. Do not leak internals

Keep error messages sent to remote users short and generic.

Send detailed errors to your log file instead, where only you can read them. Detailed messages exposed to users can reveal how your system works.

Why this matters

A surprising number of CVEs trace back to one habit: testing only the success path.

A CVE (Common Vulnerabilities and Exposures) is a publicly listed security flaw.

Code examples

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <errno.h>

/*
 * Reads up to `max` bytes from a file into a freshly allocated buffer.
 * Demonstrates: check every call, fail closed, clean up on every path,
 * and separate a private (detailed) log from a public (generic) result.
 *
 * Returns a malloc'd, NUL-terminated buffer on success (caller frees),
 * or NULL on failure. `*out_len` receives the byte count on success.
 */
static char *read_file(const char *path, size_t max, size_t *out_len) {
    if (path == NULL || out_len == NULL || max == 0) {
        return NULL;                       // validate inputs, fail closed
    }

    FILE *f = fopen(path, "rb");
    if (f == NULL) {
        // Detailed diagnostic goes to the log (stderr stands in for it).
        fprintf(stderr, "[log] fopen(%s): %s\n", path, strerror(errno));
        return NULL;
    }

    char *buf = malloc(max + 1);            // +1 for the NUL terminator
    if (buf == NULL) {
        fprintf(stderr, "[log] malloc(%zu) failed\n", max + 1);
        fclose(f);                         // clean up the file we opened
        return NULL;
    }

    size_t n = fread(buf, 1, max, f);       // fread returns items read
    if (ferror(f)) {                        // distinguish error from short read/EOF
        fprintf(stderr, "[log] fread(%s): read error\n", path);
        free(buf);                         // clean up both resources
        fclose(f);
        return NULL;
    }

    if (fclose(f) == EOF) {                 // close can fail too
        fprintf(stderr, "[log] fclose(%s): %s\n", path, strerror(errno));
        free(buf);
        return NULL;
    }

    buf[n] = '\0';                          // safe: we allocated max + 1
    *out_len = n;
    return buf;
}

int main(int argc, char **argv) {
    const char *path = (argc > 1) ? argv[1] : "/etc/hostname";

    size_t len = 0;
    char *data = read_file(path, 4096, &len);
    if (data == NULL) {
        // Public message: generic, no internals leaked.
        fprintf(stdout, "Could not read the requested file. (ref: RF01)\n");
        return EXIT_FAILURE;                // fail closed: non-zero exit
    }

    printf("Read %zu bytes.\n", len);
    free(data);                             // release the buffer we own
    return EXIT_SUCCESS;
}

What it does: read_file opens a file, allocates a buffer, reads into it, terminates it, and returns it — but every step that can fail is checked, and every failure frees whatever was already acquired before returning NULL. main turns that NULL into a generic user message plus a non-zero exit code (failing closed), while the detailed reasons went to stderr (the log).

Expected output (running with no argument, on a typical Linux box where /etc/hostname exists and is readable):

Read 9 bytes.

(The exact byte count is your hostname length plus its trailing newline.) If you instead run it on a missing file, e.g. ./a.out /no/such/file, you get two lines — the private log line and the public line:

[log] fopen(/no/such/file): No such file or directory
Could not read the requested file. (ref: RF01)

Edge cases handled: NULL/zero inputs are rejected up front; a malloc failure frees nothing-yet-but-closes the file; a mid-read error frees the buffer and closes the file; a fclose flush error still frees the buffer. The +1 byte guarantees room for the terminator so buf[n] = '\0' never overflows.

Line by line

  1. Input validation. if (path == NULL || out_len == NULL || max == 0) refuses nonsense arguments immediately — a bad caller can't drive the function into undefined behaviour. This is failing closed at the front door.
  2. fopen. Returns a FILE * or NULL. We test for NULL; on failure we log strerror(errno) (the why) and return. errno is read right after the failing call, before anything else can overwrite it.
  3. malloc(max + 1). On failure it returns NULL. Crucially, the file is already open, so before returning we fclose(f) — otherwise every failed allocation would leak a file descriptor.
  4. fread. Returns the number of items read, which may be less than max at end-of-file — that is not an error. So we don't compare the count; we call ferror(f) to ask specifically whether a read error occurred. If it did, we free the buffer and close the file before returning.
  5. fclose. Returns EOF on failure (a buffered write flush can fail). We check it and free the buffer if it fails.
  6. buf[n] = '\0'. Only reached once everything succeeded. n <= max, and we allocated max + 1, so index n is always in bounds.
  7. In main. A NULL return becomes a generic message and EXIT_FAILURE. On success we print the length and free(data) — the buffer's owner is the caller, and the caller releases it.
Point in execution f buf Action on failure
after fopen fails NULL log, return NULL
after malloc fails open NULL fclose(f), return NULL
after fread errors open valid free(buf), fclose(f), return NULL
after fclose fails closed valid free(buf), return NULL
all succeed closed valid terminate, return buf

Common mistakes

Mistake 1 — Ignoring the return value entirely.

char *buf = malloc(size);
buf[0] = 0;            // WRONG: buf may be NULL

Why it's wrong: if malloc fails, buf is NULL and the write is a null-pointer dereference (undefined behaviour, usually a crash). Fix:

char *buf = malloc(size);
if (buf == NULL) { /* log, fail closed */ return -1; }
buf[0] = 0;

Recognise it: any allocation or I/O call whose result is used on the very next line without a check.

Mistake 2 — Truthiness testing of read/fread.

if (read(fd, buf, n)) { /* treated as success */ }   // WRONG

Why it's wrong: read returns -1 on error, 0 on EOF, and a positive count otherwise. -1 is truthy, so errors are mistaken for success. Fix by comparing explicitly:

ssize_t r = read(fd, buf, n);
if (r < 0)      { /* error: fail closed */ }
else if (r == 0){ /* EOF */ }
else            { /* r bytes available */ }

Mistake 3 — Failing open in a security decision.

int ok = verify(token);      // 1 valid, 0 invalid, -1 error
if (!ok) deny(); else grant();   // WRONG: -1 is truthy -> grant()

-1 (couldn't verify) is truthy, so !ok is false and access is granted. Fix — handle the three cases and treat error as denial:

int ok = verify(token);
if (ok == 1) grant();
else         deny();        // 0 AND -1 both deny: fail closed

Mistake 4 — Clobbering errno before you read it.

if (fopen(path,"r") == NULL) {
    log_msg("opening file");            // this call may change errno!
    printf("error: %s\n", strerror(errno));   // WRONG: wrong errno now
}

Capture errno first: int e = errno; immediately after the failing call, then use e.

Mistake 5 — Leaking resources on the error path. Returning early after fopen succeeds but before fclose — the classic leaked file descriptor. Fix with a single cleanup section (goto cleanup;) or free/close before each early return, exactly as the example does.

Debugging tips

Compiler warnings first. Build with -Wall -Wextra. GCC/Clang warn about ignored results for functions marked warn_unused_result (many libc I/O calls are). Add -Werror in CI so an unchecked return breaks the build instead of shipping.

Common compiler errors

  • implicit declaration of strerror/errno → you forgot #include <string.h> / #include <errno.h>.
  • comparison of pointer to integer → you wrote if (fopen(...) == -1); pointers fail as NULL, not -1.

Common runtime errors

  • Segfault right after an allocation or open → the call failed, returned NULL, and you used it. Print the pointer or run under a debugger and check whether it's 0x0.
  • Bad file descriptor / EBADF → you used a descriptor after it was closed, or after open returned -1.
  • errno shows the wrong reason → another call overwrote it before you read it; capture it immediately.

Concrete steps

  1. Reproduce the failure deliberately: point the program at a missing file, a read-only path, or use a tool to force malloc to fail. Code that is only ever tested on the happy path hides its bugs.
  2. Run under a debugger: gdb ./prog, then run; on a crash, bt shows the stack and the offending line.
  3. Use sanitizers: compile with -fsanitize=address,undefined. AddressSanitizer catches use-after-free and null derefs with the exact line; UBSan flags undefined operations.
  4. Trace errno: log strerror(errno) at each failure so you can see why, not just that, something failed.

Questions to ask when it doesn't work: Did I check this call's return value? Am I comparing against the documented failure value? Is errno still fresh when I read it? On this error path, did I release everything I acquired? Does the failure path leave me in a safe (closed) state?

Memory safety

Error handling and memory safety are the same discipline seen from two angles — a skipped check is usually also an undefined-behaviour bug.

  • Null dereference (CWE-476). Unchecked malloc/fopen/realloc returning NULL, then used, is undefined behaviour and a reliable crash an attacker can trigger by exhausting memory. Always check before use.
  • realloc aliasing. p = realloc(p, n); leaks the original block if realloc returns NULL. Use a temporary: void *t = realloc(p, n); if (!t) { /* p still valid, free it */ } else p = t;.
  • Resource leaks on error paths. Every early return after acquiring a file, socket, lock, or buffer must release it. Leaked descriptors and memory are a denial-of-service vector under repeated failures. A single goto cleanup; label keeps this correct.
  • Uninitialised / partially filled buffers. After a short or failed read, the untouched bytes are garbage. Never treat the whole buffer as valid — use the returned length, and don't print or parse past it.
  • errno is only meaningful after a failure. It is not cleared on success; reading it when a call succeeded gives a stale value. Check the primary return first, then consult errno.

Defensive practices (security-critical code).

  • Fail closed on any error in an authorization, validation, or cryptographic check — treat "couldn't decide" as "deny".
  • Validate inputs at the boundary (as read_file does with its NULL/0 checks) so bad data never reaches the vulnerable core.
  • Least information out: generic messages + a correlation id to the user; full detail only in a protected log (CWE-209). Never echo paths, errno strings, addresses, or internal identifiers to an untrusted party.
  • Don't let error handling itself widen the attack surface: an error handler that logs attacker-controlled data unsanitised can enable log injection; treat logged strings as untrusted too.

Real-world uses

Where this shows up. The OpenSSH server checks the return of every authentication and crypto call and denies on any failure — a textbook fail-closed design. TLS libraries reject a connection when certificate verification returns an error rather than proceeding (historically, failing open here caused real CVEs where invalid certificates were accepted). Web servers and databases return a generic 500 Internal Server Error to clients while writing the stack trace and query to a private log. Embedded and OS kernel code checks every allocation because on those systems a crash can brick a device.

Professional best-practice habits

Beginner:

  • Check the return value of every allocation and I/O call before using the result.
  • Compare against the documented failure value (NULL, -1, EOF), not just "truthy/falsy".
  • On failure, stop and signal failure upward (return a code) instead of continuing.
  • Free/close what you opened, including on error paths.
  • Read errno immediately after the failing call.

Advanced:

  • Centralise cleanup with goto cleanup; so every path releases resources exactly once.
  • Separate a user-facing message from a logged diagnostic, tied together by a correlation id.
  • Turn unchecked results into build failures with -Wall -Wextra -Werror and __attribute__((warn_unused_result)) on your own fallible functions.
  • Test the failure paths deliberately (fault injection, LD_PRELOAD malloc failures, sanitizers) — happy-path tests never exercise the code most likely to be wrong.
  • Establish a project-wide error convention (e.g. 0 success / negative errno-style codes) so callers handle failures uniformly.

Practice tasks

1. (Beginner) Checked open. Write a program that takes a filename on the command line, opens it with fopen, and if that fails prints a generic message to stdout ("Cannot open file.") and a detailed one to stderr using strerror(errno), then exits with a non-zero status. On success, print "Opened OK" and fclose it. Concepts: return-value checking, errno, fail closed, cleanup. Hint: capture errno right after fopen.

2. (Beginner) Three-way read result. Write void classify(int fd) that calls read(fd, buf, sizeof buf) once and prints exactly one of error, eof, or read N bytes by comparing the result against < 0, == 0, and > 0. Do not use truthiness. Concepts: distinguishing error / EOF / data. Constraint: one read call, three explicit branches.

3. (Intermediate) Fail-closed authorizer. Implement int authorize(int lookup_result) where the caller's lookup_result is 1 (allowed), 0 (denied), or -1 (lookup failed). Return 1 only for an explicit 1; return 0 for both 0 and -1. Add a main that prints GRANT/DENY for each of the three inputs. Example: input -1 → output DENY. Concepts: fail closed, handling the error branch. Hint: one == 1 test decides everything.

4. (Intermediate) Leak-free multi-step. Write a function that (a) mallocs buffer A, (b) mallocs buffer B, (c) opens a file. If any step fails, release everything already acquired and return -1; otherwise do trivial work and clean up. Implement it twice — once with early returns, once with a single goto cleanup; — and note which is easier to keep correct. Concepts: cleanup on every path, resource ownership. Hint: initialise pointers/FILE* to NULL and only free non-NULL ones at the label.

5. (Challenge) Log-vs-user error boundary. Write a tiny "request handler" int handle(const char *path) that reads a file (reuse the lesson's pattern) and, on any failure, writes a detailed line to a log file app.log (open with "a") — including the path and strerror(errno) and a short random-ish reference code — while returning to its caller only a generic failure. The caller prints "Error (ref: XXXX)" to the user using the same reference code, so an operator can match the user's report to the log line. Ensure the log file is always closed and no internal detail reaches stdout. Concepts: information-leak prevention (CWE-209), correlation ids, layered messaging, cleanup. Constraint: stdout must never contain a path or an errno string. Hint: generate the reference code once, use it in both places.

Summary

Secure error handling rests on three habits. Check every return value — allocations, I/O, and especially system calls fail, and C won't warn you; compare against the documented failure value (NULL, -1, EOF), and read errno immediately for the reason. Fail closed — when a security-relevant check errors, treat "couldn't decide" as "deny", never as "allow"; watch for the truthiness bug where a -1 error code is mistaken for success. Don't leak internals — send terse, generic messages to users and rich detail to a private log, tied together by a correlation id (CWE-209). Underneath all three sits ordinary memory hygiene: free and close everything on every exit path, don't dereference a possibly-null pointer, and don't trust the unwritten part of a short-read buffer. The most common cause of real vulnerabilities is testing only the happy path — so exercise the failure paths on purpose, with -Wall -Wextra -Werror and sanitizers, and make the safe default the one that runs when something goes wrong.

Practice with these exercises