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:
- Identical plaintext blocks always produce identical ciphertext blocks.
- 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}