Case Study 36-1: Stack Canary in Action — GDB Trace Through Protected Code
Introduction
Reading about stack canaries is one thing. Watching one fire in a debugger is another. This case study traces through a complete GDB session: compiling a vulnerable function with -fstack-protector, disassembling to see the canary check assembly, running the program normally to see the canary stored and checked, and then triggering the stack overflow to see the protection respond.
The goal is to make the canary mechanism concrete and observable, not abstract. Security defenses only inspire confidence when you understand exactly what they do.
🔐 Security Note: This lab uses an intentionally vulnerable function compiled in a controlled educational environment. The procedure shown here is how security engineers verify that mitigations are working and understand their limits.
Setup: The Vulnerable Function
/* canary_demo.c */
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
void vulnerable(const char *input) {
char buf[32];
strcpy(buf, input);
printf("Processed: %s\n", buf);
}
int main(int argc, char **argv) {
if (argc < 2) {
fprintf(stderr, "Usage: %s <input>\n", argv[0]);
return 1;
}
vulnerable(argv[1]);
return 0;
}
Compile WITH stack protection (default on modern GCC):
gcc -g -fstack-protector-strong -o canary_demo canary_demo.c
checksec --file=canary_demo
# Stack: Canary found ✓
Phase 1: Disassembling the Protected Function
$ gdb -q canary_demo
(gdb) set disassembly-flavor intel
(gdb) disassemble vulnerable
Dump of assembler code for function vulnerable:
0x401196 <+0>: push rbp
0x401197 <+1>: mov rbp,rsp
0x40119a <+4>: sub rsp,0x40
; === CANARY PROLOGUE BEGIN ===
0x40119e <+8>: mov rax,QWORD PTR fs:0x28 ; read canary from TLS
0x4011a7 <+17>: mov QWORD PTR [rbp-0x8],rax ; store at rbp-8
0x4011ab <+21>: xor eax,eax ; clear rax (security)
; === CANARY PROLOGUE END ===
0x4011ad <+23>: mov QWORD PTR [rbp-0x38],rdi ; save input arg
0x4011b1 <+27>: mov rax,QWORD PTR [rbp-0x38] ; reload
0x4011b5 <+31>: mov rsi,rax ; strcpy src
0x4011b8 <+34>: lea rax,[rbp-0x28] ; buf at rbp-40 (size 32)
0x4011bc <+38>: mov rdi,rax ; strcpy dst
0x4011bf <+41>: call strcpy@plt
0x4011c4 <+46>: lea rax,[rbp-0x28]
0x4011c8 <+50>: mov rsi,rax
0x4011cb <+53>: lea rdi,[rip+0xe36] ; "Processed: %s\n"
0x4011d2 <+60>: mov eax,0x0
0x4011d7 <+65>: call printf@plt
; === CANARY EPILOGUE BEGIN ===
0x4011dc <+70>: mov rax,QWORD PTR [rbp-0x8] ; reload canary from stack
0x4011e0 <+74>: xor rax,QWORD PTR fs:0x28 ; XOR with original
0x4011e9 <+83>: je 0x4011f0 <vulnerable+90> ; if zero (ok), proceed
0x4011eb <+85>: call __stack_chk_fail@plt ; canary changed!
; === CANARY EPILOGUE END ===
0x4011f0 <+90>: leave
0x4011f1 <+91>: ret
Phase 2: Observing the Canary Value at Runtime
(gdb) break *0x4011a7 ; breakpoint just before canary store
(gdb) run HELLO
Breakpoint 1, 0x00000000004011a7 in vulnerable ()
(gdb) info registers rax
rax 0x4cd8b2e5f70a2300 ; ← This IS the canary value for this run
(gdb) # Note the LOW BYTE: 0x00. Always zero (null terminator protection)
(gdb) # The remaining 7 bytes are random from /dev/urandom at process start
(gdb) stepi ; execute: mov [rbp-0x8], rax
(gdb) x/8xb ($rbp - 8)
0x7ffd...b7b8: 0x00 0x23 0x0a 0xf7 0xe5 0xb2 0xd8 0x4c
; ^ null byte ^ random bytes (little-endian of 0x4cd8b2e5f70a2300)
Phase 3: Verifying Normal Operation
Continue to the epilogue check:
(gdb) break *0x4011dc ; epilogue canary load
(gdb) continue
Breakpoint 2, 0x00000000004011dc in vulnerable ()
(gdb) x/8xb ($rbp - 8)
0x7ffd...b7b8: 0x00 0x23 0x0a 0xf7 0xe5 0xb2 0xd8 0x4c
; Matches what we stored — normal operation
(gdb) stepi ; load canary into rax
(gdb) stepi ; xor with fs:0x28
(gdb) info registers rax
rax 0x0 ; ← zero! XOR of identical values = 0
(gdb) stepi ; je: jumps (ZF=1, values matched)
(gdb) # Next is leave; ret — normal return
The canary is intact. Normal flow proceeds.
Phase 4: Triggering the Canary
Now run with an input large enough to overwrite the canary:
Stack layout for vulnerable():
- buf: rbp-0x28 (40 bytes below rbp)
- buf size: 32 bytes
- gap (alignment): 8 bytes
- canary: rbp-0x8 (8 bytes below rbp)
- saved RBP: rbp
- return addr: rbp+8
Offset from buf[0] to canary:
40 - 8 = 32 bytes of buf
+ 8 bytes of gap
= 40 bytes to canary
Input needed to reach canary: 40+ bytes
(gdb) delete breakpoints
(gdb) run $(python3 -c "print('A' * 40 + 'B' * 8)")
*** stack smashing detected ***: terminated
Program received signal SIGABRT
The canary was overwritten with BBBBBBBB (0x4242424242424242). The epilogue check:
(gdb) break *0x4011dc ; epilogue
(gdb) run $(python3 -c "print('A'*40 + 'B'*8)")
Breakpoint, 0x4011dc in vulnerable ()
(gdb) x/8xb ($rbp - 8)
0x7ffd...b7b8: 0x42 0x42 0x42 0x42 0x42 0x42 0x42 0x42
; ← Canary overwritten with 'B' bytes
(gdb) stepi ; mov rax, [rbp-8]
(gdb) info registers rax
rax 0x4242424242424242 ; ← overwritten canary
(gdb) stepi ; xor rax, [fs:0x28]
(gdb) info registers rax
rax 0xe9a0e47ab1c8a142 ; ← non-zero! Canary mismatch
(gdb) stepi ; je FAILS (ZF=0)
(gdb) # Next instruction: call __stack_chk_fail
(gdb) stepi ; call __stack_chk_fail
*** stack smashing detected ***: ./canary_demo terminated
Phase 5: What __stack_chk_fail Does
__stack_chk_fail is a function in glibc (libc.so):
/* glibc source: sysdeps/x86_64/stack_chk_fail.S (simplified) */
void __stack_chk_fail(void) {
__fortify_fail("stack smashing detected");
}
void __fortify_fail(const char *msg) {
write(2, "*** ", 4);
write(2, msg, strlen(msg));
write(2, " ***: ", 6);
write(2, program_invocation_short_name, ...);
write(2, " terminated\n", 12);
abort(); /* raises SIGABRT, cannot be caught */
}
The function:
1. Writes the error message to stderr
2. Calls abort() which sends SIGABRT to itself
3. SIGABRT cannot be caught (the process terminates unconditionally)
Key Observations
-
The canary low byte is always null: visible in the stored value
0x...00. This means string functions that terminate on null cannot read or copy the full canary. A leak viaputs(canary_address)would only show an empty string. -
The canary is random per-process: running the demo twice shows different canary values. An attacker who exploited the program yesterday and noted the canary value cannot use it today.
-
The canary position is deterministic: the compiler places it at
rbp-8, just below the saved RBP. This layout is consistent across compilations. -
Abort is unconditional:
SIGABRTfromabort()cannot be intercepted by a signal handler in the same process. The OS terminates the process. -
The overhead is tiny: two instructions in the prologue (load + store) and three in the epilogue (load + xor + branch). This is why
-fstack-protector-strongis enabled by default — the performance cost is negligible.
The canary is not perfect (format string leaks can reveal it) but it is extremely effective against naive overflow-to-return-address attacks. It raises the exploitation bar from "overflow and redirect" to "overflow, leak canary, overflow again with correct canary" — a significantly more complex requirement.