CaplagCombat

Web hard

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_`).