Files
Serdtse-Sysoly/finals/caplagcombat-web-hard/WRITEUP.md
T
2026-09-17 00:50:07 +03:00

8.4 KiB
Raw Blame History

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. Запрашиваем его напрямую из авторизованной сессии:

GET /api/ctf/debug-bundle.js

Скрипт создаёт window.__CAPLAG_DEBUG__, внутри которого лежит первый флаг. Он читается прямо из тела ответа.

Проверка параметров тапа

POST /api/ctf/tap/claim принимает taps, tapPower и comboMultiplier. Начисление считается как произведение этих трёх чисел, а силу тапа сервер берёт у клиента без сверки с прогрессом, ограничивая лишь диапазоны, включая tapPower до 10000000 и comboMultiplier до 100.

Отправляем 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-инъекцию, открывающую скрытые строки:

' OR is_hidden = true --

Значение передаём в q с URL-кодированием. Условие позволяет вернуть контракт с is_hidden = true и четвёртым ответом в поле flag. Фильтр вырезает слова вроде union и select и блокирует составные запросы, но пропускает одинарную кавычку и OR, поэтому для этой ветки достаточно изменить логическое условие.

Вместо комментария можно закрыть строку поиска и открыть её снова, чтобы сохранить хвост исходного запроса:

' 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. Шаблон действителен в течение десяти минут после получения. При проверке подписи обработчик использует первое значение каждого ключа, а для авторизации — последнее.

Дописываем в конец шаблона дубли:

&ctf_role=auditor&ctf_claim=claim_hard_flag

Затем отправляем изменённую строку в POST /api/ctf/telegram-proof:

forged = template + "&ctf_role=auditor&ctf_claim=claim_hard_flag"
payload = {"initData": forged}

Подписанные первые значения остались прежними, поэтому подпись сходится. Проверка прав видит последние auditor и claim_hard_flag и возвращает пятый флаг.

Солвер.

Все этапы

Этап Флаг
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_).