algoatson.github.io ~/posts
segfault@algoatson.github.io
back to index

one keybind to checksec everything

2026-05-193 min readtooling pwn ctf

First question on a new box is always the same: of the dozen binaries in this challenge, which one is the soft target? The answer is checksec — run once per file, read all at once. So I stopped running it by hand.

the one-liner#

Find every ELF under the current tree and line their mitigations up:

checkall.shbash
1
2
3
4
5
6
7
8
#!/usr/bin/env bash
# every ELF under $1 (default .), mitigations in one table
find "${1:-.}" -type f -exec sh -c 'file "$1" | grep -q ELF' _ {} \; -print \
  | while read -r bin; do
      printf '%-28s ' "$bin"
      checksec --file="$bin" --format=json 2>/dev/null \
        | jq -r '.[] | "\(.relro) \(.canary) \(.nx) \(.pie)"'
    done | column -t

Pointed at a challenge directory it turns "twelve unknown files" into a glance:

checkall.sh on a fresh boxcast
$$checksec--file=./challenge[*]'/ctf/challenge'Arch:amd64-64-littleRELRO:FullRELROStack:NocanaryfoundNX:NXenabledPIE:PIEenabled

The one with no canary and partial RELRO is where I start. Here it is as the panel I'd paste into the writeup:

target./vuln
arch
amd64-64-little
  • RELROpartial
  • canaryoff
  • NXon
  • PIEoff

wiring it to a key#

A script I have to remember to run is a script I won't run. So it goes behind one tmux binding, scoped to the current pane's directory:

~/.tmux.confbash
bind-key C-k run-shell 'tmux display-popup -E "checkall.sh #{pane_current_path}"'

prefix C-k and the table pops over whatever I'm doing, no context switch. The muscle memory is the whole point — five seconds saved a hundred times is the difference between checking and assuming.

the version that earns its keep#

The shell one-liner is fine until a corpus has 300 files and you want it sorted by "easiest first." At that point it graduates to pwntools, where ELF already parses the mitigations and I can rank them:

checkall.pypython
import sys, glob
from pwn import ELF, context
context.log_level = "error"

def softness(e):                      # higher = easier target
    return (not e.canary) + (not e.pie) + (e.relro != "Full") + (not e.nx)

rows = []
for path in glob.glob(f"{sys.argv[1]}/**/*", recursive=True):
    try: e = ELF(path, checksec=False)
    except Exception: continue
    rows.append((softness(e), path, e.canary, e.pie, e.relro, e.nx))

for s, path, *m in sorted(rows, reverse=True):
    print(f"{s}  {path:30}  canary={m[0]} pie={m[1]} relro={m[2]} nx={m[3]}")

The diff from "prints a table" to "ranks the table" is small and worth it:

what the rewrite boughtdiff
1
2
3
4
-checksec --file="$bin" --format=json | jq ...   # one line per file, unsorted
-# eyeball twelve rows
+softness = (not canary) + (not pie) + (relro != Full) + (not nx)
+# sorted; the top row is the soft target

don't trust a green wall

checksec reads the binary, not the deployment. Full RELRO with a stack leak is still game over, and a "secure" wall just means the bug is somewhere subtler than the linker flags. The table tells you where to look first, not where to stop.

  • enumerate every ELF in the tree
  • one keybind, scoped to the pane
  • rank by exploitability, not filename
  • cache results per-file hash so re-runs are instant1

  1. The obvious next increment: key a tiny on-disk cache by the file's sha1, so a 300-binary corpus only pays the parse cost once. Left undone because the popup already feels instant under ~50 files, which is every CTF I actually play.