Вряд ли сейчас найдется человек, который бы ни разу не нажал кнопку «Восстановить пароль» в глубоком разочаровании. Даже если кажется, что пароль, без сомнения, правильный, следующий шаг его восстановления обычно проходит гладко: переход по ссылке из электронного письма и ввод нового пароля (не будем никого обманывать; он вряд ли новый, поскольку вы его только что набрали). три раза уже на шаге 1, прежде чем нажать противную кнопку).
Однако логика ссылок электронной почты требует тщательного изучения, поскольку небезопасность их создания открывает поток уязвимостей, связанных с несанкционированным доступом к учетным записям пользователей. К сожалению, вот один из примеров структуры URL-адреса восстановления на основе UUID, с которой многие, вероятно, сталкивались, но которая, тем не менее, не соответствует принципам безопасности:
https://.../recover/d17ff6da-f5bf-11ee-9ce2-35a784c01695
Использование такой ссылки обычно означает, что любой может получить ваш пароль, и это очень просто. Целью данной статьи является глубокое погружение в методы генерации UUID и выбор небезопасных подходов к их применению.
UUID — это 128-битная метка, обычно используемая для генерации псевдослучайных идентификаторов с двумя ценными атрибутами: она достаточно сложна и достаточно уникальна. В основном это ключевые требования к тому, чтобы идентификатор покидал серверную часть и отображался пользователю явно во внешнем интерфейсе или обычно отправлялся через API с возможностью наблюдения. Это затрудняет угадывание или перебор по сравнению с id = 123 (сложность) и предотвращает коллизии, когда сгенерированный идентификатор дублируется ранее использованным, например, случайным числом от 0 до 1000 (уникальность).
«Достаточные» части на самом деле берутся, во-первых, из некоторых версий Universally Unique IDentifier, что оставляет открытыми незначительные возможности для дублирования, что, однако, легко компенсируется дополнительной логикой сравнения и не представляет угрозы из-за плохо контролируемых условий для его возникновение. А во-вторых, в статье описан взгляд на сложность различных версий UUID, в целом он считается неплохим, за исключением дальнейших крайних случаев.
Первичные ключи в таблицах базы данных, похоже, основаны на тех же принципах сложности и уникальности, что и UUID. Благодаря широкому распространению встроенных методов его генерации во многих языках программирования и системах управления базами данных, UUID часто становится первым выбором для идентификации сохраненных записей данных и полем для объединения таблиц в целом и подтаблиц, разделенных путем нормализации. Отправка идентификаторов пользователей, поступающих из базы данных через API, в ответ на определенные действия также является распространенной практикой, позволяющей упростить процесс объединения потоков данных без дополнительной генерации временных идентификаторов и связывания их с идентификаторами в производственном хранилище данных.
Что касается примеров сброса пароля, то архитектура, скорее всего, включает в себя таблицу, отвечающую за такую операцию, которая вставляет строки данных со сгенерированным UUID каждый раз, когда пользователь нажимает кнопку. Он инициирует процесс восстановления, отправляя электронное письмо на адрес, связанный с пользователем по его user_id, и проверяя, для какого пользователя следует сбросить пароль, на основе имеющегося у него идентификатора после открытия ссылки для сброса. Однако существуют правила безопасности для таких идентификаторов, видимых пользователям, и некоторые реализации UUID соответствуют им с разной степенью успеха.
Версия 1 генерации UUID разделяет свои 128 бит на использование 48-битного MAC-адреса идентификатора, генерирующего устройство, 60-битной временной метки, 14-битного значения, хранящегося для увеличения значения, и 6-битного для управления версиями. Таким образом, гарантия уникальности передается от правил в логике кода производителям оборудования, которые должны правильно назначать значения для каждой новой машины в производстве. Если оставить только 60+14 бит для представления полезной изменяемой полезной нагрузки, целостность идентификатора ухудшается, особенно с такой прозрачной логикой, стоящей за ним. Давайте посмотрим на последовательность последовательно сгенерированных чисел UUID v1:
from uuid import uuid1 for _ in range(8): print(uuid1())
d17ff6da-f5bf-11ee-9ce2-35a784c01695 d17ff6db-f5bf-11ee-9ce2-35a784c01695 d17ff6dc-f5bf-11ee-9ce2-35a784c01695 d17ff6dd-f5bf-11ee-9ce2-35a784c01695 d17ff6de-f5bf-11ee-9ce2-35a784c01695 d17ff6df-f5bf-11ee-9ce2-35a784c01695 d17ff6e0-f5bf-11ee-9ce2-35a784c01695 d17ff6e1-f5bf-11ee-9ce2-35a784c01695
Как можно видеть, часть «-f5bf-11ee-9ce2-35a784c01695» остается неизменной все время. Изменяемая часть — это просто 16-битное шестнадцатеричное представление последовательности 3514824410 — 3514824417. Это поверхностный пример, поскольку производственные значения обычно генерируются с более значительными промежутками во времени, поэтому часть, связанная с временной меткой, также изменяется. 60-битная часть временной метки также означает, что более значительная часть идентификатора визуально изменяется в большей выборке идентификаторов. Суть остается прежней: UUIDv1 легко угадать, каким бы случайным он ни казался поначалу.
Возьмите только первое и последнее значения из данного списка из 8 идентификаторов. Поскольку идентификаторы генерируются строго, следовательно, ясно, что между данными двумя сгенерировано только 6 идентификаторов (путем вычитания шестнадцатеричных изменяемых частей), и их значения также могут быть окончательно найдены. Экстраполяция такой логики лежит в основе так называемой сэндвич-атаки, направленной на то, чтобы перебрать UUID, зная эти два граничных значения. Схема атаки проста: пользователь генерирует UUID A до того, как происходит создание целевого UUID, и сразу после этого UUID B. Предполагая, что за все три поколения отвечает одно и то же устройство со статической 48-битной частью MAC, оно устанавливает пользователю последовательность потенциальных идентификаторов между A и B, где находится целевой UUID. В зависимости от временной близости между сгенерированными идентификаторами и целью диапазон может быть в объемах, доступных для перебора: проверяйте все возможные UUID, чтобы найти существующие среди пустых.
В запросах API с конечной точкой восстановления пароля, описанной ранее, это означает отправку сотен или тысяч запросов с последовательными UUID, пока не будет найден ответ, указывающий существующий URL-адрес. При сбросе пароля это приводит к настройке, при которой пользователь может создать ссылки для восстановления в двух учетных записях, которые он контролирует, как можно точнее, чтобы нажать кнопку восстановления в целевой учетной записи, к которой у него нет доступа, но он знает только адрес электронной почты/логин. Затем становятся известны письма контролируемым учетным записям с UUID восстановления A и B, а целевая ссылка для восстановления пароля для целевой учетной записи может быть взломана без доступа к фактическому адресу электронной почты для сброса.
Уязвимость связана с концепцией использования исключительно UUIDv1 для аутентификации пользователя. Отправляя ссылку восстановления, предоставляющую доступ к сбросу паролей, предполагается, что, перейдя по ссылке, пользователь аутентифицируется как тот, кто должен был получить ссылку. Это та часть, где правило аутентификации не работает из-за того, что UUIDv1 подвергается прямому перебору, точно так же, как если бы чью-то дверь можно было открыть, зная, как выглядят ключи обеих соседних дверей.
Первая версия UUID в основном считается устаревшей, отчасти потому, что логика генерации использует только меньшую часть размера идентификатора в качестве рандомизированного значения. Другие версии, такие как v4, пытаются решить эту проблему, оставляя как можно меньше места для управления версиями и оставляя до 122 битов для случайной полезной нагрузки. В общем, это приводит к общему количеству возможных вариантов колоссального числа 2^122
, которое на данный момент считается удовлетворяющим «достаточной» части требований уникальности идентификатора и, таким образом, соответствует стандартам безопасности. Уязвимость для перебора может появиться, если реализация генерации каким-то образом значительно уменьшит биты, оставшиеся для случайной части. Но должно ли быть так, если нет производственных инструментов и библиотек?
Давайте немного углубимся в криптографию и внимательно рассмотрим общую реализацию генерации UUID в JavaScript. Вот функция randomUUID()
, использующая модуль math.random
для генерации псевдослучайных чисел:
Math.floor(Math.random()*0x10);
А сама случайная функция, короче это лишь часть интереса для темы данной статьи:
hi = 36969 * (hi & 0xFFFF) + (hi >> 16); lo = 18273 * (lo & 0xFFFF) + (lo >> 16); return ((hi << 16) + (lo & 0xFFFF)) / Math.pow(2, 32);
Для псевдослучайной генерации требуется начальное значение в качестве основы для выполнения над ним математических операций для создания последовательностей достаточно случайных чисел. Такие функции основаны исключительно на нем, а это означает, что если они повторно инициализируются с тем же начальным значением, что и раньше, выходная последовательность будет совпадать. Начальное значение в рассматриваемой функции JavaScript содержит переменные hi и lo, каждая из которых представляет собой 32-битное целое число без знака (от 0 до 4294967295 десятичных чисел). Комбинация того и другого необходима для криптографических целей, что делает практически невозможным окончательное изменение двух начальных значений, зная их кратное число, поскольку это зависит от сложности факторизации целых чисел с большими числами.
Два 32-битных целых числа вместе дают 2^64
возможных случая угадывания переменных hi и lo, стоящих за инициализированной функцией, создающей UUID. Если значения hi и lo каким-то образом известны, не требуется никаких усилий, чтобы продублировать функцию генерации и узнать все значения, которые она производит и будет производить в будущем из-за раскрытия начального значения. Однако 64-битные стандарты безопасности можно считать нетерпимыми к грубой силе в течение измеримого периода времени, чтобы это имело смысл. Как всегда, проблема связана с конкретной реализацией. Math.random()
преобразует различные 16 бит из каждого из значений hi и lo в 32-битные результаты; однако randomUUID()
поверх него снова сдвигает значение из-за операции .floor()
, и единственная значимая часть внезапно теперь исходит исключительно от hi. Это никоим образом не влияет на генерацию, но приводит к развалу криптографических подходов, поскольку оставляет только 2^32
возможных комбинаций для всего начального числа функции генерации (нет необходимости перебирать и hi, и lo, поскольку lo можно установить в любое значение). значение и не влияет на выход).
Поток грубой силы состоит из получения одного идентификатора и проверки возможных высоких значений, которые могли бы его сгенерировать. При некоторой оптимизации и среднем оборудовании ноутбука это может занять всего пару минут и не требует отправки большого количества запросов на сервер, как при сэндвич-атаке, а выполняет все операции в автономном режиме. Результат такого подхода приводит к репликации состояния функции генерации, используемой в бэкэнде, для получения всех созданных и будущих ссылок сброса в примере восстановления пароля. Шаги по предотвращению возникновения уязвимостей просты и требуют использования криптографически безопасных функций, например crypto.randomUUID()
.
UUID — отличная концепция, которая значительно облегчает жизнь инженерам по обработке данных во многих областях приложений. Однако его никогда не следует использовать в отношении аутентификации, поскольку в этой статье выявляются недостатки в некоторых случаях методов его генерации. Очевидно, это не означает, что все UUID небезопасны. Однако основной подход состоит в том, чтобы убедить людей вообще не использовать их в целях безопасности, что более эффективно и, скажем так, безопасно, чем установка сложных ограничений в документации, о том, как их использовать или как не создавать их для этой цели.