Сикс-севен
Описание задания
В задании дан веб-сервис кофейни «У Шамира» и архив с исходным кодом:
По легенде действует программа лояльности «6–7»: нужно купить 6 эспрессо, а затем в качестве 7-го напитка можно получить любой напиток. На практике у нового пользователя есть только 40 монет, а один эспрессо стоит 10 монет — легально можно купить только 4 эспрессо.
Задача: получить купон на 7-й напиток, не имея 6 долей.
Флаг: alfa{esPress0_MacChi4T0_pOr_fAVOr3}
Разбор исходников
В приложении реализована схема, похожая на Shamir Secret Sharing.
Каждый купленный эспрессо создаёт заказ с UUID. Для заказа генерируется QR-код, внутри которого лежит JSON вида:
json{
"order_id": "9df03866-aa45-48f8-bee8-727eb85fcf9c",
"secret_part": "22535180056152808296086384252092152298162688175416839322383634726849228300611"
}UUID заказа используется как точка x, а secret_part — значение полинома в этой точке.
Секретный купон — это свободный коэффициент полинома:
textsecret = f(0)
Чтобы честно восстановить секрет по схеме Шамира, нужно 6 точек. Но у нас только 4 эспрессо — классическая интерполяция Лагранжа не подходит.
Важная находка в Git-истории
Архив содержит не просто исходники, а Git-репозиторий. В истории видно, что раньше в проекте был отдельный Rust API.
bashgit log --oneline
В старом коммите присутствовали API-методы:
В текущем docker-compose Rust API как будто удалён, но конфигурация nginx всё ещё проксирует /api/. На живом инстансе эти эндпоинты тоже доступны.
Особенно интересен метод /api/set_module — он позволяет менять модуль поля q, в котором считаются значения схемы Шамира.
Уязвимость
В нормальной схеме Шамира используется большое простое поле mod q. Но старый API позволяет пользователю менять q. Проверка простоты модуля слабая: используется Fermat-подобная проверка, а не полноценная криптографически корректная валидация.
- Берём UUID заказа.
- Рассматриваем UUID как большое число x.
- Факторизуем x.
- Находим достаточно большой простой делитель p.
- Устанавливаем модуль:
q = p.
textx ≡ 0 mod p
textf(x) ≡ f(0) mod p
То есть доля этого заказа становится сразу:
textsecret mod p
Таким образом один заказ даёт остаток секрета по модулю p.
Почему нужны несколько заказов
Сам секрет — это 256-битное число. Один простой делитель UUID обычно меньше 256 бит, поэтому он даёт только часть информации:
textsecret ≡ a1 mod p1 secret ≡ a2 mod p2 ...
Когда произведение модулей становится больше размера секрета:
textp1 * p2 * ... * pk > 2^256
секрет можно однозначно восстановить с помощью китайской теоремы об остатках (CRT).
Эксплуатация
- Регистрируем нового пользователя.
- Покупаем 4 эспрессо.
- Из dashboard достаём UUID заказов.
- Для каждого UUID считаем его числовое значение.
- Факторизуем UUID.
- Для каждого большого простого делителя p:
- Собираем остатки через CRT.
- Отправляем восстановленный купон в
/buy-exclusive. - Получаем QR 7-го напитка.
- Декодируем QR и забираем флаг.
textsecret ≡ 104235194837086147991081 mod 271022341962674876014939 secret ≡ 22469167400840334385822069390 mod 738369751665541259657901187879 ...
Если произведение модулей недостаточно большое, нужно просто создать новый аккаунт и повторить: UUID генерируются случайно, поэтому иногда попадаются более удобные множители.
Важный нюанс с QR
QR обычного эспрессо содержит JSON с долей секрета:
json{
"order_id": "...",
"secret_part": "..."
}После успешной покупки 7-го напитка сервер создаёт отдельный exclusive-заказ. Его QR уже содержит сам флаг.
QR одноразовый: после первого обращения к /orders/<id>/qr.svg сервер помечает флаг как полученный. Поэтому нужно скачивать именно QR exclusive-заказа, а не случайный QR с dashboard.
После отправки купона в Flask-session появляется поле last_exclusive_order_id. По нему скачивается правильный QR:
text/orders/<last_exclusive_order_id>/qr.svg
Декодирование QR
bashrsvg-convert -w 1200 -h 1200 flag.svg -o flag.png
bashzbarimg --raw -Sqr.enable flag.png
alfa{иди и сам добудь флаг, халявшик}Скрипт эксплойта
python#!/usr/bin/env python3
import base64
import json
import random
import re
import string
import zlib
from math import prod
from uuid import UUID
import requests
from sympy import factorint
from sympy.ntheory.modular import crt
BASE = "https://sixseven-lo1ydmdy.alfactf.ru"
MIN_FACTOR_BITS = 64
TARGET_BITS = 256
MAX_ATTEMPTS = 100
def randstr(n=12):
return "".join(random.choice(string.ascii_lowercase + string.digits) for _ in range(n))
def decode_flask_cookie_payload(cookie_value: str) -> dict:
"""
Flask session cookie подписана, но не зашифрована.
Поэтому payload можно прочитать без SECRET_KEY.
"""
compressed = cookie_value.startswith(".")
parts = cookie_value.split(".")
if compressed:
if len(parts) < 2:
raise ValueError("Bad compressed Flask cookie format")
payload_b64 = parts[1]
else:
payload_b64 = parts[0]
payload_b64 += "=" * (-len(payload_b64) % 4)
raw = base64.urlsafe_b64decode(payload_b64)
if compressed:
raw = zlib.decompress(raw)
return json.loads(raw)
def get_session_payload(session: requests.Session) -> dict:
cookie = session.cookies.get("sixseven_session") or session.cookies.get("session")
if not cookie:
raise RuntimeError("No Flask session cookie found")
return decode_flask_cookie_payload(cookie)
def get_user_id(session: requests.Session) -> int:
print("[debug] cookies:", session.cookies.get_dict())
payload = get_session_payload(session)
print("[debug] session payload:", payload)
if "user_id" not in payload:
raise RuntimeError("No user_id in Flask session payload")
return int(payload["user_id"])
def register_and_login() -> tuple[requests.Session, int]:
s = requests.Session()
username = "u" + randstr(12)
password = "P@ssw0rd_" + randstr(12)
print("[+] username:", username)
print("[+] password:", password)
r = s.get(BASE + "/register", timeout=20, allow_redirects=True)
print("[debug] GET /register:", r.status_code, r.url)
r = s.post(
BASE + "/register",
data={"username": username, "password": password},
allow_redirects=True,
timeout=20,
)
print("[debug] POST /register:", r.status_code, r.url)
r = s.post(
BASE + "/login",
data={"username": username, "password": password},
allow_redirects=True,
timeout=20,
)
print("[debug] POST /login:", r.status_code, r.url)
if not s.cookies.get_dict():
raise RuntimeError("Login failed: no cookies")
user_id = get_user_id(s)
print("[+] registered:", username)
print("[+] user_id:", user_id)
return s, user_id
def api_get_module(session: requests.Session, user_id: int) -> int:
r = session.get(BASE + f"/api/get_module?user_id={user_id}", timeout=20)
if r.status_code != 200:
raise RuntimeError("api_get_module failed")
data = r.json()
if "q" in data:
return int(data["q"])
if "module" in data:
return int(data["module"])
raise RuntimeError(f"Unknown get_module response: {data}")
def api_set_module(session: requests.Session, user_id: int, q: int):
url = BASE + f"/api/set_module?user_id={user_id}"
r = session.post(url, json={"q": str(q)}, timeout=20)
if r.status_code == 200:
return
r = session.post(url, data={"q": str(q)}, timeout=20)
if r.status_code != 200:
raise RuntimeError("api_set_module failed")
def api_calc_shares(session: requests.Session, user_id: int) -> dict[str, int]:
r = session.get(BASE + f"/api/calc_shares?user_id={user_id}", timeout=20)
if r.status_code != 200:
raise RuntimeError("api_calc_shares failed")
data = r.json()
shares = {}
if isinstance(data, dict) and "shares" in data:
for item in data["shares"]:
order_id = item.get("id") or item.get("order_id") or item.get("uuid")
share = item.get("share") or item.get("value") or item.get("y")
if order_id and share:
shares[str(order_id)] = int(share)
elif isinstance(data, dict):
for k, v in data.items():
try:
UUID(str(k))
shares[str(k)] = int(v)
except Exception:
pass
elif isinstance(data, list):
for item in data:
order_id = item.get("id") or item.get("order_id") or item.get("uuid")
share = item.get("share") or item.get("value") or item.get("y")
if order_id and share:
shares[str(order_id)] = int(share)
if not shares:
raise RuntimeError("No shares parsed")
return shares
def buy_espresso(session: requests.Session):
r = session.post(BASE + "/buy-espresso", allow_redirects=True, timeout=20)
if r.status_code != 200:
raise RuntimeError("Cannot buy espresso")
def extract_order_ids(session: requests.Session) -> list[str]:
r = session.get(BASE + "/dashboard", allow_redirects=True, timeout=20)
if r.status_code != 200:
raise RuntimeError("Cannot open dashboard for order extraction")
ids = sorted(
set(
re.findall(
r"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}",
r.text,
)
)
)
print("[+] found order ids:", ids)
if not ids:
raise RuntimeError("No order UUIDs found on dashboard")
return ids
def collect_congruences(session: requests.Session, user_id: int, order_ids: list[str]):
congruences = []
for order_id in order_ids:
x = UUID(order_id).int
factors = factorint(x)
for p, power in factors.items():
if p.bit_length() < MIN_FACTOR_BITS:
continue
print(f"[+] trying factor p={p}, bits={p.bit_length()}")
api_set_module(session, user_id, p)
shares = api_calc_shares(session, user_id)
if order_id not in shares:
continue
secret_mod_p = shares[order_id] % p
if all(existing_p != p for _, existing_p in congruences):
congruences.append((secret_mod_p, p))
modulus_product = prod(m for _, m in congruences)
print(f" CRT modulus bits = {modulus_product.bit_length()}")
if modulus_product.bit_length() > TARGET_BITS:
return congruences
return congruences
def recover_secret(congruences):
residues = [a for a, _ in congruences]
moduli = [m for _, m in congruences]
result = crt(moduli, residues)
if result is None:
raise RuntimeError("CRT failed")
return int(result[0]), int(result[1])
def submit_coupon(session: requests.Session, coupon: int) -> str:
r = session.post(
BASE + "/buy-exclusive",
data={"coupon": str(coupon)},
allow_redirects=True,
timeout=20,
)
payload = get_session_payload(session)
if "last_exclusive_order_id" in payload:
exclusive_order_id = payload["last_exclusive_order_id"]
print("[+] coupon accepted, exclusive order id:", exclusive_order_id)
return exclusive_order_id
raise RuntimeError("Coupon was not accepted")
def save_exclusive_qr(session: requests.Session, exclusive_order_id: str):
qr_url = BASE + f"/orders/{exclusive_order_id}/qr.svg"
qr = session.get(qr_url, timeout=20)
if qr.status_code != 200:
raise RuntimeError("Could not download exclusive QR")
with open("flag.svg", "wb") as f:
f.write(qr.content)
print("[+] saved flag.svg")
print("[+] Decode:")
print(" rsvg-convert -w 1200 -h 1200 flag.svg -o flag.png")
print(" zbarimg --raw -Sqr.enable flag.png")
def solve_once(attempt: int) -> bool:
print("=" * 80)
print(f"[+] ATTEMPT {attempt}")
print("=" * 80)
s, user_id = register_and_login()
for i in range(4):
print(f"[+] buying espresso {i + 1}/4")
buy_espresso(s)
order_ids = extract_order_ids(s)
congruences = collect_congruences(s, user_id, order_ids)
if not congruences:
print("[!] No congruences collected")
return False
modulus_product = prod(m for _, m in congruences)
print("[+] collected congruences:")
for a, m in congruences:
print(f" secret ≡ {a} mod {m} ; bits={m.bit_length()}")
print("[+] total CRT modulus bits:", modulus_product.bit_length())
if modulus_product.bit_length() <= TARGET_BITS:
print("[!] Недостаточно больших множителей, пробуем новый аккаунт.")
return False
coupon, modulus = recover_secret(congruences)
print("[+] recovered coupon:", coupon)
print("[+] CRT modulus bits:", modulus.bit_length())
exclusive_order_id = submit_coupon(s, coupon)
print("[!] Важно: QR одноразовый.")
print("[!] Не открывай dashboard в браузере до сохранения QR.")
save_exclusive_qr(s, exclusive_order_id)
return True
def main():
for attempt in range(1, MAX_ATTEMPTS + 1):
try:
ok = solve_once(attempt)
if ok:
print("[+] SUCCESS")
return
except KeyboardInterrupt:
raise
except Exception as e:
print(f"[!] attempt {attempt} failed:", e)
print(f"[!] Не удалось за {MAX_ATTEMPTS} попыток.")
print("[!] Можно увеличить MAX_ATTEMPTS и запустить снова.")
if __name__ == "__main__":
main()bashpython3 solve.py rsvg-convert -w 1200 -h 1200 flag.svg -o flag.png zbarimg --raw -Sqr.enable flag.png