Как я отраженную self-XSS эксплуатировал или CORS – не приговор

На днях впервые наткнулся на отраженную self-XSS. Все просто, когда self-XSS хранимая – в таком случае внедряемый код сохраняется в некоей области приложения, которая доступна лишь пользователю, который туда это код смог поместить, например, в личном кабинете. В моем же случае произошло следующее: нашел ссылку, которая была уязвима к классической отраженной XSS, зарепортил в BugBounty (даже в голову не пришло пробовать передавать ее другому аккаунту). Через некоторое время получил ответ, что уязвимость не воспроизводится – стал разбираться.

Оказалось вот что: в уязвимом приложении был реализован механизм загрузки видео со сторонних сервисов и реализован был следующим образом:

  1. Пользователь нажимает кнопку «загрузить видео», открывается отдельная страница загрузки. В этот момент в базе создается, видимо, некоторая заглушка видео с уже присвоенным ему ID вида 10345 (ID виден в скрипте в исходном коде страницы).
  2. Пользователь выбирает пункт «загрузить со стороннего ресурса», в этот момент открывается новая страница с URL вида
    /uploadExt?videoId=10345&vulnerableParam=test, которая как раз была уязвима к XSS через параметр vulnerableParam.

Проблема заключалась в том, что уязвимую ссылку нельзя было передать другому пользователю, так как он не имел доступа к видео с таким ID и получал в ответ 403 Unauthorized. В итоге выходило, что пользователь мог быть атакован только если он сам начал загрузку видео и переходил по ссылке со своим собственным videoId. Какие варианты я пробовал для обхода данного ограничения и что получилось в итоге – читайте ниже 🙂

Инициируем процесс загрузки

После выяснения нюансов стало ясно, что простой ссылкой при эксплуатации этой XSS не обойтись, и было решено делать payload в виде HTML-страницы со скриптами.

В первую очередь было однозначно понятно, что если пользователь не начнет процесс загрузки – атакован он не будет никак, т.к. нужно, чтобы пользователю был доступен ID какого-нибудь видео. Изучив кнопку «загрузить видео», я заметил, что по сути она была простой ссылкой на страницу загрузки. Соответственно, отправив необходимый GET-запрос с куками пользователя, мы можем инициировать процесс загрузки от его имени.

Просто так отправить запрос, например, с помощью XMLHttpRequest с другого домена нельзя – мешает CORS. Однако, многим известен простейший способ отправить GET-запрос с куками пользователя – использовать для этого изображение. При загрузке изображения по адресу, например http://example.com/image.png, браузер также передает и куки пользователя для этого домена. При этом необязательно адрес должен указывать на настоящее изображение, можно использовать любой URL, по которому браузер честно попытается загрузить картинку. И если картинки там не обнаружится – изображение просто отобразится битым, однако запрос уже обработается сервером, что нам и требуется.

Так я смастерил первую часть payload:

1
2
3
4
5
6
7
8
<div id="main" style="display: none;"></div>
<script>
    div = document.getElementById('main');
    el = document.createElement('img');
    el.src = 'https://example.com/upload-video';
    el.onerror = step2;
    div.appendChild(el);
</script>

Здесь я создаю изображение с URL необходимой мне страницы инициации загрузки видео и ставлю обработчиком ошибки загрузки (onerror) функцию step2. В таком случае, данная функция выполнится как только запрос будет обработан сервером и браузер поймет, что на той стороне находится не картинка (дернется обработчик события onerror).

Ищем нужный ID видео

Дальше интересней. Инициировать процесс загрузки видео, конечно, было не сложно. Только вот теперь необходимо было каким-то образом узнать, какой именно ID назначился для видео пользователя. Было решено брутфорсить ID, пока не наткнемся на нужный, благо ID инкрементальные. Как я писал в самом начале, я заметил, что если пользователь пытается перейти по уязвимой ссылке с чужим ID – возвращается ошибка 403 Unauthorized, чем я и решил воспользоваться. Осталось только придумать, как именно эту ошибку отловить, ведь никакие запросы отправлять напрямую я не мог, вследствие CORS.

Сначала я подумал применить тот же трюк, который использовал для инициации процесса загрузки видео, и динамически насоздавать кучу изображений с адресами на страницы с разными ID, начиная от некоего, который я уже знаю (например, который назначен подконтрольному мне пользователю). Надежда была на то, что браузер вернет ошибки на тех изображениях, в ответ на запрос которых сервер вернул 403 код, а то единственное изображение, ID в адресе которого будет верным – загрузит успешно и сработает обработчик события onload. Однако, это было глупо, конечно, т.к. настоящей картинки по данным адресам нет нигде и все изображения отваливались с ошибкой.

Далее попробовал вместо изображений создавать iframe. В таком случае можно было ожидать срабатывания внедренного JS-кода в том фрейме, адрес которого ведет на страницу с нужным ID. Однако, несмотря на то, что уязвимая страница была служебной с очень коротким содержимым, сервер запрещал ее встраивание через заголовок X-Frame-Options: SAMEORIGIN. Я вспомнил, что в Chrome такое значение заголовка не поддерживается, однако теперь это уже не так и с версии 61 Chrome корректно с ним работает и также запрещает встраивание таких ресурсов.

При дальнейших исследованиях оказалось, что если вместо изображений использовать script, то для любого адреса (как минимум ведущего на HTML-страницу), в ответ на запрос которого сервер возвращает код 200, скрипт считается загруженным успешно, а в случае получения ответа кода ответа 403 – ошибочным. Осталось только дописать следующую часть payload:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function _getFunc(i) {
  return function() {
    document.location.href = 'http://example.com/uploadExt?videoId=' + i.toString() + '&vulnerableParam=<' + 'script>alert()<' + '/script>';
  }
}

function step2() {
    for (var i=10098; i < 10150; i++) {
      el = document.createElement('script');
      el.src = 'http://example.com/uploadExt?videoId=' + i.toString() + '&vulnerableParam=<' + 'script>alert()<' + '/script>';
      el.onload = _getFunc(i);
      div.appendChild(el);
    }
}

В данном скрипте в цикле динамически создаются новые скрипты, указывающие на потенциально уязвимые страницы. В случае, если сервер на один из таких запросов вернет код ответа 200 – скрипт будет считаться загруженным успешно и вызовется обработчик события onload, который содержит в себе функцию редиректа пользователя на страницу с нужным ID и встроенной полезной нагрузкой на JS. Таким образом, эксплуатация происходит полностью автоматически, невзирая на достаточно большой набор действий, который необходимо совершить.

В итоге репорт был исправлен, уязвимость принята и, помимо вознаграждения за сам баг, также выплачен бонус за сложность proof of concept 🙂

P.S.: еще один вариант решения

Есть еще один способ обойти CORS в схожих случаях. Исполнение политики CORS лежит исключительно на браузере, поэтому любое другое ПО может спокойно отправлять запросы на любые ресурсы с любыми куками. В описанном случае можно было также поступить следующим образом:

  1. После перехода пользователя на подконтрольную нам HTML-страницу, отправить запрос на, скажем, специально созданный нами PHP-скрипт, который от имени некоторого другого пользователя (подконтрольного нам) инициирует процесс загрузки видео и считывает из ответа сервера назначенный ID (так как PHP-скрипт – не браузер, он легко сможет залогиниться, отправить запрос и прочитать ответ).
  2. Далее, получив от скрипта ID, назначенный нашему пользователю, инициировать процесс загрузки файла уже от имени жертвы (все той же картинкой) и перенаправить жертву на уязвимую страницу с ID+1 (т.к. ID инкрементальны).

Вероятность успеха подобной эксплуатации очень высока, в случае, когда ID обновляется не слишком часто. Например, если бы речь шла о ID твитов в твиттере – этот способ бы не сработал, так как твитов генерируется не одна сотня за секунду, соответственно ID успевает убежать далеко вперед. Правда, и брутфорсить его в таком случае было бы сильно сложнее 🙂

  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  

Добавить комментарий

Ваш e-mail не будет опубликован. Обязательные поля помечены *