[Из песочницы] 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 на странице.

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


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





Естественно, в параметре 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 не передавать совсем (!) — то получаем такой ответ:

/*  */
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, то любой мог бы разместить у себя на сайте следующий код:


















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













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

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

/*  */
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:











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

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


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

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

/*  */
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, которое ничем не перекроешь.

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




        Workers
                








Таким образом, у нас есть возможность подменять 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) так, чтобы она сама умела пользоваться уязвимостью.

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



    
        
        

        
              







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

Выводы


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

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

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

Благодарю за внимание!

© Habrahabr.ru