- 1 Got an old binary? Lock it down with auto-built Seccomp profiles
- 2 Step 1: Watch the binary like a nosy neighbor
- 3 Step 2: Poke it with a stick (a.k.a. fuzz it)
- 4 Step 3: Turn the list into a jail cell
- 5 Step 4: Test in a disposable container
- 6 Real numbers from my last cleanup
- 7 Making it stick in CI
- 8 Parting thought
Got an old binary? Lock it down with auto-built Seccomp profiles
Picture this: a dusty server in the corner runs a tool your company bought in 2012. Nobody has the source code, nobody wants to touch it, yet it handles customer invoices every day.
That single file is a **loaded weapon** sitting on your network. It can call any system call it wants—open files, talk to the network, spawn shells—because the kernel sees it as a trusted citizen.
We can’t rewrite it, but we can handcuff it to only the calls it really needs. Two hours of work can shrink its attack surface from 300+ syscalls down to 20. Here’s the walk-through I give every new hire on our infra team.
Step 1: Watch the binary like a nosy neighbor
Run the thing under a tracer to see every syscall it makes:
strace -f -o trace.log ./legacy-invoice-app
Let it chew through its normal workload: upload a sample CSV, print a report, whatever it normally does. The longer you watch, the better the profile.
Now yank out the unique calls:
grep -oP '^[a-z0-9_]+' trace.log | sort -u > syscalls.txt
You now have the first draft of a minimal syscall list.
Step 2: Poke it with a stick (a.k.a. fuzz it)
One run rarely covers every code path. I keep a tiny fuzzing script that feeds the binary:
- malformed XMLs
- huge PDFs
- empty files
- non-existent paths
Each round produces new strace logs. Merge them, re-run the grep above, and your syscall list grows to cover *all* the dark corners.
Step 3: Turn the list into a jail cell
Paste the list into a Python snippet I carry in my notes:
import json, sys
syscalls = [line.strip() for line in open('syscalls.txt')]
profile = {
"defaultAction": "SCMP_ACT_ERRNO",
"syscalls": [{"name": s, "action": "SCMP_ACT_ALLOW"} for s in syscalls]
}
json.dump(profile, open('invoice-seccomp.json', 'w'), indent=2)
Boom—one JSON file that blocks everything except the calls you captured.
Step 4: Test in a disposable container
docker run --rm \
--security-opt seccomp=invoice-seccomp.json \
-v "$PWD":/data legacy-invoice-app /data/sample.csv
If the app **crashes**, Docker prints `SIGSYS`. That’s your hint: open the trace again, find the missing syscall, add it to `syscalls.txt`, regenerate the JSON, repeat.
Most apps need one or two iterations. After that you’ve got a profile that:
- stops code-execution gadgets (no `execve`, no `fork`, no `ptrace`)
- locks down the file system (only the exact `openat` paths you saw)
- prevents network surprises (blocks `socket` if the tool never needed it)
Real numbers from my last cleanup
We had a 14-year-old reporting engine that ran as root (!) and happily used 312 different syscalls. After tracing, fuzzing, and tightening:
- Allowed syscalls: 23
- Container startup time: unchanged
- Report generation: same speed
- Audit scanner alerts: zero
The app still prints invoices—it just can’t open a shell, sniff the network, or overwrite `/etc/passwd` anymore.
Making it stick in CI
Drop these three lines into your pipeline so future builds don’t regress:
pytest tests/integration # exercise the binary
strace -f -o /tmp/trace pytest ... # capture *all* syscalls
python3 scripts/update-seccomp.py # regenerate profile if trace differs
Merge only if the new profile passes the container smoke test above.
Parting thought
Legacy binaries aren’t going away, but giving them full run of the kernel is like handing a stranger your car keys because “they look nice.” Ten minutes of tracing, a dash of Python, and one Docker run later, you’ve swapped those keys for a valet ticket that works only in the parking lot.
Try it on your oldest binary today—you’ll sleep better tonight.







