Ищем уязвимости в логике обработки email

D2

Администратор
Регистрация
19 Фев 2025
Сообщения
4,380
Реакции
0
В конце 2023 года я участвовал в программе багбаунти одной крупной российской компании. Комбинируя логические ошибки, я смог проэксплуатировать баг, который позволяет захватить любой аккаунт. В этой статье я расскажу, как проходило исследование и какие трюки мне помогли добиться результата.

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

Первый Account Takeover​

При изучении сервиса я нашел несколько интересных аномалий:
  • При смене email в настройках профиля доступ к новому адресу не проверяется.
  • При регистрации пользователя и логине в его аккаунт API приводит переданный email к нижнему регистру. Но при смене email в настройках профиля он не подвергается никаким изменениям и успешно проходит проверку уникальности, даже если существует близнец с символами в ином регистре.
  • При исследовании JWT, который используется для авторизации пользователя, я выяснил, что роль идентификатора пользователя играет email (так как это единственный идентификатор пользователя в пейлоаде JWT).
Получается, что если у почтового адреса есть близнец, то владелец такой почты потенциально сможет подделать токен другого пользователя и получить сессию от его имени.
image1_Qyh4e9I.png



Пример пейлоада JWT​

Первым делом нужно было понять, что произойдет, если будет существовать несколько email-близнецов с символами в разных регистрах и мы попробуем использовать их для логина.
Чтобы разобраться, что происходит на бэкенде сервиса, я создал два аккаунта и email второго поменял на email первого через баг в настройке профиля, предварительно изменив регистр некоторых букв. При этом пароли у аккаунтов были разными.

При попытке войти с данными первого пользователя (того, который был зарегистрирован раньше) мы успешно попадаем в его аккаунт. Если же использовать данные второго пользователя, получаем ошибку Invalid Credentials.
Дальше я создал аккаунт и через уязвимость на странице изменения профиля поменял некоторые символы в адресе почты на аналогичные в ином регистре. Зарегистрироваться с таким адресом не получилось бы — сервер в этом случае автоматически приводит буквы к нижнему регистру.

После аутентификации я успешно зашел в аккаунт, а в JWT адрес почты оказался приведен к нижнем регистру. Из этих тестов стало известно:
  • При аутентификации хранимый в таблице email приводится к нижнему регистру.
  • Получается зайти только в более старый аккаунт, значит, существует сортировка, а API выбирает первую запись из тех, что вернул запрос к БД (ну или используется LIMIT 1 в SQL-запросе).
  • После аутентификации в аккаунте, email которого содержит буквы разного регистра, возвращаемый сервером JWT содержит email в нижнем регистре.
На основе этих фактов я составил возможный SQL-запрос, который выполняется при логине. Это помогает восстановить логику работы сервера.
image2.png


Используя эти знания, я смог получить доступ к более новому аккаунту жертвы, чем аккаунт атакующего. Для этого понадобилось:
  1. Изменить email аккаунта атакующего на email жертвы через редактирование профиля: я заменил некоторые буквы аналогичными в ином регистре.
  2. Залогиниться с помощью мейла жертвы и пароля атакующего (после аутентификации попадаем в аккаунт атакующего, так как он более старый).
  3. Изменить email аккаунта атакующего на любой другой, предварительно сохранив наш JWT.
  4. Поместить JWT обратно в куки и обновить страницу.
Таким образом попадаем в аккаунт жертвы, так как теперь это самый старый аккаунт с такой почтой, да и в принципе единственный.

Но этого недостаточно, хочется иметь возможность получать доступ к любому аккаунту. Я начал искать способ на этапе аутентификации указать сервису именно на аккаунт атакующего, который может быть новее, чем аккаунт жертвы. Пришла мысль протестировать вход в аккаунт по OAuth, поскольку велика вероятность, что в таком случае идентификатором пользователя будет целочисленный и уникальный User ID, а не email.

И это сработало! После привязки аккаунта для входа с помощью OAuth (для тестов я использовал Gmail) я мог успешно указать на аккаунт атакующего, даже если он новее, чем аккаунт жертвы. Следовательно, после логина я получал JWT, с адресом почты в нижнем регистре, а так как самый старый пользователь с таким email — это аккаунт жертвы (аккаунт атакующего создается перед атакой), то я попадал в него.

Эксплуатация​

Вот шаги, которые нужны для эксплуатации этого бага:
  1. Добавляем в нашу организацию сотрудника с любым адресом электронной почты.
  2. Переходим по ссылке, которая пришла на почту новому сотруднику, и устанавливаем пароль.
  3. Заходим в аккаунт нового сотрудника.
  4. В настройках профиля привязываем любой аккаунт Gmail для аутентификации через OAuth.
  5. Меняем наш email на адрес жертвы, заменив некоторые буквы аналогичными в ином регистре (например, email жертвы test@mail.ru, тогда адрес атакующего может быть TeSt@mail.ru).
  6. Выходим из аккаунта.
  7. Переходим на страницу https://redacted.domain/login.
  8. Входим с помощью аккаунта Gmail и попадаем в аккаунт и организацию жертвы.
  9. Для захвата аккаунта можем сменить почту на любую другую.

Рекомендации​

  • Чтобы избежать дублирования одного почтового адреса в разных регистрах, в БД стоит использовать тип данных без учета регистра (например, citext в PostgreSQL) в сочетании с UNIQUE Constraint. Или, если это невозможно, обновления данных в таблице проводить с помощью заранее подготовленного метода API или процедуры в БД, учитывающей возможную разницу регистра (чтобы исключить риск недостаточной проверки при разработке новых функций API).
  • В JWT стоит использовать идентификаторы пользователя, на которые он не может повлиять, например целочисленный User ID (судя по эндпоинтам обновления информации пользователей, он уже существует в БД).
  • Свести время действия access token к минимуму (на момент тестирования он жил не меньше суток), чтобы уменьшить эффективность возможного «провала» в чужой аккаунт.
  • При смене email пользователя запрашивать подтверждение путем отправки письма на старую почту.

Второй Account Takeover​

Через некоторое время, уже после фикса, я вернулся протестировать сервис и попробовать любыми способами добиться ATO снова.
Аутентификация была изменена:
  • Выпилили аутентификацию через OAuth, с помощью которой я в прошлый раз смог указать аккаунт атакующего и добиться захвата любой учетки.
  • Появилась возможность запомнить вход в аккаунт.
  • Появились refresh-токены.
Интересно, кстати, что если перед входом в аккаунт активировать чекбокс «Запомнить», то после истечения жизни нашего access token (JWT) он обновится с помощью refresh-токена, но в противном случае система его не обновит и нас выкинет из аккаунта.

Конечно, первым делом я проверил фикс прошлой уязвимости: дублировать существующий email с помощью изменения регистра букв не получилось. Но я заметил, что в почтовом адресе можно подставлять символы (далеко не все, но некоторые проходили из‑за недостаточной валидации), которые не могут в нем содержаться.

Тогда я решил сделать ставку на возможную проблему с нормализацией Unicode. Я пробовал заменять буквы символами, которые после нормализации могут принять вид тех самых букв (например, U+212A → K), но тесты не дали никаких результатов.

Еще есть символы, которые после нормализации вообще пропадают. Я собрал список управляющих символов Unicode, но беглый ручной тест не дал никаких результатов, и я пришел к автоматизации с помощью Python.
Запрос на изменение профиля я переписал на requests (кстати, удобно это можно сделать с помощью расширения для Burp Suite, оно называется Copy as Python Requests). Делаем запросы в цикле и подставляем в email разные управляющие символы Unicode.
Пример скрипта для перебора символов


Email с большинством символов не прошел проверку уникальности, а с некоторыми прошел, но после нормализации эти символы остались в адресе. В таком случае у нас выходит не дубликат адреса, а email с дописанным непонятным символом.
Пример результата с некоторыми управляющими символами


Но символ U+206A «симметричный обмен запрещен» стрельнул. Он обходил проверку уникальности и после нормализации пропадал. Видимо, проверка уникальности email проводилась до нормализации, а уже после нормализации адрес пишется в БД. Так мне удалось повторно обойти ограничение уникальности email путем добавления этого символа (например, test@mail.rutest\u206a@mail.ru).
Реконструкция запроса из PoC


Еще из прошлого теста известно, что, найдя способ создавать близнецов email, можно получить доступ только к более новому аккаунту, чем наш. В прошлый раз я обошел это с помощью указания на аккаунт через OAuth, но после обновления сервиса эту возможность убрали.

Я долго думал, как еще можно провернуть подобный трюк. Попробуем уцепиться за возможность обновлять токен, описанную в начале. Я подумал, что refresh token обязательно должен указывать на User ID и сможет взять на себя роль OAuth из прошлой уязвимости. Но, чтобы протестировать это, нужен access token, срок действия которого уже истек. Поскольку для обновления access token не существует отдельного эндпоинта, сервер в ответ на любой запрос к нему обновит токен, если срок жизни токена приближается к концу или токен уже протух.

«Если срок его жизни приближается к концу...» Звучит как место, куда можно воткнуть костыль! Заставлять триажера ждать, пока протухнет сессия, не хотелось, поэтому я предположил, что проверка того, скоро ли умрет токен, может выполняться до проверки самой подписи токена. Для теста я составил специальный токен, из которого убрал все лишнее, оставил только метку о том, что у нас был активирован чекбокс «Запомнить», ключу exp указал значение 0, а подпись удалил.
Токен, используемый для запуска механизма обновления


Каждый раз, когда на любой эндпоинт прилетал запрос с таким access-токеном, сервер обновлял его с помощью refresh token.

Чтобы проверить, может ли механизм обновления токенов поспособствовать нам в захвате любого аккаунта, я сменил email пользователя через аккаунт администратора организации (больше нельзя было менять себе email без подтверждения), в которой он находился, а после этого от лица пользователя послал GET-запрос (чтобы не тратить время на токены XSRF) на первый попавшийся эндпоинт с заранее подготовленным протухшим access-токеном. Сервер обновил access token, а в пейлоаде содержался новый email. Значит, refresh token привязан к пользователю по User ID и мы можем использовать механизм обновления access-токена для захвата любого аккаунта.

Эксплуатация​

Пройдемся вкратце по основным шагам атаки:
  1. Входим в аккаунт администратора организации и создаем аккаунт атакующего.
  2. Входим в аккаунт атакующего с активированным чекбоксом «Запомнить» (чтобы работал механизм обновления access token).
  3. С аккаунта администратора нашей организации обновляем атакующему email на адрес жертвы с символом U+206A (например, victim@mail.ru → victim\u206a@mail.ru).
  4. В куки аккаунта атакующего помещаем заранее подготовленный просроченный access token.
  5. Отправляем любой запрос на сервер с этим токеном (отправлял из Burp Repeater, но в целом можно просто обновить страницу) и получаем в ответе access token с email жертвы (victim@mail.ru).
  6. Так как аккаунт жертвы самый старый среди аккаунтов с таким же email (аккаунт атакующего создается непосредственно перед атакой), то с полученным access-токеном мы попадаем в аккаунт и организацию жертвы.

Рекомендации​

Вот что нужно сделать, чтобы и такая атака была невозможна:
  • Доработать проверку почтового адреса, которая не должна позволять использование недопустимых символов.
  • Проверять уникальность адреса после нормализации.
  • Использовать ограничения уникальности (например, UNIQUE Constraint) в СУБД для атрибута email.
  • В JWT использовать идентификаторы пользователя, на которые он не может повлиять, например целочисленный User ID (судя по эндпоинтам обновления информации пользователей, он уже существует в БД).
  • Если не планируется полностью менять механизм обновления токенов, то нужно хотя бы убедиться, что проверка того, как скоро истечет срок жизни access-токена, происходит после проверки подписи.
Автор @arkiix
Источник xakep.ru
 
Сверху Снизу