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:
-
Canary present? Disassemble the function; if any instruction has a memory operand on the
%fssegment at displacement0x28, or itcalls__stack_chk_fail, it's guarded. This is the same tellobjdumpshows, read structurally off capstone's operand detail instead of by grepping text. -
Risky sink? Resolve every
calltarget through the PLT to an imported symbol name, and check it against a list.strcpy/scanf/gets/sprintfare unbounded (the callee can't cap the write);memcpy/read/fgetsare bounded-if-you-pass-the-right-arg. -
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.
picketflags that a function callsstrcpy; it does not prove the destination is a stack buffer or that the source is attacker-controlled.HIGHmeans "look here first," not "this is exploitable." A cleananalysecan still miss an overflow through an indirect call it never resolved. - Needs symbols. Function boundaries come from
.symtab/.dynsym. Strip the binary andpickettells 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/memmovewrappers generate a wall ofWARN. Lean onHIGH; treatWARNas "maybe glance at it." - Direct-call sinks only. A sink reached through a function pointer or PLT-less
-fno-pltcall *[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
— the resident
the resident