Secure Coding in C · intermediate · ~10 min
- 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.
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.
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.
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), doessizeof(dst)give the destination's capacity? Why or why not?
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?
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 - startsafer thanstart + len <= totalwhenlenandstartare attacker-controlled?
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.
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 isn, and what doesbcontain?
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 */ }
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.
Every function that writes into a buffer needs two things:
The function must compare the data size against the capacity before writing.
Well-designed C functions follow a recognizable pattern:
int copy_into(char *dst, size_t cap, const char *src), returning 0 on success or -1 on failure.'\0'). A buffer of capacity cap can hold at most cap - 1 characters of text plus the terminator.#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.
in_bounds returns the two-part test idx >= 0 && idx < n. Short-circuit && means a negative idx fails immediately without evaluating the second half.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.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.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.| 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 |
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.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.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.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';.
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:
< or <=? Should it be <?sizeof on a real array (good) or on a pointer parameter (bug)?start + len overflow? Did I use the total - start form?'\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.
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.
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:
sizeof inside a function that received a pointer.i >= 0 && i < n, loop with < not <=.snprintf for string building and check its return value; size string buffers to text length + 1.Advanced:
{ptr, len} together in a struct (a "span"/"slice") so size is impossible to drop.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.
idx >= 0 && idx < n check; do not read a[idx] unless it is valid.{7,8,9}, idx=1 → returns 1, *out=8; idx=3 → returns 0.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.
snprintf (or manual copy with a reserved terminator byte).cap=6, src="hi" → dst="hi", returns 0; src="toolong" → returns -1.(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.
start + len anywhere; reject negatives.range_ok(4,8,16)=1, range_ok(12,8,16)=0, range_ok(2, INT_MAX, 100)=0.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.
range_ok before any copy; treat every argument as untrusted.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.
total; use range_ok for each record; advance a running offset; handle a truncated final record gracefully.03 41 42 43 02 44 45 → prints ABC then DE; bytes 03 41 42 → reports "truncated record at offset 0".range_ok(offset+1, declared_len, total) before reading the payload. Concepts: all four — capacity, index check, range check, overflow safety.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.