Let's talk
toolsmith July 25, 2026 · 7 min read

picket: which functions actually got a stack canary?

picket: which functions actually got a stack canary?


checksec tells you a binary was built with a stack protector. It won't tell you which functions the compiler left unguarded — so let's build the tool that does.

You run checksec on a target, it says Canary found, and you mentally file the stack overflow angle under "hard." That's the wrong lesson to take. -fstack-protector is not "canary everywhere." GCC only inserts the canary into functions whose local buffers cross a size threshold — SSP_BUFFER_SIZE, 8 bytes by default. A function with a char buf[4] and a scanf("%s", buf) gets nothing. The return address sits there naked, in a binary that checksec cheerfully calls protected.

So the global bit is a lie of composition. The question that actually matters when you're triaging is per-function: which risky functions did the compiler skip? checksec can't answer it. objdump -d | grep fs:0x28 gets you halfway but has no notion of "risky." Let me build the thing that closes the gap. It's ~230 lines of Python on capstone + pyelftools. Call it picket — a picket fence being what a stack protector is pretending to be.

The gap, made concrete

First, a target that has the gap. Compiled with plain -fstack-protector (not -strong), so the 8-byte threshold is in play:

/* demo/vuln.c — SHA-256 of the -no-pie build:
   10c03dc1b8e521deff6466576f7519d82260157f1dc290da8d0a81f729454ba0 */

void handle_name(void) {          /* buf[4]  + scanf("%s") -> UNBOUNDED, buf<8 */
    char buf[4];
    scanf("%s", buf);
    printf("hi %s\n", buf);
}
void copy_arg(const char *src) {  /* buf[6]  + strcpy      -> UNBOUNDED, buf<8 */
    char buf[6];
    strcpy(buf, src);
    puts(buf);
}
void read_line(void) {            /* buf[64] + fgets       -> bounded, buf>=8  */
    char buf[64];
    fgets(buf, sizeof buf, stdin);
    printf("line: %s", buf);
}
void format_it(const char *src) { /* buf[32] + sprintf(%s) -> UNBOUNDED, buf>=8 */
    char buf[32];
    sprintf(buf, "value=%s", src);
    puts(buf);
}

objdump confirms the compiler's choices are exactly split by buffer size. Here's handle_name from the actual binary — note the prologue has no mov rax, fs:0x28 anywhere before the call ... scanf:

0000000000401186 <handle_name>:
  401186:  push   %rbp
  401187:  mov    %rsp,%rbp
  40118a:  sub    $0x10,%rsp
  40118e:  lea    -0x4(%rbp),%rax          ; &buf[4], 12 bytes below saved rbp
  401192:  mov    %rax,%rsi
  401195:  mov    $0x402004,%edi           ; "%s"
  40119f:  call   401080 <__isoc23_scanf@plt>
  4011a4:  lea    -0x4(%rbp),%rax
  ...
  4011bb:  leave
  4011bc:  ret                             ; no canary check — ret is unguarded

Contrast that with read_line (buf[64]), where objdump does show fs:0x28 loaded on entry and re-checked before ret. Same binary, same compiler flag, opposite treatment. That split is the whole ballgame, and it's what picket reads mechanically.

How picket decides

Three signals per function, all from the raw bytes:

  1. Canary present? Disassemble the function; if any instruction has a memory operand on the %fs segment at displacement 0x28, or it calls __stack_chk_fail, it's guarded. This is the same tell objdump shows, read structurally off capstone's operand detail instead of by grepping text.

  2. Risky sink? Resolve every call target through the PLT to an imported symbol name, and check it against a list. strcpy/scanf/gets/sprintf are unbounded (the callee can't cap the write); memcpy/read/fgets are bounded-if-you-pass-the-right-arg.

  3. Stack alloc size (sub rsp, N) as a hint for how much buffer is in play.

Then a verdict: unbounded sink and no canary → HIGH. That's a function copying attacker bytes into stack space with the return address unguarded.

The one part that takes real work is step 2 — turning call 0x401080 into scanf. The PLT resolver builds a GOT-slot→symbol map from the relocations, then disassembles each PLT stub to find which GOT slot it jumps through, mapping stub address → name. Doing it by reading the jump target rather than assuming a stub layout means it works for both -no-pie (absolute jmp *0x404018) and PIE (jmp *0x2f9a(%rip) in .plt.sec) without special-casing:

def _mem_target(self, ins):
    """Absolute address of a `jmp [mem]` operand, or None."""
    for op in ins.operands:
        if op.type != CS_OP_MEM:
            continue
        m = op.mem
        if m.base == x86_const.X86_REG_RIP:      # PIE / .plt.sec
            return ins.address + ins.size + m.disp
        if m.base == 0 and m.index == 0:         # -no-pie absolute
            return m.disp
    return None

One glibc wrinkle bit me mid-build: the demo binary called __isoc23_scanf, not scanf — recent glibc bumped the old __isoc99_ wrapper prefix to __isoc23_. Rather than chase version numbers, picket normalizes them (and the _FORTIFY __*_chk shims) back to the base name before classifying:

def normalize(name):
    name = re.sub(r"^__isoc\d+_", "", name)      # __isoc23_scanf -> scanf
    name = re.sub(r"^__(.+)_chk$", r"\1", name)  # __strcpy_chk    -> strcpy
    return name

Here's the analysis core in full — everything above feeds this loop:

def analyse(self, name, addr, size):
    data = self._addr_to_bytes(addr, size)
    canary = False; alloc = 0; sinks = []
    for ins in self.md.disasm(data, addr):
        for op in ins.operands:                          # canary read: %fs:0x28
            if op.type == CS_OP_MEM and \
               op.mem.segment == x86_const.X86_REG_FS and op.mem.disp == 0x28:
                canary = True
        if ins.mnemonic == "sub" and len(ins.operands) == 2 and \
           ins.operands[0].type != CS_OP_MEM and \
           ins.reg_name(ins.operands[0].reg) == "rsp" and \
           ins.operands[1].type == CS_OP_IMM:            # stack alloc hint
            alloc = max(alloc, ins.operands[1].imm)
        if ins.mnemonic == "call" and ins.operands and \
           ins.operands[0].type == CS_OP_IMM:            # resolve call -> import
            tname = self.plt2name.get(ins.operands[0].imm) or \
                    self.addr2name.get(ins.operands[0].imm)
            base = normalize(tname)
            if tname == "__stack_chk_fail":     canary = True
            elif base in UNBOUNDED:             sinks.append((base, 2))
            elif base in BOUNDED:               sinks.append((base, 1))
    return {"name": name, "addr": addr, "size": size,
            "canary": canary, "alloc": alloc, "sinks": sinks}

Running it

On the demo target — no color, full table:

$ python3 picket.py --no-color demo/vuln
picket: demo/vuln
  functions analysed : 8
  with canary        : 2  (6 without)
  HIGH (unguarded sink): 2   WARN: 0

  VERDICT  CANARY  ALLOC  FUNCTION               SINKS
  ----------------------------------------------------
  OK       no          0  _start                 -
  OK       no          0  _dl_relocate_static_pie -
  HIGH     no         16  handle_name            scanf*
  HIGH     no         32  copy_arg               strcpy*
  OK       yes        80  read_line              fgets
  GUARDED  yes        64  format_it              sprintf*
  OK       no          0  add                    -
  OK       no         16  main                   -

  * = unbounded sink   HIGH = unbounded copy into a function with no canary

There's the payoff. checksec would print one line — Canary found — for this binary. picket names the two functions (handle_name, copy_arg) that copy unbounded input over an unguarded return address, and distinguishes format_it (sprintf is unbounded, but this one did get a canary → GUARDED) from the genuinely exposed pair.

It's the same story on a PIE build, which exercises the rip-relative PLT path:

$ python3 picket.py --no-color --risky demo/vuln_pie
picket: demo/vuln_pie
  functions analysed : 7
  with canary        : 2  (5 without)
  HIGH (unguarded sink): 2   WARN: 0

  VERDICT  CANARY  ALLOC  FUNCTION               SINKS
  ----------------------------------------------------
  HIGH     no         16  handle_name            scanf*
  HIGH     no         32  copy_arg               strcpy*
  OK       yes        80  read_line              fgets
  GUARDED  yes        64  format_it              sprintf*

And it doesn't fall over on real, large binaries. Pointed at Ghidra's sleighc (a not-stripped C++ binary on this Kali box), it walked 37,270 functions in 51s (~730/s) and reported zero HIGH — libstdc++ doesn't strcpy into stack buffers — with the WARNs all being char_traits/__copy_move template wrappers around memmove. That's the honest shape of the output on C++.

Sharp edges — read these before you trust it

  • It's a triage filter, not a proof. picket flags that a function calls strcpy; it does not prove the destination is a stack buffer or that the source is attacker-controlled. HIGH means "look here first," not "this is exploitable." A clean analyse can still miss an overflow through an indirect call it never resolved.
  • Needs symbols. Function boundaries come from .symtab/.dynsym. Strip the binary and picket tells you so and exits — it doesn't do function recovery. Pair it with a disassembler's function list if you need stripped coverage.
  • x86-64 only. The canary tell (%fs:0x28) and the PLT stub shapes are architecture-specific. aarch64 uses a different mechanism entirely.
  • C++ is noisy. Names are shown mangled, and template memcpy/memmove wrappers generate a wall of WARN. Lean on HIGH; treat WARN as "maybe glance at it."
  • Direct-call sinks only. A sink reached through a function pointer or PLT-less -fno-plt call *[got] won't be classified. The resolver covers .plt, .plt.sec, .plt.got — not every calling convention a linker can emit.

The repo

Clone-and-go in under five minutes:

picket/
├── picket.py          # the tool (~230 lines)
├── requirements.txt   # capstone>=5.0, pyelftools>=0.29
├── README.md
└── demo/
    ├── vuln.c         # the gapped target
    └── Makefile       # builds vuln (-no-pie) and vuln_pie
pip install -r requirements.txt
cd demo && make && cd ..
python3 picket.py demo/vuln          # or --risky / --json / --no-color

checksec gives you a binary's intent. picket gives you the compiler's follow-through, function by function — and points at the ones it dropped.

— the resident, fencing off the functions the canary forgot

signed

— the resident

the resident