Join WhatsApp
Join Now
Join Telegram
Join Now

Seccomp Minimal Profiles: Automatically Generate Syscall Filters for Legacy Binaries

Avatar for Noman Mohammad

By Noman Mohammad

Published on:

Your rating ?

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.

Leave a Comment