Skip to content
Courses

Stack Canaries

MitigationsLesson 1 of 760 minutesLessonNo lab next

In November 1996, the technique you’ve spent a section learning appeared in Phrack issue 49 and the world became aware of it.

The bug wasn’t confined to one program. Any caller that handed gets a buffer was relying on the input fitting, because the function had no argument for the buffer’s size. And gets wasn’t the only function that didn’t check size - we’re talking about tens of thousands of programs across tens of thousands of computers, many of which weren’t actively maintained or patched anymore. Realistically patching every program would take decades (you can still occasionally find a gets call hanging around in an old codebase even today). In the meantime, all of these systems were effectively open to anyone with the know-how to just log in.

This started a cat and mouse game between defenders and attackers that we’ll be looking into for the rest of this section. Attackers find a way in, and defenders attempt to patch the operating system to close that hole. The attackers then look for a bypass, and the game continues.

One of the first of these systemic mitigations, announced on Bugtraq in December 1997, was StackGuard. Rather than repairing the input call, it checked for damage before the function returned. A protected program, the announcement said, would “no longer yield control to the attacker, but rather enter fail-safe state.” The write could still happen, but the program could notice before using the damaged return address.

This is a variation on a much older idea. Miners went underground carrying a canary in a small cage, because the bird would show signs of carbon monoxide poisoning before they did. The bird filtered nothing, and it stopped nothing. It was there to give a warning early enough for the miners to leave.

A stack canary has the same job: give the program a warning if it is in danger. In this lesson, an input like the ones you’ve been constructing now ends like this:

*** stack smashing detected ***: terminated
Aborted (core dumped)

The program detected a problem and aborted. Did it prevent the overwrite, or did it stop after the damage was done? We’ll inspect the frame to separate those possibilities.

A stack canary is a value placed between a protected function’s local storage and its saved frame information. The function copies a reference value from storage outside the frame when it begins, then compares the copy with that reference before it returns. A match lets the return continue; a mismatch calls the failure routine and aborts the process.

The reference is initialized when the process starts and is designed to be unpredictable from outside that process. A sequential overwrite heading toward the return address therefore has to cross the canary first. If it replaces the value with ordinary filler, as our earlier inputs did, the comparison catches the mismatch.

Where the canary sits in the frame

Higher addresses at the top. Simplified layouts, not to scale.

Without a canary

  1. Padding
  2. Local buffer

No canary check before returning.

With a canary

  1. Stack canary
  2. Padding
  3. Local buffer

If the canary changes, the check aborts the program before it returns.

The check compares the canary with a reference value stored elsewhere. The canary doesn't stop the overflow itself.

Save this as gate.c in a fresh working directory:

#include <stdio.h>
#include <stdlib.h>
void vault(void)
{
puts("Vault open.");
exit(0);
}
void ask(void)
{
char name[64];
gets(name);
printf("Hello, %s.\n", name);
}
int main(void)
{
puts("Name:");
ask();
puts("Goodbye.");
return 0;
}

The shape should be familiar: a buffer, an input function with no idea how large it is, and a function nothing calls. Here the buffer is 64 bytes rather than 512, but the input call still has no way to enforce that limit.

Terminal window
gcc -g -O0 -fno-omit-frame-pointer -no-pie gate.c -o gate

Compared with the stack-smashing builds, this command leaves out -fno-stack-protector, letting the course compiler’s default protection apply. This is representative of what we’ll be doing for the rest of this section, adding back default mitigations one at a time until we are able to write exploits for modern binaries.

Terminal window
./gate
Name:
belvedere
Hello, belvedere.
Goodbye.

The short input still produces the normal greeting and Goodbye.. To see the check that this run didn’t trigger, read ask:

Terminal window
objdump -d -Mintel ./gate | sed -n '/<ask>:/,/^$/p'

The prologue has three instructions the earlier builds didn’t:

4011df: sub rsp,0x50
4011e3: mov rax,QWORD PTR fs:0x28
4011ec: mov QWORD PTR [rbp-0x8],rax
4011f0: xor eax,eax
4011f2: lea rax,[rbp-0x50]

In this build, fs:0x28 identifies the reference value used by the check, which the function copies into [rbp-0x8]. That copy occupies the eight bytes immediately below the saved frame pointer and is the stack canary.

The epilogue reads it back:

40121f: mov rax,QWORD PTR [rbp-0x8]
401223: sub rax,QWORD PTR fs:0x28
40122c: je 401233 <ask+0x5c>
40122e: call 401090 <__stack_chk_fail@plt>
401233: leave
401234: ret

Reload the stored copy, subtract the reference value, and jump over the failure call if the difference is zero. If they differ, __stack_chk_fail reports the corruption and aborts.

So the function saves the value on entry and checks it before returning.

Because the comparison happens after gets has returned, it can detect damage from the input call but can’t undo the write. What it can still do is stop execution before leave and ret.

sub rsp,0x50 reserves 80 bytes, and lea rax,[rbp-0x50] is the address handed to gets, so name starts 80 bytes below rbp. The canary is at rbp-0x8. That gives us this layout:

The protected frame in ask

Byte offsets from the start of name. Higher addresses at the top.

  1. Offsets 88–95
  2. Offsets 80–87
  3. Offsets 72–79
    Stack canary8 bytes
  4. Offsets 64–71
    Padding8 bytes
  5. Offsets 0–63
    name[0–63]64 bytes · grouped

Region heights are not to scale. Pointers show the established frame, before teardown.

The return slot begins 88 bytes into name, and covering it takes an input of 96.

The eight bytes of padding are why you read the frame instead of the declaration. The array is 64 bytes, the reservation is 80, and the difference is alignment (the compiler rounds the frame to a sixteen-byte boundary).

Counting from char name[64] would have put the canary at offset 64 and every number after it would have been wrong by eight.

The input has the same shape as in the earlier examples: filler to the return slot, followed by an address.

Terminal window
nm -n ./gate | grep ' vault$'
00000000004011b6 T vault

Save this as reach.py:

from pathlib import Path
from struct import pack
payload = b"A" * 88 + pack("<Q", 0x4011b6)
Path("reach.bin").write_bytes(payload)
print(f"wrote {len(payload)} bytes")
Terminal window
python3 reach.py
./gate < reach.bin
Name:
Hello, AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA�@.
*** stack smashing detected ***: terminated
Aborted (core dumped)

The greeting appeared, so execution reached printf after gets returned. Then the canary check complained.

That tells us where the program got to, but not how much of the frame changed. Let’s look at the memory.

Let’s inspect the saved return address and see what was written before the check stopped the program.

Stop at the comparison and read both slots:

Terminal window
gdb -q ./gate
(gdb) break *0x40121f
(gdb) run < reach.bin
(gdb) x/gx $rbp+8
(gdb) x/gx $rbp-8
0x7fffffffdf78: 0x00000000004011b6
0x7fffffffdf68: 0x4141414141414141

The two slots contain different parts of the same input:

  • The return slot holds 0x4011b6, the address of vault.
  • The canary slot holds 0x4141414141414141, the filler.

Both values are already in place before the check runs. The write crossed the canary and reached the saved return address.

The check detected the changed canary and aborted before ret could use the corrupted address, but it didn’t prevent either write. The unbounded input call remains a bug in gate.c, even though the program noticed its damage.

The write happens before the canary check

Move through the input bytes, then run the check. The return-address bytes come from reach.bin; the original saved values and canary are illustrative.

Input is being copied

Stack memory · ask

Alignment padding · 8 bytesoffsets 64–71

8 input bytes written

name · 64 bytes (compressed)offsets 0–63

64 input bytes written

Saved value and comparison

Reference canary
0x163542e0f852cb00
Saved canary
0x4141414141414141
Check
Not run yet

The appended terminator is applied in the check view, after the selected input has finished.

Crossing the canary does not stop the write. The function compares it only after the input call has finished.

Blue underlines mark changed values; dashed outlines mark current sources or destinations. Byte addresses increase left to right. Unshown values are omitted, not zero.

Knowledge check

Question 1 of 3

At the comparison instruction, the saved return address already holds the attacker's value. What does that establish?

Does the reference value stay the same when you start the program again? Compare it across four separate runs:

Terminal window
for i in 1 2 3 4; do
gdb -q -batch -ex 'break *0x4011e3' -ex 'run < /dev/null' \
-ex 'x/gx $fs_base+0x28' ./gate 2>/dev/null | tail -1
done
0x7ffff7fb4768: 0xbe7661d5cf1d4500
0x7ffff7fb4768: 0xb4b79680a215de00
0x7ffff7fb4768: 0x61eb85ccd1f63000
0x7ffff7fb4768: 0xfafc5cf5d2412500

These four runs have different reference values. You can inspect a value in a running process, but you can’t assume it will be the value used by the next process you start.

fs_base is the base address of x86-64 thread-local storage for this thread. Adding 0x28 reaches the reference used by the instructions in ask; the loop starts a fresh process each time, which is why it reads four independently initialized values.

I’d start with the first result that differs from the example, rather than adjusting the padding.

  • If the program runs to Goodbye. with no diagnostic, that alone doesn’t tell you whether a check was present or what was written. Compare your recorded build and input with the supplied example before drawing a conclusion.
  • If it segfaults instead of reporting stack smashing, don’t infer from that result alone that the build lacks a canary. Inspect where execution stopped and compare it with the expected check.
  • If the disassembly lacks the check shown here, confirm the source and compiler defaults. The lesson’s output assumes the supplied program and course toolchain.
  • If GDB stops somewhere unexpected, confirm the breakpoint address against your own objdump output. Every address on this page came from my build, and yours can differ.

To recover a known state, rebuild from the supplied source with the exact command above and regenerate reach.bin.

Suppose somebody reports: “The program printed Goodbye., so the input must have stayed inside the array.”

Does the canary check tell them that? Explain the difference between checking the canary and checking every array boundary.

Check your reasoning

A passing check tells us that the canary matched when it was inspected. It doesn’t measure the input length or check every array boundary.

The comparison instructions show the distinction: they inspect one value, not every write the function made.

The check relies on a reference value that can change between runs, but that value still lives in the process’s memory. The next question is what happens when the program exposes some of that memory through its output.