Книга «Безопасность в PHP» (часть 5.1). Нехватка энтропии для случайных значений
https://habr.com/company/mailru/blog/352446/Случайные значения в PHP повсюду. Во всех фреймворках, во многих библиотеках. Вероятно, вы и сами написали кучу кода, использующего случайные значения для генерирования токенов и солей, а также в качестве входных данных для функций. Также случайные значения играют важную роль при решении самых разных задач:
- Для случайного выбора опций из пула или диапазона известных опций.
- Для генерирования векторов инициализации при шифровании.
- Для генерирования непредсказуемых токенов или одноразовых значений при авторизации.
- Для генерирования уникальных идентификаторов, например ID сессий.
Во всех этих случаях имеется характерная уязвимость. Если атакующий угадает или предскажет выходные данные вашего генератора случайных чисел (RNG, Random Number Generator) или генератора псевдослучайных чисел (PRNG, Pseudo-Random Number Generator), то он сможет вычислить токены, соли, одноразовые значения и криптографические векторы инициализации, создаваемые с помощью этого генератора. Поэтому очень важно генерировать высококачественные случайные значения, т. е. те, которые крайне трудно предсказать. Ни в коем случае не допускайте предсказуемости токенов сброса паролей, CSRF-токенов, ключей API, одноразовых значений и токенов авторизации!
В PHP со случайными значениями связаны ещё две потенциальные уязвимости:
- Раскрытие информации (Information Disclosure).
- Нехватка энтропии (Insufficient Entropy).
В данном контексте «раскрытие информации» относится к утечке внутреннего состояния генератора псевдослучайных чисел — его начального значения (seed value). Подобные утечки могут сильно облегчить предсказывание будущих выходных данных PRNG.
«Нехватка энтропии» описывает ситуацию, когда вариативность начального внутреннего состояния (seed) PRNG или его выходных данных столь мала, что весь диапазон возможных значений относительно легко перебирается брутфорсом. Не слишком хорошие новости для PHP-программистов.
Мы подробно рассмотрим обе уязвимости с примерами сценариев атак. Но сначала давайте разберёмся, что на самом деле представляет собой случайное значение, когда речь идёт о программировании на PHP.
Что делают случайные значения?
Путаница относительно предназначения случайных величин усугубляется и общим непониманием. Несомненно, вы слышали о разнице между криптографически стойкими случайными значениями и расплывчатыми «уникальными» значениями «для других видов использования». Основное впечатление — используемые в криптографии случайные значения требуют высококачественной случайности (или, точнее, высокой энтропии), а значения для других областей применения могут обойтись меньшей энтропией. Я считаю это впечатление фальшивым и контрпродуктивным. Реальное различие между непредсказуемыми случайными значениями и теми, что нужны для тривиальных задач, лишь в том, что предсказуемость вторых не влечёт за собой вредных последствий. Это вообще исключает криптографию из рассмотрения вопроса. Иными словами, если вы используете случайное значение в нетривиальной задаче, то автоматически должны выбрать гораздо более сильные RNG.
Сила случайных значений определяется затраченной для их генерирования энтропией. Энтропия — это мера неопределённости, выраженная в «битах». Например, если я возьму двоичный бит, его значение может быть 0 или 1. Если атакующий не знает точное значение, то мы имеем энтропию 2 бита (т. е. подбрасывание монеты). Если атакующий знает, что значение всегда равно 1, то мы имеем энтропию 0 бит, поскольку предсказуемость — антоним неопределённости. Также количество бит может находиться в диапазоне от 0 до 2. Например, если 99 % времени двоичный бит равен 1, то энтропия может чуть-чуть превышать 0. Так что чем более неопределённые двоичные биты мы выбираем, тем лучше.
В PHP это можно увидеть более наглядно. Функция mt_rand()
генерирует случайные значения, это всегда цифры. Она не выдаёт буквы, специальные символы или иные значения. Это означает, что на каждый байт у атакующего приходится гораздо меньше догадок, т. е. энтропия низкая. Если заменить mt_rand()
чтением байтов из Linux-источника /dev/random
, то мы получим по-настоящему случайные байты: они генерируются на основе шума, формируемого драйверами системных устройств и прочими источниками. Очевидно, что этот вариант гораздо лучше, потому что обеспечивает значительно больше бит энтропии.
О нежелательности mt_rand()
говорит и то, что это генератор не истинно случайных, а псевдослучайных чисел, или, как его ещё называют, детерминированный генератор случайных двоичных последовательностей (Deterministic Random Bit Generator, DRBG). В нём реализован алгоритм под названием «вихрь Мерсенна» (Mersenne Twister), который генерирует числа, распределённые таким образом, чтобы результат получался приближенным к результату работы генератора истинно случайных чисел. mt_rand()
использует только одно случайное значение — начальное (seed), на его основе фиксированный алгоритм генерирует псевдослучайные значения.
Взгляните на этот пример, вы можете протестировать его самостоятельно:
mt_srand(1361152757.2); for ($i=1; $i < 25; $i++) { echo mt_rand(), PHP_EOL; }
Это простой цикл, исполняемый после того, как PHP-функция вихря Мерсенна получила начальное, заранее установленное значение. Оно было получено на выходе функции, приводимой в качестве примера в документации к mt_srand()
и использующей текущие секунды и микросекунды. Если выполнить приведённый код, то он выведет на экран 25 псевдослучайных чисел. Они выглядят случайными, никаких совпадений, всё прекрасно. Снова выполним код. Что-нибудь заметили? Именно: выводятся ТЕ ЖЕ САМЫЕ числа. Запустим в третий, четвёртый, пятый раз. В старых версиях PHP результат может быть разным, но это не относится к проблеме, поскольку она характерна для всех современных версий PHP.
Если атакующий получит начальное значение такого PRNG, то он сможет предсказать все выходные данные mt_rand()
. Так что защита начального значения — дело первостепенной важности. Если вы его потеряете, то больше не имеете права генерировать случайные значения…
Вы можете сгенерировать начальное значение одним из двух способов:
- вручную, с помощью функции
mt_srand()
, - вы проигнорируете
mt_srand()
и позволите PHP сгенерировать его автоматически.
Второй вариант предпочтительнее, но и сегодня легаси-приложения зачастую наследуют применение mt_srand()
, даже после портирования на более современные версии PHP.
Это повышает риск того, что атакующий восстановит начальное значение (атака Seed Recovery Attack), что даст ему достаточно информации для предсказания будущих значений. В результате любое приложение после подобной утечки становится уязвимым для атаки раскрытия информации. Это самая настоящая уязвимость, несмотря на её очевидно пассивную природу. Утечка информации о локальной системе способна помочь атакующему в последующих атаках, что нарушит принцип эшелонированной защиты.
Случайные значения в PHP
В PHP используется три PRNG, и если злоумышленник получит доступ к начальным значениям, применяемым в их алгоритмах, то он сможет предсказывать результаты их работы:
- Линейный конгруэнтный генератор (Linear Congruential Generator, LCG),
lcg_value()
. - Вихрь Мерсенна,
mt_rand()
. - Локально поддерживаемая C-функция
rand()
.
Также эти генераторы применяются для внутренних нужд, для функций вроде array_rand()
и uniqid()
. Это означает, что злоумышленник может предсказывать выходные данные этих и других функций, использующих внутренние PRNG языка PHP, если заполучит все необходимые начальные значения. Также это означает, что не получится улучшить защиту, запутав нападающего с помощью многочисленных обращений к генераторам. Особенно это касается open source приложений. Злоумышленник способен предсказать ВСЕ выходные данные для любого известного ему начального значения.
Чтобы повысить качество генерируемых случайных значений для нетривиальных задач, PHP нужны внешние источники энтропии, предоставляемой операционной системой. В Linux обычно применяют /dev/urandom
, можно считывать его напрямую либо обращаться не напрямую, с помощью функций openssl_pseudo_random_bytes()
или mcrypt_create_iv()
. Обе они могут использовать и криптографически безопасный генератор псевдослучайных чисел (CSPRNG) в Windows, но в PHP в пользовательском пространстве пока нет прямого метода получения данных от этого генератора без расширений, обеспечиваемых этими функциями. Иными словами, убедитесь, что в вашей серверной версии PHP включено расширение OpenSSL или Mcrypt.
/dev/urandom
— PRNG, но зачастую он получает новые начальные значения от высокоэнтропийного источника /dev/random
. Это делает его неинтересной целью для злоумышленника. Мы стараемся избегать прямого чтения из /dev/random
, потому что это блокирующий ресурс. Если он исчерпает энтропию, то все чтения будут заблокированы, пока снова не наберётся достаточно энтропии от системного окружения. Хотя для наиболее важных задач следует использовать именно /dev/random
.
Всё это приводит нас к правилу:
Все процессы, подразумевающие применение нетривиальных случайных чисел, ДОЛЖНЫ использовать openssl_pseudo_random_bytes(). В качестве альтернативы вы МОЖЕТЕ попытаться напрямую считывать байты из /dev/urandom. Если ни один вариант не сработал и у вас нет выбора, то вы ДОЛЖНЫ генерировать значение с помощью сильного смешивания данных от нескольких доступных источников случайных или секретных значений.
Базовую реализацию этого правила вы найдёте в эталонной библиотеке SecurityMultiTool. Как обычно, внутренности PHP предпочитают усложнять жизнь программистам вместо прямого включения безопасных решений в ядро PHP.
Хватит теории, теперь давайте посмотрим, как можно атаковать приложение, вооружившись вышеописанным.
Атака на генераторы случайных чисел в PHP
По ряду причин в PHP для решения нетривиальных задач используются PRNG.
Функция openssl_pseudo_random_bytes()
была доступна только в PHP 5.3. В Windows она создавала проблемы с блокировкой, пока не вышла версия 5.3.4. Также в PHP 5.3 функция mcrypt_create_iv()
в Windows стала поддерживать источник MCRYPT_DEV_URANDOM. До этого в Windows поддерживался только MCRYPT_RAND — по сути, тот же системный PRNG, используемый для внутренних нужд функцией rand()
. Как видите, до появления PHP 5.3 было немало пробелов, так что многие легаси-приложения, написанные на предыдущих версиях, могли и не переключиться на более сильные PRNG.
Выбор расширений Openssl и Mcrypt — на ваше усмотрение. Поскольку нельзя положиться на их доступность даже на серверах с PHP 5.3, приложения часто используют PRNG, встроенные в PHP, в качестве запасного варианта для генерирования нетривиальных случайных значений.
Но в обоих случаях мы имеем нетривиальные задачи, которые применяют случайные значения, сгенерированные с помощью PRNG с низкоэнтропийными начальными значениями. Это делает нас уязвимыми к атакам с восстановлением начальных значений. Давайте рассмотрим простой пример.
Представим, что мы нашли в онлайне приложение, использующее следующий код для генерирования токенов, которые применяются в разных задачах по всему приложению:
$token = hash('sha512', mt_rand());
Есть и более сложные средства генерирования токенов, но это неплохой вариант. Здесь используется только один вызов mt_rand()
, захешированный с помощью SHA512. На практике, если программист решит, что функции случайных значений в PHP «достаточно случайны», то он наверняка выберет упрощённый подход, пока не прозвучит слово «криптография». Например, к некриптографическим случаям относятся токены доступа, CSRF-токены, одноразовые значения API и токены сброса паролей. Прежде чем продолжить, я подробно распишу всю степень уязвимости этого приложения, чтобы вы лучше понимали, что вообще делает приложения уязвимыми.
Характеристики уязвимого приложения
Это не исчерпывающий список. На практике список характеристик может отличаться!
1. Сервер применяет mod_php, который при использовании KeepAlive позволяет обслуживать несколько запросов одним и тем же PHP-процессом
Это важно потому, что генераторы случайных чисел в PHP получают начальные значения единожды на один процесс. Если мы можем сделать к процессу два запроса и более, то он будет использовать одно и то же начальное значение. Суть атаки заключается в том, чтобы применить раскрытие одного токена для извлечения начального значения, которое нужно для предсказания другого токена, генерируемого на основе ТОГО ЖЕ начального значения (т. е. в том же процессе). Поскольку mod_php идеально подходит для использования нескольких запросов для получения связанных случайных значений, то иногда с помощью всего одного запроса можно извлечь несколько значений, относящихся к mt_rand()
. Это делает избыточными любые требования к mod_php. Например, часть энтропии, используемой для генерирования начального значения для mt_rand()
, может утечь через ID сессий или выходные значения в том же запросе.
2. Сервер раскрывает CSRF-токены, токены сброса паролей или подтверждения аккаунтов, сгенерированные на основе mt_rand()-токенов
Для извлечения начального значения нам нужно напрямую проверить число, созданное генераторами в PHP. Причём даже неважно, как оно используется. Мы можем извлечь его из любого доступного значения, будь то выходные данные mt_rand()
, или хешированный CSRF, или токен подтверждения аккаунта. Подойдут даже косвенные источники, у которых случайное значение определяет иное поведение на выходе, что раскрывает это самое значение. Главное ограничение заключается в том, что оно должно быть из того же процесса, генерирующего второй токен, который мы пытаемся предсказать. А это уязвимость «раскрытие информации». Как мы скоро увидим, утечка выходных данных PRNG может быть крайне опасна. Обратите внимание, что уязвимость не ограничена единственным приложением: вы можете считывать выходные данные PRNG в одном приложении на сервере и применять их для определения выходных данных в другом приложении на том же сервере, если оба они используют один PHP-процесс.
3. Известный слабый алгоритм генерирования токенов
Вы можете вычислить его:
- покопавшись в исходниках open source приложения,
- дав взятку сотруднику с доступом к личному исходному коду,
- найдя бывшего сотрудника, затаившего обиду на бывшего работодателя,
- или просто предположив, какой алгоритм тут может быть.
Некоторые методы генерирования токенов более очевидны, некоторые — более популярны. По-настоящему слабые средства генерирования отличаются использованием одного из генераторов случайных чисел PHP (например, mt_rand()
), слабой энтропией (нет других источников неопределённых данных) и/или слабым хешированием (например, MD5 или вообще без хеширования). Рассмотренный выше пример кода как раз имеет признаки слабого метода генерирования. Также я использовал хеширование SHA512, чтобы продемонстрировать, что маскировка — это всегда неудовлетворительное решение. SHA512 — слабое хеширование, поскольку оно быстро вычисляется, т. е. атакующий может с невероятной скоростью брутфорсить входные данные на любых CPU или GPU. И не забывайте, что закон Мура тоже ещё действует, а значит, скорость брутфорса будет расти с каждым новым поколением CPU/GPU. Поэтому пароли должны хешироваться с помощью инструментов, взлом результатов которых требует фиксированного времени вне зависимости от производительности процессоров или закона Мура.
Выполнение атаки
Наша атака достаточно проста. В рамках подключения к PHP-процессу мы проведём быструю сессию и отправим два отдельных HTTP-запроса (запрос А и запрос Б). Сессия будет удерживаться сервером, пока не будет получен второй запрос. Запрос А нацелен на получение какого-нибудь доступного токена вроде CSRF, токена сброса пароля (отправляется атакующему по почте) или чего-то подобного. Не забывайте и о других возможностях вроде встроенной разметки (inline markup), используемых в запросах произвольных ID и т. д. Мы будем мучить исходный токен, пока он нам не выдаст своё начальное значение. Всё это — часть атаки с восстановлением начального значения: когда у начального значения такая маленькая энтропия, что его можно брутфорсить или поискать в заранее вычисленной радужной таблице.
Запрос Б будет решать более интересную задачу. Давайте сделаем запрос на сброс локального администраторского пароля. Это запустит генерирование токена (с помощью случайного числа на базе того же начального значения, которые мы вытаскиваем с помощью запроса А, если оба запроса успешно отправляются на один и тот же PHP-процесс). Этот токен будет храниться в базе данных в ожидании момента, когда администратор воспользуется ссылкой сброса пароля, отправленной ему на почту. Если мы сможем извлечь начальное значение для токена из запроса А, то, зная, как генерируется токен из запроса Б, мы предскажем токен сброса пароля. А значит, сможем перейти по ссылке сброса до того, как администратор прочитает письмо!
Вот последовательность развития событий:
- С помощью запроса A получаем токен и подвергаем его обратному инжинирингу для вычисления начального значения.
- С помощью запроса Б получаем токен, сгенерированный на базе того же начального значения. Этот токен хранится в базе данных приложения для будущего сброса пароля.
- Взламываем хеш SHA512, чтобы достать сгенерированное сервером случайное число.
- С помощью полученного случайного значения брутфорсим начальное значение, которое было сгенерировано с его помощью.
- Используем начальное значение для вычисления серии случайных значений, которые, вероятно, могут лежать в основе токена сброса пароля.
- Используем этот токен(-ы) для сброса пароля администратора.
- Получаем доступ к администраторскому аккаунту, развлекаемся и получаем выгоду. Ну, как минимум развлекаемся.
Займёмся хакингом...
Пошаговый взлом приложения
Шаг 1. Осуществляем запрос А для извлечения токена
Мы исходим из того, что целевой токен и токен сброса пароля зависят от выходных данных mt_rand()
. Поэтому нужно выбрать именно его. В приложении в нашем воображаемом сценарии все токены генерируются одним и тем же способом, так что можно просто извлечь CSRF-токен и сохранить его на будущее.
Шаг 2. Осуществляем запрос Б для получения токена сброса пароля, сгенерированного для администраторского аккаунта
Этот запрос представляет собой простую отправку формы сброса пароля. Токен будет сохранён в базе данных и отправлен пользователю по почте. Нам нужно правильно вычислить этот токен. Если характеристики сервера точны, то запрос Б использует тот же PHP-процесс, что и запрос А. Следовательно, в обоих случаях вызовы mt_rand()
будут применять одно и то же начальное значение. Можно даже использовать запрос А для захвата CSRF-токена формы сброса, чтобы включить ввод данных (submission) ради упорядочивания процедуры (исключаем промежуточный round trip).
Шаг 3. Взламываем хеширование SHA512 токена, полученного по запросу А
SHA512 внушает программистам благоговейный трепет: у него крупнейший номер во всём семействе алгоритмов SHA-2. Однако в методе генерирования токенов, выбранном нашей жертвой, есть одна проблема — случайные значения ограничены только цифрами (т. е. степень неопределённости, или энтропия, ничтожна). Если вы проверите выходные данные mt_getrandmax()
, то обнаружите, что наибольшее случайное число, которое может сгенерировать mt_rand()
, — 2,147 миллиарда с мелочью. Это ограниченное количество возможностей делает SHA512 уязвимым для брутфорса.
Только не верьте мне на слово. Если у вас есть дискретная видеокарта одного из последних поколений, то можно пойти следующим путём. Поскольку мы ищем одиночный хеш, то я решил воспользоваться замечательным инструментом для брутфорса — hashcat-lite. Это одна из самых быстрых версий hashcat, она есть для всех основных операционных систем, включая Windows.
С помощью этого кода сгенерируйте токен:
$rand = mt_rand(); echo "Random Number: ", $rand, PHP_EOL; $token = hash('sha512', $rand); echo "Token: ", $token, PHP_EOL;
Этот код воспроизводит токен из запроса А (он содержит нужное нам случайное число и спрятан в хеш SHA512) и прогоняет через hashcat:
./oclHashcat-lite64 -m1700 --pw-min=1 --pw-max=10 -1?d -o ./seed.txt <SHA512 Hash> ?d?d?d?d?d?d?d?d?d?d
Вот что означают все эти опции:
- -m1700: определяет алгоритм хеширования, где 1700 означает SHA512.
- --pw-min=1: определяет минимальную входную длину хешируемого значения.
- --pw-max=10: определяет максимальную входную длину хешируемого значения (10 для
mt_rand()
). - -1?d: определяет, что нам нужен кастомный словарь из одних лишь цифр (т. е. 0—9).
- -o ./seed.txt: файл для записи результатов. На экран ничего не выводится, так что не забудьте задать этот параметр!
- ?d?d?d?d?d?d?d?d?d?d: маска, задающая используемый формат (все цифры максимум до 10).
Если всё сработает правильно и ваш GPU не расплавится, Hashcat вычислит захешированное случайное число за пару минут. Да, минут. Ранее я уже объяснял, как работает энтропия. Убедитесь в этом сами. У функции mt_rand()
так мало возможностей, что SHA512-хеши всех значений реально вычислить за очень короткое время. Так что бессмысленно было хешировать выходные данные mt_rand()
.
Шаг 4. Восстанавливаем начальное значение с помощью свежевзломанного случайного числа
Как мы видели выше, на извлечение из SHA512 любого сгенерированного mt_rand()
значения требуется всего пара минут. Вооружившись случайным значением, мы можем запустить другой инструмент для брутфорса — php_mt_seed. Эта маленькая утилита берёт выходные данные mt_rand()
и после брутфорса вычисляет начальное значение, на основании которого могло быть сгенерировано анализируемое. Скачайте текущую версию, скомпилируйте и запустите. Если появятся проблемы с компиляцией, попробуйте более старую версию (с новыми у меня были проблемы с виртуальными средами).
./php_mt_seed <RANDOM NUMBER>
Это может занять немного больше времени, чем взлом SHA512, поскольку выполняется на CPU. На приличном процессоре утилита найдёт весь возможный диапазон начального значения за несколько минут. Результат — одно или несколько возможных значений (т. е. значений, на основании которых могло быть получено данное случайное число). Повторюсь: мы наблюдаем результат слабой энтропии, только на этот раз в отношении генерирования в PHP начальных значений для функции вихря Мерсенна. Позднее мы рассмотрим, как были сгенерированы эти значения, так что вы увидите, почему можно так быстро выполнять брутфорс.
Итак, до этого мы пользовались простыми инструментами взлома, доступными в сети. Они заточены под вызовы mt_rand()
, но иллюстрируют собой идею, которая может быть применена и к другим сценариям (например, последовательные вызовы mt_rand()
при генерировании токенов). Также имейте в виду, что скорость взлома не препятствует генерированию радужных таблиц, учитывающих конкретные подходы к генерированию токенов. Вот ещё один инструмент, эксплуатирующий уязвимости mt_rand()
и написанный на Python.
Шаг 5. Генерируем возможные токены сброса пароля администраторского аккаунта
Предположим, что в рамках запросов А и Б было сделано всего два запроса к mt_rand()
. Теперь начнём предсказывать токены, используя ранее вычисленные возможные начальные значения:
function predict($seed) { /** * Передаём в PRNG начальное значение */ mt_srand($seed); /** * Пропускаем вызов функции из запроса А */ mt_rand(); /** * Предсказываем и возвращаем сгенерированный в запросе Б токен */ $token = hash('sha512', mt_rand()); return $token; }
Эта функция предсказывает токен сброса для каждого возможного начального значения.