Race conditon в веб-приложениях

Race conditon в веб-приложениях

Многие недооценивают такую уязвимость, как race condition. А также неправильно ее эксплуатируют. Автор расскажет как и с чем ее готовить.

Антон Лопаницын

При написании программ человек берет простейшие алгоритмы, которые он объединяет в единый сюжет, что и будет являться сценарием программы. Предположим, задача программиста — написать логику переводов денег (баллов, кредитов) от одного человека к другому в веб-приложении. Руководствуясь логикой можно составить алгоритм, состоящий из нескольких проверок.

Вася хочет перевести 100 долларов, которые есть у него на счету, Пете. Он переходит на вкладку переводов, вбивает Петин ник и в поле с количеством средств, которые необходимо перевести — цифру 100. Далее, нажимает на кнопку перевода. Данные кому и сколько отправляются на веб-приложение. Что может происходить внутри? Что необходимо сделать программисту, чтобы все работало корректно?

Убедиться, что у Васи достаточно денег на счету

Нужно убедиться, что сумма доступна Васе для перевода. Необходимо получить значение текущего баланса пользователя, если оно меньше чем сумма, которую он хочет перевести — сказать ему об этом. С учетом того, что на нашем сайте не предусмотрены кредиты и в минус баланс уйти не должен.

Вычесть сумму, которую необходимо перевести из баланса пользователя

Необходимо записать в значение баланса текущего пользователя его баланс с вычетом переводимой суммы. Было 100, стало 100-100=0.

Добавить к балансу пользователя Петя сумму которую перевели.

Пете наоборот, было 0, стало 0+100=100.

Вывести сообщение пользователю, что он молодец!

У нас получается примерно такой псевдокод:

Если (Вася.баланс >= сумма_перевода) То

Вася.Баланс=Вася.Баланс-сумма_перевода

Петя.Баланс=Петя.Баланс+сумма_перевода

Поздравление()

Иначе

Ошибка()

Но всё бы ничего, если бы все происходило в одном потоке. Тогда бы все запросы выполнялись друг за другом. Но так как сайт может обслуживать одновременно множество пользователей, это все происходит не в одном потоке, потому что современные веб-приложения используют многопроцессорность и многопоточность для параллельной обработки данных. С появлением многопоточности у программ появилась забавная архитектурная уязвимость состояние гонки (или race condition). А теперь представим, что наш алгоритм срабатывает одновременно 3 раза.

У Васи все так же 100 баксов на балансе, только вот каким-то образом он обратился к веб-приложению тремя потоками одновременно (с минимальным количеством времени между запросами). Все три потока проверяют, существует ли пользователь Петя, и проверяют, достаточно ли баланса у Васи для перевода. В тот момент времени, когда алгоритм проверяет баланс, он всё еще равен 100. Как только проверка пройдена, из текущего баланса 3 раза вычитается 100, и добавляется Пете.

Что мы имеем? У Васи на счету минусовой баланс (100 - 300 = -200 долларов). Тем временем, у Пети 300 долларов, хотя фактически, должно быть 100. Это и есть типичный пример эксплуатации состояния гонки. Сравнимо с тем, что по одному пропуску проходят сразу несколько человек.

Скриншот by @4lemon

Также race condition применяется, например, для повышения привилегий в операционных системах.

Типичная гонка

“Заходит хакер в кальянную, квест и бар, а ему — у вас race condition!” - Omar Ganiev

В большинстве случаев для проверки/эксплуатации состояния гонки используют многопоточное программное обеспечение в качестве клиента. Например, burp suite и его инструмент Intruder. Ставят один HTTP-запрос на повторение, устанавливают много потоков и включают флуд. Как например, в этой статье. Это достаточно рабочий способ, если сервер допускает использование множества потоков на свой ресурс. Но дело в том, что в некоторых ситуациях, это может быть не столь эффективно. Особенно если вспомнить, как подобные приложения обращаются к серверу.

Что там на сервере

Каждый поток устанавливает TCP соединение, отправляет данные, ждет ответа, закрывает соединение, открывает снова, отправляет данные и так далее. На первый взгляд, все данные отправляются одновременно, но сами HTTP-запросы могут приходить не синхронно и в разнобой из-за особенностей транспортного уровня, необходимости устанавливать защищенное соединение (HTTPS) и резолвить DNS (не в случае с burp’ом) и множества слоёв абстракций, которые проходят данные до отправки в сетевое устройство. Когда речь идет о миллисекундах, это может сыграть ключевую роль.

Конвейерная обработка HTTP

Можно вспомнить о HTTP-Pipelining, в котором можно отправлять данные с помощью одного сокета. Ты можешь сам посмотреть как это работает, использовав утилиту netcat (у тебя же есть GNU/Linux, ведь так?).

На самом деле использовать linux необходимо по многим причинам, ведь там более современный стек TCP/IP, который поддерживается ядрами операционной системы. Сервер скорее всего тоже на нем.

Например, выполни команду nc google.com 80 и вставь туда строки

GET / HTTP/1.1

Host: google.com

GET / HTTP/1.1

Host: google.com

GET / HTTP/1.1

Host: google.com

Таким образом, в рамках одного соединения будет отправлено три HTTP-запроса, и ты получишь три HTTP ответа. Эту особенность можно использовать для минимизации времени между запросами.

Что там на сервере

Веб-сервер получит запросы последовательно (ключевое слово), и обработает ответы в порядке очереди. Эту особенность можно использовать для атаки в несколько шагов (когда необходимо последовательно выполнить два действия в минимальное количество времени) или, например, для замедления работы сервера в первом запросе, чтобы увеличить успешность атаки*.

Трюк - ты можешь мешать серверу обработать твой запрос нагружая его СУБД, особенно эффективно если будет использован INSERT/UPDATE. Более тяжелые запросы могут “затормозить” твою нагрузку, тем самым, будет большая вероятность, что ты выиграешь эту гонку.

Разбиение HTTP-запроса на две части

Для начала вспомни как формируется HTTP-запрос.

Ну как ты знаешь, первая строка это метод, путь и версия протокола:

GET / HTTP/1.1

Второй и последующий это заголовки и их содержимое

Host: google.com

Cookie: a=1

Но как веб-сервер узнает, что HTTP-запрос закончился?

Давай рассмотрим на примере, введи nc google.com 80, а там

GET / HTTP/1.1

Host: google.com

после того, как нажмешь ENTER, ничего не произойдет. Нажмешь еще раз — увидишь ответ.

То есть, чтобы веб-сервер принял HTTP-запрос, необходимо два перевода строки. А корректный запрос выглядит так:

GET / HTTP/1.1\r\nHost: google.com\r\n\r\n

Если бы это был метод POST (не забываем про Content-Length), то корректный HTTP-запрос был бы таким:

POST / HTTP/1.1

Host: google.com

Content-Length: 3

a=1

Или POST / HTTP/1.1\r\nHost: google.com\r\nContent-Length: 3\r\na=1\r\n\r\n

Попробуй отправить подобный запрос из командной строки:

echo -ne "GET / HTTP/1.1\r\nHost: google.com\r\n\r\n" | nc google.com 80

В итоге ты получишь ответ, так как наш HTTP-запрос полноценный. Но если ты уберешь последний символ \n, то ответа не получишь.

На самом деле многим веб-серверам достаточно использовать в качестве переноса \n, поэтому важно не менять местами \r и \n, иначе дальнейшие трюки могут не получиться.

Что это дает? Ты можешь одновременно открыть множество соединений на ресурс, отправить 99% своего HTTP-запроса и оставив неотправленным последний байт. Сервер будет ждать пока ты не дошлёшь последний символ перевода строки. После того, как будет ясно, что основная часть данных отправлена — дослать последний байт (или несколько).

Это особенно важно, если речь идет о большом POST-запросе, например, когда необходима заливка файла. Но и даже в небольшом запросе это имеет смысл, так как доставить несколько байт намного быстрее, чем одновременно килобайты информации.

Задержка перед отправкой второй части запроса

По результатам исследования Влада Роскова, нужно не только расщеплять запрос, но и имеет смысл делать задержку в несколько секунд между отправкой основной части данных и завершающей. А всё потому, что веб-сервера начинают парсить запросы еще до того, как получат его целиком.

Points scored

Что там на сервере

Например nginx при получении заголовков HTTP-запроса начнет их парсить, складывая неполноценный запрос в кэш. Когда придет последний байт — веб-сервер возьмет частично обработанный запрос и отправит его уже непосредственно приложению, тем самым сокращается время обработки запросов, что повышает вероятность атаки.

Кстати, как с этим бороться

В первую очередь это конечно же архитектурная проблема, если правильно спроектировать веб-приложение, можно избежать подобных гонок.

Обычно, применяют следующие методы борьбы с атакой:

Используют блокировки.

Операция блокирует в СУБД обращения к заблокированному объекту, пока его не разблокируют. Другие стоят и ждут в сторонке. Необходимо правильно работать с блокировками, не блокировать ничего лишнего.

Рулят изоляциями транзакций.
Упорядоченные транзакции (serializable) - гарантируют, что транзакции будут выполнены строго последовательно, однако, это может сказаться на производительности.

Используют мьютексные семафоры (простихоспади).

Берут какую-нибудь штуку (например etcd). В момент вызова функций создают запись с ключем, если не получилось создать запись, значит она уже есть и тогда запрос прерывается. По окончании обработки запроса запись удаляется.

И вообще мне понравилось видео выступления Ивана Работяги про блокировки и транзакции, очень познавательно.

Особенности сессий в race condition

Одна из особенностей сессий может быть то, что она сама по-себе мешает эксплуатировать гонку. Например, в языке PHP после session_start() происходит блокировка сессионного файла, и его разблокировка наступит только по окончанию работы сценария (если не было вызова session_write_close). Если в этот момент вызван другой сценарий который использует сессию, он будет ждать.

Для обхода этой особенности можно использовать простой трюк — выполнить аутентификацию нужное количество раз. Если веб-приложение разрешает создавать множество сессий для одного пользователя, просто собираем все PHPSESSID и делаем каждому запросу свой идентификатор.

Близость к серверу

Если сайт, на котором необходимо эксплуатировать race condition хостится в AWS — возьми тачку в AWS. Если в DigitalOcean — бери там.

Когда задача отправить запросы и минимизировать промежуток отправки между ними, непосредственная близость к веб-серверу несомненно будет плюсом.

Ведь есть разница, когда ping к серверу 200 и 10 мс. А если повезет, вы вообще можете оказаться на одном физическом сервере, тогда зарейсить будет немного проще :)

Подводя черту

Для успешного race condition можно применять различные трюки для увеличения вероятности успеха. Отправлять несколько запросов (keep-alive) в одном, замедляя веб-сервер. Разбивать запрос на несколько частей и создавать задержку перед отправкой. Уменьшать расстояние до сервера и количество абстракций до сетевого интерфейса.

В результате этого анализа мы вместе с Michail Badin разработали инструмент RacePWN

Он состоит из двух компонентов:

Библиотеки librace на языке C, которая за минимальное время и используя большинство фишек из статьи отправляет множество HTTP-запросов на сервер

Утилиты racepwn, которая принимает на вход json-конфигурацию и вообще рулит этой библиотекой

RacePWN можно интегрировать в другие утилиты (например в Burp Suite), или создать веб-интерфейс для управления рейсами (все никак руки не доходят). Enjoy!

Но на самом деле ещё есть куда расти и можно вспомнить о HTTP/2 и его перспективы для атаки. Но в данный момент HTTP/2 у большинство ресурсов лишь фронт, проксирующий запросы в старый-добрый HTTP/1.1.

Может ты знаешь еще какие-то тонкости?


Ищем баги вместе! Но не те, что в продакшене...

Разбираем кейсы, делимся опытом, учимся на чужих ошибках

Зафиксируйте уязвимость своих знаний — подпишитесь!