binculator
binculator
Platform: CUSTOM | Category: Reversing | Type: Challenge | Difficulty: Medium | OS: N/A | **Author: XESXOR Techniques: bfs_pathfinding, capstone_immediate_extraction, dfa_reconstruction, plt_got_hooking, python_bump_allocator_malloc_stub, unicorn_partial_function_emulation
Summary
Walkthrough of the CUSTOM Reversing binculator. Task: server streams 10 base64-encoded 32-bit x86 ELF DFA validators, each needing an accepting input string within 120s. Solution: built a generic solver that emulates only init_states() with Unicorn
Description
Сервер присылает вам 10 бинарей по очереди. В ответ необходимо послать строчку, которая заставит текущий бинарь написать 'You win!'. Таймаут 120 секунд на каждый бинарь. После 10-го правильного ответа сервер пришлет флаг.
English: The server ("Welcome to THE BINARY CALCULATOR - binculator") sends 10 base64-encoded 32-bit x86 Linux ELF binaries one at a time. For each one you must send a string that makes the current binary print You win!. There is a 120-second timeout per binary. After the 10th correct answer the server sends the flag.
Each binary is a deterministic finite automaton (DFA) validating an input string. Because you get a fresh, different binary every round with only 120s each, and the DFA graph is built dynamically at runtime (the init_states function is ~1088 disassembly lines and differs per binary), manual reversing per round is infeasible — this challenge demands a generic automated solver.
Analysis
The binaries are ELF 32-bit LSB, Intel 80386, dynamically linked, not stripped (GCC Ubuntu 7.5.0). Reversing main, get_next_state, create_trans and init_states reveals a classic table-driven DFA.
main (0x08049227)
fgetsa line from stdin,strlenit.- Call
init_states()— this builds the whole DFA graph on the heap. - Read the start-state pointer from the global
init_state(0x804b030). - Loop over each character:
state = get_next_state(state, ch). - After consuming the string:
cmp eax, 0xcade7against the final state's marker (state[0]). If equal → printYou win!.
State struct (8 bytes, malloc'd)
[0] int marker ; 0xcade7 = ACCEPT, 0xbbb27 = default non-accept, 0xffffffff (-1) = error
[+4] ptr trans_array ; 0 => no transitions (self-loop on any char)
Transition struct (8 bytes)
[0] int char ; sign-extended (movsx in create_trans); -1 terminates the array
[+4] ptr target_state
get_next_state(state, char) (0x080491d2)
- If
state->trans_array == 0→ return the same state (self-loop for any char). - Else linearly scan; return the matching target, or the global
error_state(0x804b034, marker = -1) if no match.
create_trans(trans_ptr, char, target) (0x08048536)
Writes trans[0] = movsx(char), trans[1] = target.
init_states (0x08048561) — the per-binary-unique part
Mallocs an array of 25 state pointers (0x64 bytes), mallocs each 8-byte state, sets all markers to 0xbbb27, then wires transitions with create_trans and marks the single accept state. This function is what differs per round; everything else is identical.
Key insight: rather than reimplement the ever-changing init_states, run it with a CPU emulator and read the resulting graph straight out of emulated memory.
Solution
The solver harness runs on darwin/arm64 (no qemu-i386), so it uses the Unicorn engine to emulate only init_states():
- Load all
PT_LOADsegments into a UnicornUC_ARCH_X86 / UC_MODE_32instance. - Set up a stack and a heap region; hook the PLT stubs (parse
.plt: each 16-byte entry isff 25 <got_addr>, mapped to a name via.rel.plt+.dynsym). Implementmallocin Python as a bump allocator and stub out the rest, returning by popping the saved return address. - Emulate
init_states()from EIP=init_states, stopping at a sentinel return address (0xdeadbeef). This builds the real DFA graph in emulated memory. - Read the start-state pointer from the
init_stateglobal. - Extract the accept marker dynamically by disassembling
mainwith capstone and findingcmp eax, IMM(IMM > 0x1000) — the accept constant can differ per binary, so hard-coding0xcade7is unsafe. - BFS over the reconstructed graph from the start state to any state whose marker == accept marker, recording
(prev_state, char)to rebuild the shortest accepting string. - Verify by replaying the string through the DFA logic in Python and confirming the final marker == accept marker.
PLT / malloc hooking (the core harness detail)
python read-only • copy allowed# Map GOT offset -> imported name from .rel.plt + .dynsym got_to_name = {} for r in elf.get_section_by_name('.rel.plt').iter_relocations(): got_to_name[r['r_offset']] = dynsym.get_symbol(r['r_info_sym']).name # Each 16-byte .plt entry is: ff 25 <got_addr> -> map name -> plt stub addr plt_stubs = {} off = 0 while off < len(plt_data): if plt_data[off:off+2] == b'\xff\x25': got = struct.unpack('<I', plt_data[off+2:off+6])[0] if got in got_to_name: plt_stubs[got_to_name[got]] = plt_addr + off off += 16 plt_by_addr = {v: k for k, v in plt_stubs.items()} heap_ptr = [0x9000000] def hook_code(uc, address, size, user_data): if address in plt_by_addr: name = plt_by_addr[address] esp = uc.reg_read(UC_X86_REG_ESP) ret = struct.unpack('<I', uc.mem_read(esp, 4))[0] args = struct.unpack('<4I', uc.mem_read(esp+4, 16)) if name == 'malloc': sz = args[0] p = heap_ptr[0] heap_ptr[0] = (heap_ptr[0] + sz + 0xf) & ~0xf # bump allocator uc.reg_write(UC_X86_REG_EAX, p) else: uc.reg_write(UC_X86_REG_EAX, 0) # stub everything else uc.reg_write(UC_X86_REG_ESP, esp + 4) # simulate `ret` uc.reg_write(UC_X86_REG_EIP, ret) mu.hook_add(UC_HOOK_CODE, hook_code)
Extracting the accept marker with capstone
python read-only • copy allowedmd = Cs(CS_ARCH_X86, CS_MODE_32) for ins in md.disasm(main_code, main_addr): if ins.mnemonic == 'cmp' and ins.op_str.startswith('eax,'): v = int(ins.op_str.split(',')[1].strip(), 0) if v > 0x1000: # skip small compares (e.g. strlen loop counters) accept_marker = v break
Running init_states and BFS over the reconstructed graph
python read-only • copy allowed# sentinel return so emu_start halts cleanly when init_states returns mu.mem_map(0xdeadbeef & ~0xfff, 0x1000) esp = mu.reg_read(UC_X86_REG_ESP) - 4 mu.mem_write(esp, struct.pack('<I', 0xdeadbeef)) mu.reg_write(UC_X86_REG_ESP, esp) mu.emu_start(init_states, 0xdeadbeef) # build the DFA graph in emulated memory rd = lambda a: struct.unpack('<I', mu.mem_read(a, 4))[0] start_state = rd(init_state_glob) # BFS: state -> (prev_state, char); state[0]=marker, state[4]=trans_ptr (0 = no transitions) seen = {start_state: None} q = deque([start_state]) accept_state = None while q: st = q.popleft() if rd(st) == accept_marker: accept_state = st break tptr = rd(st + 4) if tptr == 0: continue i = 0 while True: c = struct.unpack('<i', mu.mem_read(tptr + i*8, 4))[0] # -1 terminator if c == -1: break tgt = rd(tptr + i*8 + 4) if tgt not in seen: seen[tgt] = (st, c & 0xff) q.append(tgt) i += 1 # reconstruct shortest accepting string chars = [] cur = accept_state while seen[cur] is not None: prev, c = seen[cur] chars.append(c) cur = prev chars.reverse() answer = bytes(chars).decode('latin1')
The newline gotcha
Appending a trailing \n to the answer drives the DFA into the error state — the accept state has no self-loop for \n. A verifier that replays chars + [0x0a] will flag this. In practice the server's line protocol strips the newline before feeding stdin to the binary, so sending answer\n over the socket is correct — the binary receives just answer. (Verify against chars only, not chars + [0x0a].)
Network driver
pwn.py connects and loops 10 times: receive until the answer: prompt, grab the last long base64 blob (re.findall(r'[A-Za-z0-9+/=]{200,}', text)[-1]), base64-decode to an ELF, call solve_bytes(elf), then sendall(answer + "\n"). After 10 rounds it reads the flag.
python read-only • copy allowedb64 = re.findall(r'[A-Za-z0-9+/=]{200,}', accum.decode('latin1'))[-1] elf = base64.b64decode(b64) ans = solve_bytes(elf) # Unicorn emulate + BFS s.sendall(ans.encode('latin1') + b"\n")
Result
All 10 rounds solved automatically inside the per-binary timeout. Answers in one run:
EmQr, vLsnj, zUxx, YEAmgO, Gxopeo, IjLM, HQSPU, WswcEi, YBdNxD, iWoYpq.
Server: YES! then flag: auto{REDACTED}.
Key Takeaways / Lessons
- Mastering bfs_pathfinding, capstone_immediate_extraction, dfa_reconstruction, plt_got_hooking, python_bump_allocator_malloc_stub, unicorn_partial_function_emulation was key to this challenge.
- Always check for common misconfigurations and verify assumptions.
Crafted by XESXOR Research • 2026-07-02 • CUSTOM • binculator • Difficulty: Medium