Secure Coding in C · intermediate · ~10 min

Bounds checking everywhere

- Build the habit of passing a buffer's **capacity** alongside every buffer pointer. - Write the canonical index check (`idx >= 0 && idx < n`) and understand why both halves matter. - Validate an **offset-plus-length** sub-range before reading or writing a slice. - Reserve one byte for the NUL terminator when the buffer holds a C string. - Prefer capacity-aware APIs like `snprintf` and detect truncation from their return value. - Recognise integer overflow and signed/unsigned traps that silently defeat a bounds check.

Overview

In most modern languages, if you ask for element 100 of a 10-element array, the program stops with a clear error. C makes no such promise. It hands you a raw pointer and trusts you completely: read or write wherever you point, and the compiler will not object.

That trust is powerful and dangerous. Bounds checking is the discipline of asking one question before every buffer access: does what I'm about to touch actually fit inside the memory I own? If the answer is no, you stop — you do not write.

This lesson builds directly on Buffer overflow basics. There you saw what happens when a write runs past the end of a buffer: corrupted neighbours, crashes, and hijackable control flow. Here you learn the everyday habits that prevent it: carry the size with the buffer, compare before you write, and leave room for the terminator. In plain terms, bounds checking is looking before you leap. In precise terms, it is verifying that every index and every range stays within [0, capacity) before it is used to address memory.

Why it matters

Missing bounds checks are not a stylistic wart — they are the single most common root cause of memory-corruption vulnerabilities in C code. Out-of-bounds reads leak secrets (Heartbleed leaked private keys by reading past a buffer); out-of-bounds writes corrupt adjacent data and, in the worst case, let an attacker overwrite a return address and run their own code.

Because C gives you no safety net, the only thing standing between correct input and catastrophe is the check you wrote by hand. A single unchecked array[i] = x in a parser, a network handler, or a file reader can turn a malformed input into a remote exploit. Getting bounds checking into your fingertips — so you write it automatically, every time — is one of the highest-leverage habits in defensive C programming.

Core concepts

1. The capacity-with-the-pointer rule

Definition. A raw pointer carries no size information. To use a buffer safely, the code that writes to it must also know its capacity — so capacity must travel with the pointer, as a separate argument or a struct field.

Plain explanation. char *buf tells you where the buffer starts but says nothing about how big it is. sizeof(buf) inside a function returns the size of the pointer (typically 8 bytes), not the buffer. So the caller must pass the real size explicitly.

How it works internally. An array like char line[64] is a contiguous block of 64 bytes. When you pass it to a function, it decays to a pointer to its first element; the length is lost at the call boundary. The function only knows what you tell it.

char line[64];

  line ─┐
        v
        +----+----+----+-- ... --+----+
        | b0 | b1 | b2 |         | b63|   64 bytes total
        +----+----+----+-- ... --+----+
        ^                             ^
        valid indices 0 .............. 63
        writing line[64] or beyond = out of bounds

Inside f(char *p): sizeof(p) == 8 (pointer size), NOT 64.
The 64 must be passed as a separate argument.

When to use / not use. Always pass capacity for any buffer a function writes into. You can skip it only for fixed compile-time work fully contained in one scope where sizeof(array) still sees the true array type — and even then, being explicit is safer.

Pitfall. Calling sizeof on a pointer parameter and believing it is the buffer size. It is not.

Knowledge check: Inside void copy(char *dst, const char *src), does sizeof(dst) give the destination's capacity? Why or why not?

2. The canonical index check

Definition. Before using i to index an n-element array, verify i >= 0 && i < n.

Plain explanation. Valid indices run from 0 up to n - 1. Index n is one past the end. A negative index reaches before the buffer. Both are undefined behaviour.

How it works internally. array[i] compiles to *(array + i) — it computes an address by adding i elements to the base. There is no runtime check; if i is 500 for a 10-element array, the CPU cheerfully reads or writes that far-away address.

When to use / not use. Use it any time an index comes from outside the function — user input, a file, a network packet, arithmetic on untrusted values. You may omit it for a for (i = 0; i < n; i++) loop where you fully control the bound, though writing it as < n (never <= n) is exactly the check.

Pitfall. Writing i <= n instead of i < n. The off-by-one lets you touch element n, one past the last valid slot.

Check Reads/writes index... Verdict
i < n 0 .. n-1 correct
i <= n 0 .. n off-by-one overflow
i < n only (no i >= 0) with signed i negative i slips through under-read possible

Knowledge check (find the bug): for (int i = 0; i <= len; i++) buf[i] = src[i]; — what goes wrong, and on which iteration?

3. Range checks: offset plus length

Definition. Before touching len bytes starting at start in a total-byte buffer, verify the whole slice fits: start >= 0 && len >= 0 && start <= total && len <= total - start.

Plain explanation. Length-prefixed formats (network protocols, file headers) tell you "read N bytes from offset K." If you trust N and K without checking, a malicious N can walk right off the end.

How it works internally. The naive test start + len <= total looks right but can overflow: if start + len wraps around past the maximum integer value, the sum becomes small and the check passes falsely. The overflow-safe form rearranges the algebra so nothing can wrap: compare len against total - start, having first confirmed start <= total.

buffer: [0 .................................. total)
                 start ->|<---- len ---->|
                         |               |
         valid slice iff: start <= total  AND  len <= total - start

  Attack: start = 4, len = 0xFFFFFFF0 (huge)
    naive:  start + len  --> wraps to a tiny number --> check PASSES (BUG)
    safe:   len <= total - start --> huge > small --> check FAILS (good)

When to use / not use. Use it whenever a length or offset arrives from data you did not create. In fully internal, provably-bounded arithmetic you can relax, but parsers should always use the safe form.

Pitfall. Using start + len in the comparison and letting the addition overflow.

Knowledge check (explain in your own words): Why is len <= total - start safer than start + len <= total when len and start are attacker-controlled?

4. The NUL terminator reservation

Definition. A C string of visible length L needs L + 1 bytes: L characters plus the terminating '\0'. A buffer of capacity cap holds at most cap - 1 characters of text.

Plain explanation. String functions find the end of a string by scanning for '\0'. If you fill all cap bytes with characters and leave no room for the terminator, every later strlen/printf("%s") runs off the end looking for a zero that isn't there.

How it works internally. "hi" occupies 3 bytes: 'h', 'i', '\0'. Functions like strcpy copy up to and including the '\0'. snprintf always writes a terminator within the given size (as long as size > 0), truncating the text if needed.

cap = 4, text "hi":
  +----+----+----+----+
  | h  | i  | \0 | ?? |   ok: 2 chars + terminator, 1 byte spare
  +----+----+----+----+

cap = 4, text "data" written with a naive copy:
  +----+----+----+----+
  | d  | a  | t  | a  |   NO room for \0 -> reads run past the end
  +----+----+----+----+

When to use / not use. Any time a buffer holds a C string. Not relevant for raw byte buffers you never treat as strings.

Pitfall. Sizing a string buffer to exactly the text length, forgetting the extra byte.

5. Capacity-aware APIs and truncation reporting

Definition. Prefer functions that take a destination size and tell you whether the result fit. snprintf(dst, cap, fmt, ...) never writes past cap and returns how many characters it would have written.

Plain explanation. snprintf returns the length the full result needed (excluding the terminator). If that return value is >= cap, the output was truncated — a signal you must check, not ignore.

Function Bounds the write? Signals truncation? Notes
strcpy no no never use with untrusted length
strncpy yes (n bytes) no, and may not terminate leaves buffer un-terminated if src is long
snprintf yes yes (return >= cap) always terminates when cap > 0

Pitfall. Assuming snprintf's return value is the number of bytes written. It is the number needed; on truncation it exceeds cap - 1.

Knowledge check (predict the output): char b[4]; int n = snprintf(b, sizeof b, "%s", "hello"); — what is n, and what does b contain?

Syntax notes

The two building-block checks, written as small reusable helpers:

#include <stddef.h>   /* size_t */

/* Is idx a valid index into an n-element array? */
int in_bounds(int idx, int n) {
    return idx >= 0 && idx < n;   /* BOTH ends: not negative, not past the last slot */
}

/* Does reading len bytes from offset start fit within total bytes? */
int range_ok(int start, int len, int total) {
    if (start < 0 || len < 0 || total < 0) return 0;  /* reject negatives first */
    if (start > total) return 0;                      /* offset itself past the end */
    return len <= total - start;                      /* overflow-safe: no start+len */
}

Capacity-aware string building:

/* snprintf writes at most cap bytes (including the '\0') and returns the
   number of characters it WOULD have produced, excluding the terminator. */
int n = snprintf(out, cap, "%s/%s", a, b);
if (n < 0 || (size_t)n >= cap) { /* encoding error, or truncated */ }

Lesson

Bounds checking is manual in C

C does not check array or buffer sizes for you. There is no automatic guard that stops a write from running past the end of a buffer.

That work is yours. Bounds checking means comparing how much you are about to write against how much space you actually have, and refusing to write if it would not fit.

The core rule

Every function that writes into a buffer needs two things:

  • The buffer itself.
  • The buffer's capacity (how many bytes it can hold).

The function must compare the data size against the capacity before writing.

Conventional API shapes

Well-designed C functions follow a recognizable pattern:

  • Pass the capacity next to the destination pointer. For example: int copy_into(char *dst, size_t cap, const char *src), returning 0 on success or -1 on failure.
  • For C strings, always reserve one byte for the NUL terminator ('\0'). A buffer of capacity cap can hold at most cap - 1 characters of text plus the terminator.

Code examples

#include <stdio.h>
#include <stddef.h>
#include <string.h>

/* Valid index into an n-element array: not negative, not past the last slot. */
int in_bounds(int idx, int n) {
    return idx >= 0 && idx < n;
}

/* Does reading `len` bytes starting at `start` fit inside `total` bytes?
   Written to avoid start+len overflow by comparing len against total-start. */
int range_ok(int start, int len, int total) {
    if (start < 0 || len < 0 || total < 0) return 0;
    if (start > total) return 0;
    return len <= total - start;
}

/* Join a and b as "a/b" into out (capacity cap). Returns 0 on success,
   -1 if the result did not fit (truncation) or an encoding error occurred. */
int safe_join(char *out, size_t cap, const char *a, const char *b) {
    int n = snprintf(out, cap, "%s/%s", a, b);
    if (n < 0 || (size_t)n >= cap) return -1;   /* would-be length >= cap means truncated */
    return 0;
}

int main(void) {
    /* 1. Index check guarding an array access. */
    int data[5] = { 10, 20, 30, 40, 50 };
    int n = (int)(sizeof data / sizeof data[0]);
    int wanted[] = { 2, 5, -1 };   /* 5 and -1 are out of range on purpose */

    for (int k = 0; k < 3; k++) {
        int idx = wanted[k];
        if (in_bounds(idx, n))
            printf("data[%d] = %d\n", idx, data[idx]);
        else
            printf("index %d rejected (valid range 0..%d)\n", idx, n - 1);
    }

    /* 2. Range check before slicing a length-prefixed field. */
    int total = 16;
    printf("range(4, 8, %d)  = %d\n", total, range_ok(4, 8, total));   /* fits */
    printf("range(12, 8, %d) = %d\n", total, range_ok(12, 8, total));  /* runs off end */

    /* 3. Bounded string build with truncation detection. */
    char path[12];
    if (safe_join(path, sizeof path, "usr", "local") == 0)
        printf("joined: %s\n", path);
    else
        printf("join truncated\n");

    char tiny[4];
    if (safe_join(tiny, sizeof tiny, "usr", "local") == 0)
        printf("joined: %s\n", tiny);
    else
        printf("join into tiny[4] truncated (as expected)\n");

    return 0;
}

What it does. It demonstrates the three habits: an index check that accepts 2 but rejects 5 and -1; a range check that accepts a slice fitting in 16 bytes but rejects one starting at offset 12 with length 8; and a bounded join that succeeds into a roomy buffer but reports truncation into a 4-byte one.

Expected output:

data[2] = 30
index 5 rejected (valid range 0..4)
index -1 rejected (valid range 0..4)
range(4, 8, 16)  = 1
range(12, 8, 16) = 0
joined: usr/local
join into tiny[4] truncated (as expected)

Edge cases. range_ok(16, 0, 16) returns 1 — a zero-length slice at the exact end is valid. range_ok(0, 0, 0) returns 1. Passing cap == 0 to snprintf writes nothing (not even a terminator), so safe_join would correctly report failure since the return value still exceeds 0.

Line by line

  1. in_bounds returns the two-part test idx >= 0 && idx < n. Short-circuit && means a negative idx fails immediately without evaluating the second half.
  2. range_ok first rejects any negative input — a defensive gate so later arithmetic works on non-negative numbers. Then it rejects start > total (the offset alone is past the end). Finally len <= total - start: because start <= total is already guaranteed, total - start is non-negative and cannot underflow, and we never form the potentially-overflowing sum start + len.
  3. safe_join calls snprintf, which writes at most cap bytes including the terminator and returns the length the full text needed. The guard (size_t)n >= cap detects truncation; n < 0 catches a rare encoding error.
  4. In main, n is computed as sizeof data / sizeof data[0] = 20 / 4 = 5 — the reliable way to get an array's element count while the true array type is still visible.
  5. The loop trace:
k idx in_bounds(idx,5) action
0 2 2>=0 && 2<5 → true prints data[2] = 30
1 5 5<5 → false prints rejection
2 -1 -1>=0 → false prints rejection
  1. range_ok(4, 8, 16): start=4 <= 16, and 8 <= 16 - 4 = 12 → true. range_ok(12, 8, 16): 12 <= 16, but 8 <= 16 - 12 = 4 is false → 0.
  2. safe_join(path, 12, "usr", "local"): the result "usr/local" is 9 characters; snprintf returns 9, which is < 12, so success and path holds usr/local plus a terminator.
  3. safe_join(tiny, 4, ...): the full text needs 9 characters, so snprintf returns 9; 9 >= 4 is true, so the function returns -1 and we print the truncation message. tiny itself contains a safely-terminated fragment ("usr"), never an overflow.

Common mistakes

Mistake 1 — trusting sizeof on a pointer parameter.

void copy_in(char *dst, const char *src) {
    strncpy(dst, src, sizeof(dst));   // WRONG: sizeof(dst) is 8, the pointer size
}

Why it's wrong: inside the function dst is a pointer, so sizeof(dst) is the pointer width (usually 8), not the buffer's capacity. Corrected — pass the capacity:

void copy_in(char *dst, size_t cap, const char *src) {
    snprintf(dst, cap, "%s", src);
}

Recognise it: any sizeof applied to a parameter that is a pointer is a red flag.

Mistake 2 — the <= off-by-one.

for (int i = 0; i <= n; i++) buf[i] = 0;   // WRONG: writes buf[n], one past the end

Corrected: use i < n. Recognise it: loops that touch array[n]; a crash or corruption that only appears on the last iteration.

Mistake 3 — overflow in the range check.

if (start + len <= total) read_slice(start, len);   // WRONG: start+len can wrap

Corrected: if (start <= total && len <= total - start). Recognise it: any bounds test that adds two untrusted numbers before comparing.

Mistake 4 — forgetting the NUL byte.

char name[5];
strcpy(name, "hello");   // WRONG: 5 chars + '\0' = 6 bytes into a 5-byte buffer

Corrected: size the buffer to at least length + 1, and use snprintf(name, sizeof name, "%s", src) so it always terminates. Recognise it: buffers sized to the exact text length; garbage printed after a string.

Mistake 5 — treating strncpy as safe.

char b[4];
strncpy(b, "data", sizeof b);   // fills all 4 bytes, leaves NO terminator
printf("%s", b);                 // reads past the end

Corrected: use snprintf, or after strncpy force b[sizeof b - 1] = '\0';.

Debugging tips

Compiler errors and warnings. Turn on -Wall -Wextra. GCC and Clang can warn array subscript is above array bounds and, with -Warray-bounds, catch some constant out-of-range indices at compile time. -Wsign-compare flags mixing signed and unsigned in comparisons — a common source of broken checks.

Runtime detection. Build with AddressSanitizer: gcc -fsanitize=address -g prog.c && ./a.out. On any out-of-bounds read or write it prints the exact line, the offending address, and whether it was a heap/stack/global overflow. This is the fastest way to see a bounds bug. valgrind ./a.out catches many heap overreads too.

Logic errors — questions to ask when it misbehaves:

  • Is my loop condition < or <=? Should it be <?
  • Where did this index/length come from — did I check it at the boundary where untrusted data entered?
  • Am I calling sizeof on a real array (good) or on a pointer parameter (bug)?
  • Could start + len overflow? Did I use the total - start form?
  • Did I leave a byte for '\0', and does the buffer end up terminated?

A practical technique. When a value crosses a trust boundary (function argument from a parser, a field from a packet), add a check right there and, temporarily, an assert(in_bounds(i, n)); — a failing assertion pinpoints the first bad index far more clearly than a distant crash.

Memory safety

This lesson is memory safety, so the concerns are the whole point.

Out-of-bounds write (the severe one). buf[i] = x with i past the end corrupts adjacent memory. On the stack this can overwrite a saved return address — the classic path from a bounds bug to arbitrary code execution (see Buffer overflow basics). Defence: the index check before every write sourced from untrusted data.

Out-of-bounds read. Reading past a buffer leaks whatever bytes happen to be adjacent — potentially passwords or keys. Heartbleed was exactly this: a length field trusted without a range check. Defence: range_ok-style validation on every length that arrives as data.

Signed/unsigned confusion. Sizes are usually size_t (unsigned). If a signed length goes negative and is compared against or converted to size_t, it becomes a huge positive number and sails past len < cap. Defence: reject negatives before any unsigned comparison, or keep lengths in signed types until validated.

Integer overflow. start + len, count * size, or len + 1 can wrap on 32-bit values, producing a small result that passes a naive check. Defence: rearrange to subtraction (len <= total - start), or check before multiplying (if (count > SIZE_MAX / size) fail;).

Initialisation and lifetime. A bounds check protects the range, but reading a slice of an uninitialised buffer still yields garbage, and indexing a buffer after it is freed is use-after-free. Bounds checking is necessary, not sufficient — pair it with proper initialisation and lifetime discipline.

Defensive posture. Validate untrusted lengths and offsets at the boundary; prefer capacity-aware APIs (snprintf) over unbounded ones (strcpy, gets — never use gets); treat every external number as hostile until checked.

Real-world uses

Concrete use cases. Network protocol parsers (TLS, HTTP/2, DNS) are almost entirely length-prefixed fields — every one needs a range_ok-style check before the payload is read; the Heartbleed vulnerability was a missing one. File-format loaders (image, font, video decoders) validate declared dimensions and chunk sizes against the actual data length. Operating-system kernels bounds-check every pointer and length crossing the syscall boundary from user space, because a single miss is a privilege-escalation bug. Embedded firmware bounds-checks sensor and radio buffers where there is no OS to catch a fault.

Best-practice habits.

Beginner:

  • Pass capacity next to every buffer pointer; never rely on sizeof inside a function that received a pointer.
  • Write the index check as i >= 0 && i < n, loop with < not <=.
  • Use snprintf for string building and check its return value; size string buffers to text length + 1.

Advanced:

  • Centralise range validation in one audited helper so parsers can't each reinvent (and mis-write) it.
  • Use the overflow-safe subtraction form and add explicit multiplication-overflow guards before allocations.
  • Build tests and CI under AddressSanitizer/UBSan; fuzz parsers so malformed lengths are exercised automatically.
  • Prefer carrying {ptr, len} together in a struct (a "span"/"slice") so size is impossible to drop.

Practice tasks

Beginner 1 — Safe element getter. Write int get_at(const int *a, int n, int idx, int *out) that stores a[idx] into *out and returns 1 if idx is in bounds, else leaves *out untouched and returns 0.

  • Requirements: use the canonical idx >= 0 && idx < n check; do not read a[idx] unless it is valid.
  • Example: array {7,8,9}, idx=1 → returns 1, *out=8; idx=3 → returns 0.
  • Hint: check first, dereference second. Concepts: index check, output parameter.

Beginner 2 — Bounded copy. Write int bounded_copy(char *dst, size_t cap, const char *src) that copies src into dst, never writing past cap, always terminating, and returning 0 on success or -1 if src did not fit.

  • Requirements: use snprintf (or manual copy with a reserved terminator byte).
  • Example: cap=6, src="hi"dst="hi", returns 0; src="toolong" → returns -1.
  • Hint: (size_t)snprintf(...) >= cap means truncation. Concepts: capacity-aware API, NUL reservation.

Intermediate 1 — Range validator with overflow safety. Implement int range_ok(int start, int len, int total) (as in the lesson) and add a test harness that also passes start and len values large enough that start + len would overflow int, proving your subtraction form still rejects them.

  • Requirements: no start + len anywhere; reject negatives.
  • Example: range_ok(4,8,16)=1, range_ok(12,8,16)=0, range_ok(2, INT_MAX, 100)=0.
  • Hint: compare len to total - start only after confirming start <= total. Concepts: range check, integer overflow.

Intermediate 2 — Length-prefixed field reader. Given a byte buffer and a total length, write int read_field(const unsigned char *buf, int total, int start, int len, unsigned char *out) that copies len bytes from offset start into out only if the slice is fully in bounds; return 1 on success, 0 on rejection.

  • Requirements: validate with your range_ok before any copy; treat every argument as untrusted.
  • Example: total=16, start=4, len=8 → copies 8 bytes, returns 1; start=12,len=8 → returns 0.
  • Hint: this is the exact shape a real parser uses. Concepts: range check, defensive copy.

Challenge — Mini length-prefixed record parser. Parse a buffer of the form [1-byte length][that many payload bytes] repeated until the buffer ends. Write a function that walks the records, and for each one validates that the declared length does not run past the remaining bytes, printing each payload; on the first invalid length, stop and report the byte offset where parsing failed.

  • Requirements: never read past total; use range_ok for each record; advance a running offset; handle a truncated final record gracefully.
  • Input/output example: bytes 03 41 42 43 02 44 45 → prints ABC then DE; bytes 03 41 42 → reports "truncated record at offset 0".
  • Constraints: single pass, no fixed-size assumptions about record count.
  • Hint: at each step check range_ok(offset+1, declared_len, total) before reading the payload. Concepts: all four — capacity, index check, range check, overflow safety.

Summary

Bounds checking is manual in C: the language never checks array or buffer sizes for you, so every safe access is a check you wrote by hand.

Main concepts. Carry a buffer's capacity alongside its pointer (a bare pointer knows no size, and sizeof on a pointer parameter lies). Guard every index from untrusted data with idx >= 0 && idx < n. Validate every offset-plus-length slice with the overflow-safe len <= total - start form. Reserve one byte for '\0' in string buffers.

Most important syntax. snprintf(dst, cap, fmt, ...) bounds the write and returns the length it needed — a return >= cap means truncation, which you must check.

Common mistakes to avoid. sizeof on a pointer parameter; <= instead of <; start + len overflow; forgetting the terminator; trusting strncpy to terminate.

What to remember. Look before you leap: for every buffer write, know the capacity, check the range, and refuse to write when it would not fit. That habit — applied everywhere, not just where you expect trouble — is what keeps C programs memory-safe.

Practice with these exercises