Flash CTF – Double Booking

Overview

A hotel booking web app (“The Grand Hotel”) where we enter our name and get an encrypted booking token stored as a cookie. A dashboard page decrypts the cookie and shows our booking details. The source code is available at /source.

Reconnaissance

Reading the source, we see how the token is built:

def make_booking_token(name: str) -> str:
    name = name.replace("|", "")
    return encrypt_token(f"name={name}|room=101|role=guest")

The plaintext is pipe-delimited key-value pairs. It’s encrypted with AES-ECB and PKCS#7 padding:

def encrypt_token(plaintext: str) -> str:
    cipher = AES.new(KEY, AES.MODE_ECB)
    return cipher.encrypt(pkcs7_pad(plaintext.encode())).hex()

The dashboard checks role:

flag = FLAG if role == "suite" else ""

So we need a token that decrypts to a string containing role=suite.

Finding the vulnerability

AES-ECB mode encrypts each 16-byte block independently with the same key. This means:

  1. Identical plaintext blocks always produce identical ciphertext blocks.
  2. Ciphertext blocks can be rearranged or spliced between tokens.

The server only strips | from our name. Everything else goes through, including non-printable bytes. This is important: we can include raw PKCS#7 padding bytes in our name to craft blocks that look like valid “end of token” blocks.

Exploitation

Block alignment

The prefix before the name is name= (5 bytes). The suffix is |room=101|role=guest (20 bytes). AES block size is 16 bytes.

With a 12-character name, the token plaintext is 37 bytes, padded to 48:

name=AAAAAAAAAAAA|room=101|role=guest

Block 0: name=AAAAAAAAAAA    (bytes  0-15)
Block 1: A|room=101|role=    (bytes 16-31)
Block 2: guest\x0b\x0b...   (bytes 32-47)

The value guest + 11 bytes of PKCS#7 padding (\x0b) fills the final block. If we could replace this block with one containing suite\x0b\x0b..., we’d forge a suite-tier token.

Crafting the “suite” block

We register with:

name = "A" * 11 + "suite" + "\x0b" * 11

The \x0b bytes are raw bytes sent via HTTP POST — this is valid, since the server only filters |. The resulting token plaintext is 52 bytes, padded to 64:

name=AAAAAAAAAAAAAsuite\x0b\x0b...\x0b|room=101|role=guest

Block 0: name=AAAAAAAAAAA    (bytes  0-15)
Block 1: suite\x0b\x0b...   (bytes 16-31)  <-- golden block
Block 2: |room=101|role=g    (bytes 32-47)
Block 3: uest\x0c\x0c...    (bytes 48-63)

Block 1 is exactly suite followed by 11 bytes of \x0b — a valid PKCS#7 padded final block.

Splicing

We take blocks 0 and 1 from the first token (the standard 12-char booking), and block 1 from the crafted token:

forged = token1[blocks 0-1] + token2[block 1]
       = token1_hex[:64]    + token2_hex[32:64]

This decrypts to:

name=AAAAAAAAAAAA|room=101|role=suite

The PKCS#7 unpadding removes the \x0b bytes, the server parses role=suite, and we get the flag.

The solve script

import sys
import requests

HOST = sys.argv[1] if len(sys.argv) > 1 else "http://localhost"
BLOCK_HEX = 32  # 16 bytes = 32 hex chars


def book(name: bytes) -> str:
    """Submit a booking and return the encrypted token (hex string)."""
    resp = requests.post(
        f"{HOST}/book",
        data={"name": name},
        allow_redirects=False,
    )
    token = resp.cookies.get("booking")
    if not token:
        raise RuntimeError(f"No booking cookie returned. Status: {resp.status_code}")
    return token


def dashboard(token: str) -> str:
    """Fetch the dashboard with a given token and return the page body."""
    resp = requests.get(f"{HOST}/dashboard", cookies={"booking": token})
    return resp.text

name1 = b"A" * 12
token1 = book(name1)
print(f"[*] Token 1 (name={'A'*12}):")
print(f"    {token1}")
print(f"    Blocks: {len(token1) // BLOCK_HEX}")

name2 = b"A" * 11 + b"suite" + b"\x0b" * 11
token2 = book(name2)
print(f"\n[*] Token 2 (crafted name, {len(name2)} bytes):")
print(f"    {token2}")
print(f"    Blocks: {len(token2) // BLOCK_HEX}")

forged = token1[: 2 * BLOCK_HEX] + token2[BLOCK_HEX : 2 * BLOCK_HEX]
print(f"\n[*] Forged token:")
print(f"    {forged}")
print(f"    Blocks: {len(forged) // BLOCK_HEX}")

page = dashboard(forged)
if "MetaCTF{" in page:
    start = page.index("MetaCTF{")
    end = page.index("}", start) + 1
    flag = page[start:end]
    print(f"\n[+] FLAG: {flag}")
else:
    print("\n[-] No flag found. Dashboard response:")
    print(page[:500])
    sys.exit(1)

Flag

MetaCTF{3cb_cut_and_p4ste_ch3ck_1n}