“Уязвимости в популярных сервисах часто лежат на поверхности, но их не замечают, потому что смотрят не туда. Мы нашли RCE там, где его не должно было быть, и это изменило наш подход к тестированию.”
## Неочевидная точка входа
Большинство исследователей начинают с типичных целей: формы входа, загрузки файлов, API-эндпоинты. Мы решили пойти от обратного — посмотреть, как сервис обрабатывает данные, которые он сам же и генерирует. Речь о функциях экспорта и отчётов.
В этом SaaS-сервисе была возможность создавать сложные отчёты с фильтрами, группировками и вычисляемыми полями, а затем экспортировать их в PDF или Excel. Интерфейс позволял сохранять шаблоны таких отчётов для повторного использования. Логика подсказывала: если пользователь может задать параметры, которые позже интерпретируются сервером при генерации документа, то где-то должен быть парсер.
Первичный анализ показал, что шаблоны сохраняются в виде структурированных JSON-объектов. Но при экспорте сервер не просто подставлял данные в шаблон, а выполнял их предварительную обработку. В логах запросов мы заметили интересную деталь: помимо самого JSON-шаблона, на сервер передавался параметр `template_engine_version`. Это намекало на использование какого-то шаблонизатора на бэкенде.
## От шаблонизатора к инъекции
Параметр `template_engine_version` был статичным и не менялся. Но что, если попробовать повлиять на сам шаблон? Мы начали экспериментировать с полями, которые, предположительно, могли интерпретироваться. В настройках вычисляемого поля для отчёта был параметр “Формула”. Интерфейс предлагал выбор из предустановленных функций: `SUM()`, `AVG()`, `CONCAT()`.
Попытка вставить простейшую строку вроде `{{7*7}}` ни к чему не привела — сервис её отверг. Однако мы заметили, что шаблон в итоге сохранялся в двух представлениях: “безопасном” для отображения в UI и “полном” для сервера. Отправляя прямой POST-запрос к API сохранения шаблона, можно было модифицировать “полную” версию, минуя фронтендную валидацию.
В “полном” JSON-шаблоне структура была сложнее. Поле `formula` содержало не просто строку, а объект с типами и аргументами. Один из аргументов назывался `expression`. Изменение его значения на `#{«calc»=>»7*7»}` привело к тому, что в сгенерированном PDF на месте результата формулы отображалось “49”. Сервер вычислял переданное выражение.
[ИЗОБРАЖЕНИЕ: Схематичное представление обхода валидации: UI -> Валидация фронтенда -> Блокировка. Прямой запрос к API -> “Полный” JSON-шаблон -> Обработка на сервере.]
## Эскалация до RCE
Факт выполнения сервером кода из шаблона — это уже серьёзно. Но это была Sandboxed-среда? Мы начали исследовать, какие объекты и методы доступны в контексте этого шаблонизатора. Методом проб и ошибок, подставляя различные конструкции в `expression`, мы выяснили, что используется изолированная среда выполнения на основе JRuby (реализация Ruby на JVM), но с неполной изоляцией.
Ключевым стал неочевидный трюк. Вместо того чтобы сразу искать способ выполнения системных команд, мы попытались получить доступ к объектам самого приложения. Выражение `#{«self».methods}` вернуло массив методов. Среди них был `class`, а от него — `classloader`. В средах на основе JVM загрузчик классов (ClassLoader) — это часто путь к выходу из песочницы.
Мы смогли получить ссылку на системный загрузчик классов и загрузить стандартный Java-класс, например, `java.lang.Runtime`. Дальнейшая цепочка была стандартной для эксплуатации уязвимостей десериализации или RCE в JVM-окружении: получить Runtime, вызвать метод `exec()`.
Финальный payload в параметре `expression` выглядел примерно так (упрощённо):
`#{«cl = self.class.classLoader; r = cl.loadClass(‘java.lang.Runtime’).getMethod(‘getRuntime’).invoke(null); r.exec(‘calc’)»}`
После отправки шаблона с такой “формулой” и запуска экспорта отчёта, на сервере выполнялась команда. В нашем тестовом случае — открывался калькулятор (если бы это был Windows-сервер). На реальном целевом сервере мы, разумеется, выполняли безвредные команды вроде `id` или `whoami` для подтверждения уязвимости.
## Почему это прошло незамеченным
Обход валидации через прямой вызов API — распространённая проблема. Но коренная причина была глубже. Архитектурное решение вынести сложную логику генерации отчётов в отдельный микросервис привело к тому, что на его границах не дублировались проверки безопасности, существовавшие в основном приложении. Команда, отвечавшая за этот микросервис, считала, что получает уже очищенные и проверенные данные.
Кроме того, использование шаблонизатора, допускающего выполнение кода, для обработки пользовательского ввода — это грубая ошибка. Похоже, разработчики выбрали мощный инструмент для гибкости, но не оценили риски или положились на изоляцию JRuby, которая оказалась неабсолютной.
Ещё один фактор: уязвимость проявлялась только при определённом сценарии — создании шаблона через API *и* последующем его использовании для экспорта. Автоматизированные сканеры безопасности, которые часто тестируют только отдельные эндпоинты, такой цепочку событий обычно не воспроизводят.
## Ответственный disclosure и Bug Bounty
Обнаружив уязвимость, мы немедленно прекратили тестирование и приступили к составлению отчёта. Отчёт включал:
1. Детальное описание уязвимости (Server-Side Template Injection, эскалированная до RCE).
2. Пошаговые инструкции по воспроизведению.
3. Доказательство концепции (PoC) с выполнением безвредной команды.
4. Анализ потенциального воздействия.
5. Рекомендации по исправлению: отключение выполнения кода в шаблонизаторе, строгая валидация входных данных на уровне API микросервиса, применение “белого списка” допустимых функций.
Мы отправили отчёт через официальную программу Bug Bounty платформы. Ответ пришёл в течение 24 часов. Команда безопасности подтвердила уязвимость, оценила её как критическую (Critical Severity) и оперативно приступила к разработке патча.
Через три дня уязвимость была устранена. В качестве исправлений разработчики:
— Заменили шаблонизатор на неисполняющий (logic-less).
— Внедрили строгую схему валидации для всех данных, приходящих в микросервис генерации отчётов.
— Добавили сигнатуры подобных атак в WAF (Web Application Firewall).
Вознаграждение за баг составило пятизначную сумму в долларах, что соответствовало высшей границе таблицы выплат программы.
## Уроки для исследователей и разработчиков
Этот случай — не просто история об успешном поиске бага. Он иллюстрирует несколько важных принципов.
**Для исследователей:**
— **Смотрите на данные в движении.** Не только на то, что вводит пользователь, но и на то, как эти данные преобразуются, сохраняются и используются в разных частях системы.
— **Изучайте второстепенные функции.** Функции экспорта, импорта, уведомлений, отчётов — часто содержат сложную логику обработки и могут быть построены на устаревших или небезопасных компонентах.
— **Анализируйте архитектуру по косвенным признакам.** Параметры вроде `template_engine_version`, названия заголовков (X-Service-Name), структура ответов ошибок — всё это помогает понять, с чем вы имеете дело.
**Для разработчиков и архитекторов:**
— **Не доверяйте фронтендной валидации.** Всегда дублируйте критичные проверки на бэкенде, особенно на границах микросервисов.
— **Опасайтесь шаблонизаторов с выполнением кода.** Для обработки пользовательских данных используйте только те шаблонизаторы, которые не допускают выполнения произвольной логики. Если такая возможность нужна, её следует изолировать максимально строго и проверять каждый “кусочек” кода.
— **Микросервисы — не острова безопасности.** Каждый микросервис должен самостоятельно проверять входящие данные, считая, что вызывающая сторона может быть скомпрометирована.
Найденная уязвимость была подобна скрытой трещине в фундаменте — она не видна при поверхностном осмотре, но под нагрузкой может привести к серьёзным последствиям. Её обнаружение потребовало не столько сложных инструментов, сколько внимания к деталям и понимания того, как данные путешествуют по приложению.