C / UNDEFINED BEHAVIOUR AND DEFENSIVE C
Common security bugs: overflows and format strings
Spot and fix the two classic C memory-safety bugs: unbounded copies into fixed buffers, and printf calls whose format string comes from untrusted data.
What you will learn
- Bound copies with sizeof the destination, never with strlen of the input
- Detect truncation by comparing snprintf's return value against the buffer size
- Pass untrusted text as a %s argument, never as the format string itself
- Read %p, %s and %n in attacker data as leak, dereference and write primitives
Understanding Common security bugs: overflows and format strings
Both bug families start the same way: an operation trusts a size it cannot see. strcpy, strcat, sprintf and gets take no destination capacity at all, so the number of bytes written is chosen entirely by the input, and a 4000-byte name copied into char buf[64] writes 3937 bytes past the object, over saved registers, a return address, or whatever the compiler happened to place next. The mental model is that a bound must be derived from the destination, because the destination is the only thing whose size you actually know. Once an array is passed to a function it has decayed to a pointer, so that knowledge has to travel alongside it as an explicit size argument.
A format string is a small program and printf executes it. The variadic calling convention gives printf no way to know how many arguments it received or what types they had, so each conversion simply fetches the next argument slot the ABI defines, whether that is a register or a stack offset. If the format came from an attacker, %p %p %p reads slots that were never filled and prints what was left there (return addresses, canaries, heap pointers), %s treats such a leftover value as a char * and dereferences it, and %n treats it as an int * and writes through it. printf(user) does not print user; it lets the user pick the operations.
Both classes are undefined behaviour, which is why "it printed the right thing on my machine" proves nothing: the variable you overwrote may live in a register today, the padding you smashed may be unused until the next field is added, and a corrupted return address only surfaces when the function returns. That is why the fix has to be structural rather than empirical: every write takes its bound from its destination, and every format string is a literal in your source with runtime text moved behind %s. Those two rules are cheap enough to apply mechanically to every call site, which matters because you cannot review your way to confidence about which byte the optimiser put where.
<stdio.h>
<string.h>
/* Both strings arrived from outside the program. */
static const char *user_name = "Bartholomew";
static const char *user_note = "50% off: %s %s %n";
int main(void)
{
char label[8];
int n;
/* strcpy takes no size, so the input decides how much it writes. */
if (strlen(user_name) + 1 > sizeof label)
printf("strcpy would write %zu bytes; label holds %zu\n",
strlen(user_name) + 1, sizeof label);
/* snprintf bounds the write and reports the length it wanted. */
n = snprintf(label, sizeof label, "%s", user_name);
if (n < 0 || (size_t)n >= sizeof label)
printf("truncated: needed %d bytes, kept \"%s\"\n", n + 1, label);
/* The note is an argument, so its %s and %n are only bytes. */
printf("note as data: %s\n", user_note);
return 0;
}
A write's length must come from the destination, and a format string must come from your source code, never from data.
Worked examples
%n is a store, not a print
Shows the conversion that turns a format string bug into a memory write, used here with a real pointer so the behaviour is defined.
<stdio.h>
int main(void)
{
int written = -1;
printf("cost: %-8s|%n end\n", "42", &written);
printf("printf stored %d into written\n", written);
return 0;
}
Example explained
Line 1%-8s pads "42" out to eight columns, so exactly 15 bytes have been emitted when printf reaches %n.
Line 2%n emits nothing; it stores that running count through its int * argument, which is why an unexpected %n is a write and not merely a leak.
Line 3Here the pointer is genuine, but in printf(user) no pointer was ever passed, so printf writes through whatever value occupies that argument slot.
Line 4The second printf shows the effect is a real store to a local variable, visible after the call returns.
A missing terminator reads the next field
Demonstrates why strncpy without a terminator turns a bounded write into an unbounded read of adjacent memory.
<stdio.h>
<string.h>
int main(void)
{
char record[16];
memset(record, 0, sizeof record);
memcpy(record + 8, "SECRET", 7); /* the field after the name */
/* The source is exactly 8 bytes, so strncpy writes no terminator. */
strncpy(record, "ABCDEFGH", 8);
printf("name: [%s]\n", record);
printf("strlen(record) = %zu\n", strlen(record));
return 0;
}
Example explained
Line 1memcpy copies "SECRET" together with its NUL, so index 14 holds a zero byte and every read below stays inside record.
Line 2strncpy stops after 8 bytes and adds no NUL when the source is not shorter than the limit, so the name field runs straight into the next field.
Line 3%s and strlen both scan for the first zero byte, so they report the joined 14 bytes even though no write ever left the buffer.
Line 4Real neighbouring data rarely contains a convenient zero byte, and then the scan leaves the object entirely and the behaviour is undefined rather than just wrong.
Important notes
sizeof on a parameter declared char buf[64] yields the pointer size, not 64, because the array decays at the call boundary; the capacity must travel as its own argument.
-Wformat-security flags a non-literal format with no further arguments but stays quiet about printf(fmt, x), and glibc's refusal of %n in a writable format string needs -D_FORTIFY_SOURCE=2, is libc-specific, and still allows %p and %s leaks.
Common mistakes
Writing strncpy(dst, src, strlen(src)) or strncpy(dst, src, sizeof src): the bound comes from the source, so it overflows dst exactly as strcpy would.
Using snprintf's return value as bytes actually written and advancing a cursor by it; on truncation the cursor points past the end and the next write is out of bounds.
Assuming printf(msg) is safe because msg came from your own config file or log line, when %n makes it a memory write even if nobody sees the output.
Try it yourself
Change, predict, then run
Write copy_field(char *dst, size_t dstsz, const char *src) that uses snprintf and returns 1 when the input did not fit, then call it with a 40-character string and an 8-byte buffer and print both the truncated result and the flag. Then set char note[] = "%s%n" and print it with printf("%s\n", note) to confirm the four characters come out literally.
Open the C workspaceCheck your understanding
A program does: char buf[64]; snprintf(buf, sizeof buf, "%s", name); printf(buf); where name is user-controlled. The copy into buf is correctly bounded, so why is this still exploitable?
- snprintf may leave buf without a terminating NUL, so printf reads past the array
- Conversions inside name make printf fetch arguments that were never passed, so it can read stack slots or write through %n
- printf is unbuffered here, so another process can observe the string
- snprintf truncated the name, so printf prints a shortened string
Show answer
The overflow was fixed but the format string bug was not: printf parses whatever bytes ended up in buf, and each %p or %x reads an argument slot nothing was pushed into while %n writes through one, so a name of "%p%p%n" yields both a leak and a memory write. Option 3 describes a real truncation wart, but printing a shorter string does not let the attacker read or write memory; option 0 is false, because snprintf always terminates when the size is non-zero.