Memory Systems · Memory Safety

Stack Canaries and What They Actually Detect

How a random guard word between local buffers and saved control data turns a linear stack overflow into a detectable event, and which overflows it cannot see.

A stack overflow is the easiest memory-safety violation to exploit because the target is at a fixed offset. The return address sits at a known position relative to the frame pointer or the stack pointer, the saved frame pointer sits just below it, and a linear write from a buffer earlier in the frame reaches both after a predictable number of bytes. The classic answer is not to prevent the write but to make it detectable: place a value between the vulnerable objects and the control data, choose that value so the attacker cannot predict it, and check it before the function returns.

That is the entire design of a stack canary, and its limits follow from the word “between”. A canary watches one boundary in one direction. It is a tripwire, not a bounds check.

The epilogue is the checkpoint

The compiler knows the frame layout it generated. When stack protection is enabled and a function qualifies, the prologue loads a guard value from thread-local storage and stores it in the frame at the position just before the saved frame pointer and return address. The epilogue reloads the guard and compares it against the stored copy; on mismatch it calls a failure routine that terminates the process rather than returning.

/* Shape of a protected function, as the compiler generates it. */
extern unsigned long stack_guard;      /* set once, at process start */

void parse(const char *src)
{
    char buf[64];
    unsigned long guard = stack_guard;

    /* ... body writes into buf ... */

    if (guard != stack_guard)
        __stack_chk_fail();                /* does not return */
}

The placement is what matters. The guard is not part of buf and is not checked by the code that writes to buf; it is a separate object that the compiler inserts at a chosen offset. Because the compiler controls both the frame layout and the check, no source-level annotation is needed for the basic case; the only decision is which functions get the treatment.

The guard value itself is chosen once per process, at startup, from a source the attacker cannot observe: on Linux from the AT_RANDOM bytes the kernel places on the initial stack, which are derived from the kernel’s randomness pool. The value is conventionally 8 bytes on a 64-bit target, with its least significant byte set to zero. That zero byte is not decoration. A canary containing an embedded zero byte stops a string-copy overflow early, because strcpy and its relatives stop at the first zero, so the write terminates before reaching the return address and the guard is left intact: the overflow is truncated rather than detected, which is a better outcome for the defender.

Which frames get a canary

Instrumenting every function would add a load, a store, and a compare to every call, including leaf functions that have no buffer at all. Compilers therefore apply a heuristic, exposed in GCC as a graded set of three options that Clang also provides under the same names:

  • -fstack-protector protects functions with a local buffer of eight bytes or more, and functions that call alloca.
  • -fstack-protector-strong adds functions with a local array of any element type, and functions that reference a local frame address.
  • -fstack-protector-all protects every function, including those with no arrays at all.

The middle level is the default on several distributions because the first level misses a class of real bugs: an array of an element type that is not char, such as an array of pointers or of a struct, is a perfectly good overflow target. The third level is used when the cost is acceptable, and it protects against overflows into saved registers and frame pointers even when no array is present, since a frame whose locals are scalars can still be overflowed if an out-of-bounds index into a struct is used.

The heuristic has a well-known hole. A function with no local array and no address-taken local is not protected at the middle level, yet it may still write out of bounds through a pointer into a caller’s frame or into a global. Canaries protect the frame they are placed in, and only against writes that travel upward in address order past the guard. A write through a pointer that points downward, into a callee’s frame or into a heap object, is invisible.

The offsets a canary cannot cover

Three structural gaps account for most of the residual risk.

The first is direction. The guard sits above the locals and below the saved control data, so it catches a write that runs from a local buffer toward higher addresses. A buffer overflow that writes toward lower addresses, through a negative index or a pointer decremented in a loop, moves away from the canary. The stack grows down on the common architectures, so a downward overflow from one frame reaches the red zone and then the next frame’s locals, corrupting data with no guard in the path.

The second is the granularity of the check. The comparison happens once, in the epilogue, on the value the compiler stored. Any corruption that occurs after the check, in the caller once the protected function has returned, is not observed. A canary also cannot detect a write that lands entirely inside the frame’s own locals, because that is what the frame is for: overwriting buf’s neighbour len is a memory-safety violation in the source language and a perfectly ordinary store at the machine level.

The third is that the guard is a value in memory, and memory can be read. An attacker with an arbitrary read primitive learns the canary and reproduces it, at which point the check passes and the return address is whatever the overflow wrote. This is why canaries are frequently described as raising the cost of exploitation rather than preventing it: they force a two-stage attack in which the first stage leaks the guard. Formats that print stack contents, or a read primitive obtained from a separate bug, defeat them completely.

There is a subtler issue with the failure path. __stack_chk_fail terminates the process, and in a server that means the request-handling thread or the whole process dies. The corruption has already happened; the canary converts a potential code execution into a crash. Whether that is an improvement depends on the threat model: for a service, a crash is a denial of service and the availability cost is real. The check is a last line, not a repair.

Canaries against the other mitigations

The canary is one layer of a stack that has accumulated over two decades, and its position in that stack explains its persistence.

Non-executable stacks remove the simplest payload of writing shellcode into the buffer and jumping to it. They do not stop a return-oriented payload, which reuses existing executable bytes. Canaries do not stop return-oriented payloads either, but they do force the attacker to preserve or guess the guard before the return happens, which is the same obstacle from a different angle.

Shadow stacks address the same target, the return address, with a stronger mechanism: a second, protected copy of the return address that the epilogue compares against the stack’s copy. That comparison catches a corrupted return address regardless of how it was corrupted, including by a non-linear write. The design and its hardware support are the subject of the shadow-stack article. A canary is cheaper, one word per frame rather than a parallel stack, and weaker, since it checks an interval rather than a value.

Address-space layout randomisation moves the code and data the attacker needs to reference. It composes with canaries rather than replacing them: randomisation makes an absolute address unknown, and the canary makes a relative overwrite detectable, so an attacker who defeats one still faces the other.

The allocator-side mitigations are the heap counterpart, and they share the canary’s shape: a known value in a known place, checked at a point where the program still has a chance to notice. The pattern of placing a guard value between the vulnerable object and the control data is the same; only the object and the checkpoint differ. The technique survives because it is cheap, requires no source changes, and converts a class of silent corruption into a definite signal. Its weakness is equally consistent: it sees only the boundary it was placed on, in the direction it was placed.

References

  1. StackGuard: Automatic Adaptive Detection and Prevention of Buffer-Overflow Attacks — Cowan et al., USENIX Security 1998
  2. ProPolice: GCC stack smashing protector — Hiroaki Etoh, IBM Research
  3. GNU Compiler Collection: Instrumentation Options — GCC project
  4. System V Application Binary Interface: AMD64 Architecture Processor Supplement — x86-64 psABI working group