The stack canary is the most widely deployed anti-exploit measure in the world, and almost nobody can say how it works. Here is the whole mechanism, in three instructions from a real binary on this machine:
$ cd courses/sec/assets/samples && ./build_samples.sh 2>&1 | sed -n '/H1/,/H2/p'
the mechanism, in the gcc default build -- load, store, compare:
1192: 64 48 8b 04 25 28 00 mov %fs:0x28,%rax
119b: 48 89 44 24 18 mov %rax,0x18(%rsp)
11ca: 48 8b 44 24 18 mov 0x18(%rsp),%rax
11cf: 64 48 2b 04 25 28 00 sub %fs:0x28,%rax
11d8: 75 0b jne 11e5 <main+0x5c>
That is the entire feature. Read %fs:0x28, park a copy in the frame, and on the way out check that the copy still matches. If a buffer overflow ran off the end of a local array far enough to reach the parked copy, the subtraction is non-zero and the jne branches away — to __stack_chk_fail, which prints a message and does not return.
Five instructions, and there is no setup code anywhere. %fs:0x28 is a fixed offset into the thread control block, and the value is already there — put there by the C runtime at thread creation, from a source of randomness the attacker cannot see. The kernel builds the thread control block and hands the process a pointer to it; %fs is the register that points at the current one. That is why there is nothing to initialise: the canary predates main.
What the check does and does not cover, which is the part that matters:
And here is the part that makes it precise rather than impressionistic. The canary sits between the local arrays and the saved registers. An overflow travelling upward through the frame must cross it. That is why the mechanism works at all, and it is also why it is a poor detector of anything that is not travelling upward through the frame.
So the honest description is: the canary converts a silent control-flow hijack into a loud, diagnosable abort, at the cost of three instructions per protected function. It is a detector, not a prevention mechanism, and it does not make the overflow not happen.
Three overflows, 200 bytes each, in two builds. The specimen has a global buffer, a function that overflows a global while holding a local, and a function with no local at all:
$ ./build_samples.sh 2>&1 | sed -n '/H2 /,/H3 /p'
three overflows, 200 bytes each, two builds:
case gcc default (FORTIFY on, canary on) canary only (FORTIFY off)
a buffer overflow NO DIAGNOSTIC
b buffer overflow stack smashing
c buffer overflow NO DIAGNOSTIC
the two messages, which is how you tell them apart in the wild:
canary: *** stack smashing detected ***
fortify: *** buffer overflow detected ***
Read the middle column against the right one. With FORTIFY switched off, the canary catches exactly one of the three: case b, the overflow of the function’s own local. Cases a and c overflow a global — the damage never touches the frame, so the canary is intact and completely silent. That is not a bug in the implementation. It is what the mechanism is.
And the left column is the surprise. In a default gcc build, FORTIFY catches all three, because strcpy into a known-size object is checkable wherever that object lives — frame or global, the compiler knows the size at compile time either way. So the two mechanisms are not redundant and they are not nested: they have genuinely different reach, and on this compiler both are on by default at -O1.
The messages are the practical takeaway, because both mechanisms abort and the aborts look identical from a distance. Anyone debugging a crash needs to know which one fired, and the message is the only way to tell:
*** stack smashing detected *** the CANARY tripped.
The frame changed. Look for an
overflow of a LOCAL in THIS function.
*** buffer overflow detected *** FORTIFY, at a __*_chk call.
The length exceeded a size the
COMPILER knew. It may be a global,
a heap block, or a local.
*** Segmentation fault *** neither fired. The write went
somewhere with no tripwire.
That last line is the uncomfortable one and it belongs in the table. The absence of a diagnostic is not evidence of safety — it is evidence that this particular tripwire was not on the path. The image course already taught the MAP_FIXED lesson about tools that fail silently; this is the same shape in a different place.
There is no header field, no flag, no note for the stack protector. Its entire trace is an instruction pattern, so a posture reader has to look for the bytes. That is why harden.py does this:
The result is a number, and the number is the whole check. Zero tripwires in a binary with functions means no stack protector. Two tripwires means one. It is a coarse instrument — it cannot tell you which functions are protected — and it is stated as coarse in the artifact. But it is the only signal available, and it is enough to catch the default that the last concept measured.
Which functions get instrumented is a separate question, and the answer is a heuristic the compiler applies, not a guarantee. -fstack-protector instruments every function with a frame. -fstack-protector-strong (gcc’s default) instruments a narrower set: functions with a local array, or that take an address of a local, or that call alloca. -fstack-protector-all instruments everything with a frame. Measured on this machine, all three produce 2 references, and -fno-stack-protector produces 0 — because this specimen falls inside all three heuristics. A specimen that separated them would be a better demonstration, and the difference is worth knowing about rather than assuming: the flag names suggest a spectrum, and on a small function they do not differ.
$ cd courses/sec/assets/samples $ ./build_samples.sh 2>&1 | sed -n '/H2 /,/H3 /p' $ python3 harden.py can_gcc can_clang $ python3 crosscheck.py 2>&1 | sed -n '/H1/,/H3/p'
Then find where it stops working, which is the better exercise:
1. Write a function with a local array, and
one WITHOUT, and one that only calls
alloca. Count %fs:0x28 refs in each, under
-fstack-protector, -strong and -all.
(This is the experiment that separates the
three heuristics. My first specimen did not
separate them, because it satisfied all
three at once -- so the ladder read as
"no difference" when the real answer was
"this function is inside every
heuristic".)
2. Recover the canary value at run time and
print it. Then print it again from a
SECOND thread. (Same value or different?
The TCB is per-thread, so the answer is
the more interesting of the two -- and
the reason a canary is not a constant is
that the attacker cannot read it.)
3. Overwrite the canary yourself. Take
probe_can, and in case b, write one byte
PAST the local array before returning.
You should get "stack smashing" on a
single byte of overflow. (The tripwire is
adjacent, not approximate. It fires on a
one-byte overrun, which no bounds check
would.)
4. Now find an overflow the canary CANNOT
see, on purpose: strcpy into a global from
a function with no locals, compiled with
-fno-stack-protector. Run it. (Silence.
That is case c in the table, and producing
it deliberately is the point.)
5. Finally, make the tripwire fire on a
DIFFERENT function's frame. Overflow a
local in f() so far that it reaches g()'s
saved return address. Which function
reports it? (Whichever one's epilogue
runs -- and that is a real property of
this design: the diagnostic names a
function that may not be the one with the
bug.)
Exercise 5 has the best answer in the course and it is genuinely counter-intuitive. The diagnostic names whichever function’s epilogue happens to run, which is frequently not the function with the bug. That is a direct consequence of placing the tripwire in the frame rather than at the overflow site — and it is why “stack smashing detected” tells you that a frame was disturbed and almost nothing about where. Anyone chasing one of these for a day has been misled by the function name in the message.
Exercise 2 is the one that closes the security argument. Per-thread means the value is not a constant, and not-a-constant is the entire property. A canary that were a fixed global would be readable by any code in the process, and an attacker with a write primitive would simply restore it after overwriting the return address — turning the detector back off. The auxv’s AT_RANDOM and the kernel’s per-thread secret are what make the tripwire something the attacker cannot defuse, and that is a better reason for the design than “it detects overflows”.
The direct technical debt this pays is to the auxiliary vector concept. That concept established that AT_RANDOM points at 16 bytes of kernel-supplied randomness on the initial stack, and noted that the value changes every run. The canary is the other end of that: the C runtime reads those bytes at startup and derives a per-thread secret, which is why %fs:0x28 is non-zero and unpredictable without a single instruction of setup code. Two concepts, one mechanism, and the kernel is at the bottom of both. It is a good illustration of why the image course came first: a hardening feature you cannot explain is a flag, and a hardening feature you can explain is a design.
The connection to FORTIFY is the other half of this concept and the contrast is the useful part. The canary is a runtime detector over a whole frame; FORTIFY is a compile-time check at a specific call with a specific known size. That difference explains the entire H2 table: FORTIFY caught the global overflow the canary structurally cannot see, because the canary is a property of where the check lives (the frame) and FORTIFY is a property of what the code says (this copy, this size). Neither is better. They have different reach, and a hardened build wants both.
Two connections to earlier courses, both about cost. The relocations course measured the price of position independence — one extra relocation per datum — and established the pattern this course repeats: a security property costs something measurable, and the cost is the point of measuring it. Five instructions and two relocations-worth of register pressure per protected function is a small price, and it is a price, and the honest thing is to name it rather than pretend the feature is free. The static linking course measured the other extreme — -static at 52× — and a canary is cheaper than that, which is a useful reminder that “more secure” and “more overhead” are not the same axis.
And the connection to the platform, briefly, because it is a real limitation. All of this is x86-64 System V. AArch64 spells the same idea SP; the per-thread secret lives at a different offset in a different register. The mechanism transfers and the encodings do not, which is why the posture reader counts a byte pattern rather than reading a header field — and why a reader who takes the 0x28 offset as universal will get a confident wrong answer on the second architecture. The DWARF frame course covers the register-encoding side of that for AArch64, and this course deliberately does not duplicate it.