Skip to main content

UMDCTF Roulette - Damon Comprehensive Write-up

1) Initial Requirement

Original objective:

  • Make the roulette machine accept the bet.
  • Find the flag.
  • Run everything from macOS using Docker (Linux ELF target).
  • Investigate whether a hidden text payload (not just a number) is required.

2) Challenge Context

Target binary:

  • roulette is a stripped, statically linked Linux ELF x86-64 executable.
  • Host machine was macOS, so native execution was not possible.

Baseline metadata:

  • ELF: 64-bit LSB executable, x86-64, static.
  • SHA256: 8309cb47de4a34b8f779707d59ba287256d62b9b749226502d6d89ec5c0291b5

3) Environment Constraints (Good and Bad)

Good

  • Docker + QEMU user emulation worked reliably for running the binary.
  • Disassembly in Docker worked with architecture-specific tools.
  • Unicorn emulator allowed deterministic, scriptable reverse engineering.

Bad

  • Could not execute ELF directly on macOS.
  • objdump in generic binutils inside Ubuntu arm64 container failed with architecture unknown until switching to x86_64-linux-gnu-objdump.
  • gdb/gdb-multiarch setup and qemu remote-debug flow were brittle and slowed progress.
  • Some shell quoting mistakes produced misleading loop output (payload filename printed repeatedly).
  • Multiple early payload hypotheses were wrong and all rejected.

4) Recon and Early Hypotheses

We explored strings and .rodata and found suspicious embedded content including:

  • ANTHROPIC_MAGIC_STRING_TRIGGER_REFUSAL_...
  • a base64-looking block beginning with KElnbm9yZSB0aGUg...

Early assumption:

  • Maybe a hidden literal string from .rodata is the accepted input.

Result:

  • Testing payload.bin, payload2.bin, payload3.bin, and payload_base64.bin in Docker all returned rejected.

5) Static Analysis Findings

Disassembly focus:

  • Main checker loop around 0x401770 to 0x401950.
  • Success path at 0x401956 prints accepted.
  • Failure path at 0x401862 prints rejected.
  • Input length gate required exactly 106 bytes before newline.

Key observation:

  • Validation uses a complex per-round transform across 27 rounds and compares against a 27-entry table in .rodata.
  • The check is not a simple numeric roulette bet; it is effectively a byte-structured payload validation.

6) Failed Approaches (Important)

  1. Direct candidate strings from .rodata
  • Rejected.
  1. Base64 candidate extraction
  • Rejected.
  1. Reimplementing arithmetic helper logic from disassembly (0x401cd0) by hand
  • Several near misses.
  • Repeated mismatches in final state (ebx) like:
    • 0x24212868
    • 0xdb2127db
    • 0x2ce84ede
    • 0x3547d890
  • One iteration also had a Python file-handle bug (ValueError: seek of closed file).
  1. Patch-based bypass (solve.sh with NOPs)
  • Useful for proving branch understanding.
  • Not a clean solve of the original validator.

7) Breakthrough

Instead of fully re-deriving every helper subpath manually, we used controlled Unicorn emulation of the real binary code path:

  • Emulate checker from entry 0x40175b.
  • Hook at compare-pre instruction 0x4018fc.
  • At that point, compute the exact required 32-bit word (want = eax ^ table[idx]) and write matching bytes into the emulated input buffer.
  • Continue until accepted branch is reached.

Critical fix:

  • The helper reads canary from fs:0x28; we had to map FS memory and set FS_BASE, otherwise emulation failed with unmapped read at address 0x28.

8) Final Exploit Strategy

Exploit here means "construct the exact payload that passes the binary's hidden validator":

  1. Map roulette PT_LOAD segments into Unicorn.
  2. Create emulated input buffer of 106 bytes.
  3. Hook code execution at compare site (0x4018fc).
  4. Inject per-round bytes needed to satisfy each table compare.
  5. Stop on accepted (0x401956).
  6. Dump resulting 106-byte payload and append newline.
  7. Verify via Docker + qemu execution.

9) Verification Results

Payload file outcomes in Docker:

  • payload.bin -> rejected
  • payload2.bin -> rejected
  • payload3.bin -> rejected
  • payload_base64.bin -> rejected
  • payload_solved.bin -> rejected
  • payload_hook.bin -> accepted

Final accepted payload text:

UMDCTF{I_R3ALLY-want-to-pl4y-the-p0werball,+but-my-d4d-said-no-so-im-b3tting-ill-win-on-POLYMARKETinstead}

10) Meaning of the Flag

UMDCTF{I_R3ALLY-want-to-pl4y-the-p0werball,+but-my-d4d-said-no-so-im-b3tting-ill-win-on-POLYMARKETinstead}

Interpretation:

  • The message is humorous narrative text, not a pure random token.
  • It references not being allowed to play Powerball and "betting on Polymarket instead".
  • The challenge intentionally framed input as a roulette "number" while actually requiring a highly specific 106-byte phrase.

11) Full Final Code (solve_damon.py)

#!/usr/bin/env python3
"""
Damon's reproducible solver for UMDCTF roulette.

This script derives the accepted 106-byte input by emulating the binary's
validation loop and patching candidate words at the exact compare site.
"""

from __future__ import annotations

import argparse
import struct
import subprocess
import sys
from pathlib import Path

from elftools.elf.elffile import ELFFile
from unicorn import Uc, UcError, UC_ARCH_X86, UC_MODE_64
from unicorn import UC_HOOK_CODE, UC_PROT_EXEC, UC_PROT_READ, UC_PROT_WRITE
from unicorn.x86_const import UC_X86_REG_EAX
from unicorn.x86_const import UC_X86_REG_EBP
from unicorn.x86_const import UC_X86_REG_FS_BASE
from unicorn.x86_const import UC_X86_REG_RDI
from unicorn.x86_const import UC_X86_REG_RIP
from unicorn.x86_const import UC_X86_REG_RSP
from unicorn.x86_const import UC_X86_REG_R14

PAGE = 0x1000

# Binary addresses used by the final exploit path.
ENTRY_ADDR = 0x40175B
ACCEPT_ADDR = 0x401956
REJECT_ADDR = 0x401862
COMPARE_PRE_ADDR = 0x4018FC
RODATA_TABLE_ADDR = 0x499CE0
INPUT_LEN = 106


def align_down(x: int) -> int:
return x & ~(PAGE - 1)


def align_up(x: int) -> int:
return (x + PAGE - 1) & ~(PAGE - 1)


def read_u32(buf: bytes, off: int) -> int:
return struct.unpack_from("<I", buf, off)[0]


def derive_payload(binary_path: Path) -> bytes:
with binary_path.open("rb") as f:
elf = ELFFile(f)
ro = elf.get_section_by_name(".rodata")
if ro is None:
raise RuntimeError(".rodata not found")

ro_base = ro["sh_addr"]
ro_data = ro.data()
table = [read_u32(ro_data, RODATA_TABLE_ADDR - ro_base + i * 4) for i in range(27)]

uc = Uc(UC_ARCH_X86, UC_MODE_64)

# Map all PT_LOAD segments at original virtual addresses.
for seg in elf.iter_segments():
if seg["p_type"] != "PT_LOAD":
continue
vaddr = seg["p_vaddr"]
memsz = seg["p_memsz"]
filesz = seg["p_filesz"]
seg_data = seg.data()

start = align_down(vaddr)
end = align_up(vaddr + memsz)
perms = 0
flags = seg["p_flags"]
if flags & 4:
perms |= UC_PROT_READ
if flags & 2:
perms |= UC_PROT_WRITE
if flags & 1:
perms |= UC_PROT_EXEC

uc.mem_map(start, end - start, perms)
uc.mem_write(vaddr, seg_data)
if memsz > filesz:
uc.mem_write(vaddr + filesz, b"\x00" * (memsz - filesz))

# Set up fs:0x28 canary read used inside helper logic.
fs_base = 0x7000_0000_0000
uc.mem_map(fs_base, PAGE, UC_PROT_READ | UC_PROT_WRITE)
uc.mem_write(fs_base + 0x28, struct.pack("<Q", 0x1122334455667788))
uc.reg_write(UC_X86_REG_FS_BASE, fs_base)

# Stack and input buffers.
stack_base = 0x7FFF_F000_0000
uc.mem_map(stack_base, 0x40000, UC_PROT_READ | UC_PROT_WRITE)
rsp = stack_base + 0x30000

inp = 0x600000
uc.mem_map(inp, PAGE, UC_PROT_READ | UC_PROT_WRITE)
uc.mem_write(inp, b"A" * INPUT_LEN + b"\x00\x00")

uc.reg_write(UC_X86_REG_RSP, rsp)
uc.reg_write(UC_X86_REG_R14, inp)
uc.reg_write(UC_X86_REG_RIP, ENTRY_ADDR)

state = {"accepted": False, "rejected": False}

def on_code(emu: Uc, addr: int, _size: int, _user: object) -> None:
if addr == COMPARE_PRE_ADDR:
idx = emu.reg_read(UC_X86_REG_EBP) & 0xFFFFFFFF
pre = emu.reg_read(UC_X86_REG_EAX) & 0xFFFFFFFF
want = pre ^ table[idx]

# The loop only consumes 106 bytes, so the last dword is partial.
base = idx * 4
assembled = 0
for k in range(4):
pos = base + k
if pos < INPUT_LEN:
b = (want >> (8 * k)) & 0xFF
emu.mem_write(inp + pos, bytes([b]))
assembled |= b << (8 * k)
emu.reg_write(UC_X86_REG_RDI, assembled)

elif addr == ACCEPT_ADDR:
state["accepted"] = True
emu.emu_stop()
elif addr == REJECT_ADDR:
state["rejected"] = True
emu.emu_stop()

uc.hook_add(UC_HOOK_CODE, on_code)
uc.emu_start(ENTRY_ADDR, ACCEPT_ADDR + 4)

payload = bytes(uc.mem_read(inp, INPUT_LEN))
if not state["accepted"]:
raise RuntimeError("emulation finished without reaching accepted branch")
if b"\x00" in payload or b"\n" in payload:
raise RuntimeError("derived payload has disallowed byte(s) before terminator")

return payload


def verify_in_docker(workdir: Path, payload_file: Path) -> str:
cmd = [
"docker",
"run",
"--rm",
"-i",
"-v",
f"{workdir}:/app",
"-w",
"/app",
"ubuntu:20.04",
"bash",
"-lc",
"apt update >/dev/null && apt install -y qemu-user-static >/dev/null && "
f"cat /app/{payload_file.name} | qemu-x86_64-static ./roulette",
]
proc = subprocess.run(cmd, capture_output=True, text=True, check=False)
return proc.stdout + proc.stderr


def main() -> int:
parser = argparse.ArgumentParser(description="Derive and verify roulette winning payload")
parser.add_argument("--binary", default="roulette", help="Path to ELF binary")
parser.add_argument("--out", default="payload_hook.bin", help="Output payload file")
parser.add_argument("--verify", action="store_true", help="Run Docker/QEMU verification")
args = parser.parse_args()

binary_path = Path(args.binary)
out_path = Path(args.out)

if not binary_path.exists():
print(f"[-] Missing binary: {binary_path}", file=sys.stderr)
return 1

try:
payload = derive_payload(binary_path)
except (RuntimeError, UcError) as e:
print(f"[-] Solve failed: {e}", file=sys.stderr)
return 1

out_path.write_bytes(payload + b"\n")
print(f"[+] Wrote {out_path} ({len(payload)} data bytes + newline)")

text = payload.decode("ascii", errors="replace")
print(f"[+] Payload text: {text}")

if args.verify:
output = verify_in_docker(Path.cwd(), out_path)
print("\n[+] Docker verification output:")
print(output.rstrip())

return 0


if __name__ == "__main__":
raise SystemExit(main())

12) How to Reproduce End-to-End

  1. Create and activate virtual environment.
python3 -m venv .venv
. .venv/bin/activate
pip install unicorn pyelftools
  1. Generate payload and verify in Docker.
python solve_damon.py --out payload_damon.bin --verify

Expected key lines:

  • Payload text printed with full UMDCTF{...} flag.
  • Docker output includes:
    • lets go gambling!
    • submit roulette number:
    • accepted

13) Artifacts in This Directory

  • roulette - original challenge binary.
  • solve_damon.py - final reproducible solver.
  • payload_hook.bin - successful accepted payload.
  • payload_damon.bin - generated by final script.
  • payload.bin, payload2.bin, payload3.bin, payload_base64.bin, payload_solved.bin - failed candidates.
  • trace.gdb - debugging attempt script (helpful context, not final path).
  • solve.sh - patch-based bypass experiment.
  • exploit.py - early heuristic attempt.

14) Final Summary

  • Requirement satisfied: roulette accepted the bet.
  • Flag recovered and validated by running the real binary under Docker/QEMU.
  • The winning input is a strict 106-byte payload, not a conventional roulette number.
  • Most important technical win: dynamic emulation hook at compare site, with proper FS canary setup.