В кон­це 2023 года я учас­тво­вал в прог­рамме баг­баун­ти одной круп­ной рос­сий­ской ком­пании. Ком­бинируя логичес­кие ошиб­ки, я смог про­экс­плу­ати­ровать баг, который поз­воля­ет зах­ватить любой акка­унт. В этой статье я рас­ска­жу, как про­ходи­ло иссле­дова­ние и какие трю­ки мне помог­ли добить­ся резуль­тата.

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

Pentest Award

Этот текст получил пре­мию Pentest Award 2024 в катего­рии Hack the logic, пос­вящен­ной поис­ку логичес­ких уяз­вимос­тей. Это сорев­нование еже­год­но про­водит­ся ком­пани­ей Awillix.

warning

Статья име­ет озна­коми­тель­ный харак­тер и пред­назна­чена для спе­циалис­тов по безопас­ности, про­водя­щих тес­тирова­ние в рам­ках кон­трак­та. Автор и редак­ция не несут ответс­твен­ности за любой вред, при­чинен­ный с при­мене­нием изло­жен­ной информа­ции. Рас­простра­нение вре­донос­ных прог­рамм, наруше­ние работы сис­тем и наруше­ние тай­ны перепис­ки прес­леду­ются по закону.

Первый Account Takeover

При изу­чении сер­виса я нашел нес­коль­ко инте­рес­ных ано­малий:

По­луча­ется, что если у поч­тового адре­са есть близ­нец, то вла­делец такой поч­ты потен­циаль­но смо­жет под­делать токен дру­гого поль­зовате­ля и получить сес­сию от его име­ни.

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

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

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

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

Даль­ше я соз­дал акка­унт и через уяз­вимость на стра­нице изме­нения про­филя поменял некото­рые сим­волы в адре­се поч­ты на ана­логич­ные в ином регис­тре. Зарегис­три­ровать­ся с таким адре­сом не получи­лось бы — сер­вер в этом слу­чае авто­мати­чес­ки при­водит бук­вы к ниж­нему регис­тру.

Пос­ле аутен­тифика­ции я успешно зашел в акка­унт, а в JWT адрес поч­ты ока­зал­ся при­веден к ниж­нем регис­тру.

Из этих тес­тов ста­ло извес­тно:

На осно­ве этих фак­тов я сос­тавил воз­можный SQL-зап­рос, который выпол­няет­ся при логине. Это помога­ет вос­ста­новить логику работы сер­вера.

Ис­поль­зуя эти зна­ния, я смог получить дос­туп к более новому акка­унту жер­твы, чем акка­унт ата­кующе­го. Для это­го понадо­билось:

  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. Для зах­вата акка­унта можем сме­нить поч­ту на любую дру­гую.

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

Второй Account Takeover

Че­рез некото­рое вре­мя, уже пос­ле фик­са, я вер­нулся про­тес­тировать сер­вис и поп­робовать любыми спо­соба­ми добить­ся ATO сно­ва.

Аутен­тифика­ция была изме­нена:

Ин­терес­но, кста­ти, что если перед вхо­дом в акка­унт акти­виро­вать чек­бокс «Запом­нить», то пос­ле исте­чения жиз­ни нашего 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
Ре­конс­трук­ция зап­роса из 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.ruvictim\u206a@mail.ru).
  4. В куки акка­унта ата­кующе­го помеща­ем заранее под­готов­ленный прос­рочен­ный access token.
  5. От­прав­ляем любой зап­рос на сер­вер с этим токеном (отправ­лял из Burp Repeater, но в целом мож­но прос­то обно­вить стра­ницу) и получа­ем в отве­те access token с email жер­твы (victim@mail.ru).
  6. Так как акка­унт жер­твы самый ста­рый сре­ди акка­унтов с таким же email (акка­унт ата­кующе­го соз­дает­ся непос­редс­твен­но перед ата­кой), то с получен­ным access-токеном мы попада­ем в акка­унт и орга­низа­цию жер­твы.

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

Вот что нуж­но сде­лать, что­бы и такая ата­ка была невоз­можна: