C / STRINGS AND BUFFER SAFETY
Buffer overflows and how they become exploits
Trace what an unchecked copy actually overwrites, why one stray byte can flip an authorisation flag or a code pointer, and where canaries and NX stop helping.
What you will learn
- Work out what sits after a buffer with sizeof and offsetof before judging an overflow
- Explain how one overwritten flag byte becomes privilege escalation with no shellcode
- Spot the one-byte off-by-one hidden in buf[len] = '\0'
- Say what stack canaries, ASLR and NX each stop, and what they leave open
Understanding Buffer overflows and how they become exploits
A copy like strcpy(s->user, name) compiles to a byte loop that stops when it finds a NUL in the source, and nothing in the emitted code knows that user is eight bytes long: the array decayed to a bare address the moment it was passed, and the size survived only in the type at the call site. The standard requires no runtime check, so the loop keeps storing bytes into whatever the ABI placed after those eight. That is why an overflow is not "the extra characters are lost" -- the extra characters are stored, at addresses you did not choose, and the program then reads those neighbouring values as if you had assigned them yourself.
An attacker picks two things: how far past the end the write reaches, and which bytes land there. Both matter because the interesting targets sit at fixed offsets from the buffer -- an authorisation flag a few bytes on, a length or size field, a function pointer, the saved return address at the top of the frame. Overwriting a flag is the cheap exploit and needs no machine code at all, because the program's own if statement reads the corrupted byte and takes the privileged branch. Overwriting a code pointer is the expensive version: NX makes stack pages non-executable, so real exploits do not execute bytes from the buffer, they aim execution at code already mapped in the process, which makes the address bytes rather than the payload the hard part.
Mitigations each break one link of that chain and none of them removes the bug. A stack canary is a random word placed between the locals and the saved return address and checked on return, so it catches a linear smash that reaches the return address but never sees a write that stays inside a struct -- the is_admin example defeats it without trying. ASLR takes away hardcoded addresses, NX takes away executable payloads, and _FORTIFY_SOURCE only fires when the compiler can see the destination's size, which it cannot once the buffer arrives as a pointer parameter. The bug is gone only when the write is bounded by a limit derived from the destination object itself, with the terminator counted inside that limit.
<stdio.h>
<stddef.h>
<string.h>
struct session {
char user[8];
int is_admin; /* 0 = ordinary user */
};
static void login(struct session *s, const char *name)
{
s->is_admin = 0;
strcpy(s->user, name); /* no bound anywhere: this is the bug */
}
int main(void)
{
struct session s;
char attack[] = "AAAAAAAABBB"; /* 11 chars + NUL = 12 bytes */
printf("sizeof(struct session) = %zu\n", sizeof s);
printf("is_admin lives %zu bytes after user\n",
offsetof(struct session, is_admin) - offsetof(struct session, user));
login(&s, "alice");
printf("after \"alice\": user=%-11s is_admin=%d\n", s.user, s.is_admin);
login(&s, attack);
printf("after attack: user=%-11s is_admin=%d\n", s.user, s.is_admin);
if (s.is_admin)
puts("access granted: nobody assigned that flag, the copy overwrote it");
return 0;
}
A buffer's end is not a barrier: an overflow is an attacker-chosen write into whatever the ABI put next, and that neighbour decides whether the bug is a crash or a privilege escalation.
Worked examples
Overwriting the function pointer that sits after the buffer
Shows the byte-level state an overflow of label leaves behind, and that the struct's own function pointer then sends control somewhere the program never chose.
<stdio.h>
<stddef.h>
<string.h>
static void show_menu(void) { puts("menu: 1) list 2) quit"); }
static void grant_shell(void) { puts("grant_shell() ran: control flow was redirected"); }
struct widget {
char label[8];
void (*render)(void);
};
int main(void)
{
struct widget w;
unsigned char payload[sizeof w];
void (*target)(void) = grant_shell;
w.render = show_menu;
w.render();
memset(payload, 'X', sizeof payload);
memcpy(payload + offsetof(struct widget, render), &target, sizeof target);
memcpy(&w, payload, sizeof w); /* the state an unchecked copy into label leaves */
w.render();
printf("label bytes are now %.8s\n", w.label);
return 0;
}
Example explained
Line 1w.render = show_menu puts a real code address eight bytes past label, so those bytes are a value the program will later jump through.
Line 2The memcpy at offsetof(struct widget, render) places the address bytes of grant_shell exactly where a copy running off the end of label would put input bytes.
Line 3memcpy(&w, payload, sizeof w) writes the whole object, so this program is defined behaviour while reproducing byte for byte what the overflow would leave.
Line 4The second w.render() call reaches grant_shell without a single injected instruction, which is why an executable-stack defence such as NX would not stop it.
The one-byte off-by-one that zeroes the next field
Demonstrates that a single stray NUL terminator, written one index past an array, silently resets the field behind it.
<stdio.h>
<string.h>
struct record {
char name[8];
unsigned char quota; /* alignment 1, so it is the byte at offset 8 */
};
static void set_name(struct record *r, const char *src, size_t len)
{
memcpy(r->name, src, len);
r->name[len] = '\0'; /* index 8 when len is 8: one byte too far */
}
int main(void)
{
struct record r;
r.quota = 5;
set_name(&r, "abcdefg", 7);
printf("len 7: name=%.8s quota=%u\n", r.name, r.quota);
r.quota = 5;
set_name(&r, "abcdefgh", 8);
printf("len 8: name=%.8s quota=%u\n", r.name, r.quota);
return 0;
}
Example explained
Line 1unsigned char needs no padding before it, so quota occupies offset 8, the first byte after name, with nothing in between to absorb the mistake.
Line 2memcpy(r->name, src, len) is in bounds for len 8; the copy is not the bug, the terminator is.
Line 3r->name[len] = '\0' with len 8 stores a zero at index 8, and quota therefore reads back as 0 rather than 5.
Line 4%.8s bounds the read at eight characters, which is why the len 8 name still prints in full even though no terminator fits inside name.
Important notes
Overflow behaviour is undefined, not merely machine-dependent: the numbers above are what x86-64 System V little-endian produces at -O0, while gcc -O2 with glibc's _FORTIFY_SOURCE may abort the same program instead, and an optimiser is entitled to discard code it can prove overflows.
C fixes the layout of struct members but not of separate local variables, so overflowing one local into another is unpredictable and can even appear to run backwards; that is why these examples corrupt neighbouring fields, whose offsets the ABI does pin down.
Common mistakes
Treating a clean run as proof of safety: an overflow that happens to land in padding prints correct results until a field reorder or an optimisation change makes the same input corrupt a live pointer, and the crash then surfaces in unrelated code far from the copy.
Deriving the copy length from the source, as in memcpy(buf, src, strlen(src) + 1): the attacker supplies the bound, so the code looks length-aware while limiting nothing.
Budgeting for the characters but not the terminator: filling all sizeof buf bytes and then writing buf[sizeof buf] = '\0' zeroes the next field, and a zeroed length or flag byte is frequently exactly what an exploit needs.
Try it yourself
Change, predict, then run
Using the session struct, find by experiment the shortest attack string that leaves is_admin nonzero, then insert char pad[4]; between user and is_admin and report both the new shortest string and why the vulnerability itself has not changed.
Open the C workspaceCheck your understanding
A service copies attacker-controlled input into char user[8] inside a struct whose next member is int is_admin, and the binary is built with -fstack-protector-strong, ASLR and NX. Why can an attacker still become admin?
- The overflow only has to reach the adjacent is_admin field, and the canary guards the saved return address, not data sitting in front of it
- ASLR does not randomise stack addresses, so the attacker can compute where is_admin lives and write to it directly
- NX only applies to heap pages, so the bytes the attacker placed in user[] still execute as code
- The canary is verified only when the process exits, so nothing notices the corrupted flag during the run
Show answer
The canary sits between the locals and the saved return address and is checked on return, so a write that stops inside the struct never crosses it; is_admin is corrupted in place and the program's own check reads the new value long before any return happens. Option 3 is tempting because NX is a real defence, but this attack executes no injected bytes at all -- it is pure data corruption, which is precisely why NX and ASLR are irrelevant to it.