Semgrep для поиска уязвимостей в коде: пишем правила под SSTI и небезопасную десериализацию

На code review Flask-сервиса для внутреннего портала я наткнулся на render_template_string(user_input) — пользовательский ввод летит прямиком в шаблонизатор Jinja2 без фильтрации. Это SSTI (Server-Side Template Injection — инъекция в серверный шаблонизатор): атакующий отправляет {{config.items()}}, получает конфигурацию сервера, а через цепочку __class__.__mro__ — произвольное выполнение кода. Уязвимость жила в репозитории четыре месяца, прошла три code review, и ни один линтер её не поймал. Я написал правило Semgrep, которое ловит этот паттерн автоматически — за 15 минут. Ниже разберём, как создавать такие правила для двух критических классов уязвимостей: SSTI и небезопасной десериализации.
Зачем кастомные SAST-правила при code review безопасности приложений
Semgrep — открытый инструмент статического анализа кода. SAST (Static Application Security Testing) — тестирование безопасности приложений через анализ исходного кода без его запуска. Если grep ищет совпадения по тексту, то Semgrep разбирает код в AST (абстрактное синтаксическое дерево — представление кода в виде логических блоков: функции, аргументы, операторы) и сопоставляет паттерн правила с узлами этого дерева. На практике это значит: запись foo($X, ...) в правиле найдёт вызов функции foo с любым первым аргументом — независимо от форматирования, переносов строк и комментариев в коде.
Semgrep Registry (публичная библиотека правил) содержит более 5 000 готовых правил для 30+ языков. Наборы p/owasp-top-ten или p/security-audit покрывают типовые проблемы: SQL-инъекции, XSS, хардкоженные секреты. Но два класса уязвимостей дефолтные правила закрывают слабо.
SSTI попадает в категорию A03:2021 Injection по OWASP Top 10 (стандартный рейтинг рисков веб-приложений). В терминологии MITRE ATT&CK (открытая база тактик и техник атак; T-коды вроде T1190 — её идентификаторы) SSTI открывает путь к технике T1190 Exploit Public-Facing Application (тактика Initial Access — первичное проникновение). Успешная эксплуатация может привести к установке веб-шелла — T1505.003 (тактика Persistence — закрепление в системе). Если переводить в цепочку атаки: SSTI — это initial access, от которого один шаг до полного контроля над сервером.
Небезопасная десериализация — восстановление объекта из потока байтов без проверки содержимого. Классифицируется как CWE-502 (Deserialization of Untrusted Data — десериализация недоверенных данных: продукт десериализует данные без гарантий их валидности, что может привести к выполнению произвольного кода). Входит в A08:2021 Software and Data Integrity Failures по OWASP Top 10 — категорию нарушений целостности кода и данных.
Оба класса требуют кастомных правил, потому что уязвимый паттерн зависит от конкретного фреймворка и контекста проекта. Дефолтные наборы здесь бессильны.
Статус инструмента: репозиторий Semgrep на GitHub (semgrep/semgrep) активно поддерживается, текущая серия релизов — 1.x. Semgrep OSS (бесплатный) делает паттерн-матчинг внутри одной функции. Semgrep Pro/Enterprise добавляет межфункциональный taint-анализ — отслеживание «заражённых» данных от источника до точки использования. Для правил из этой статьи хватит OSS-версии.
SSTI уязвимость: поиск в коде и Semgrep YAML правило
Как выглядит SSTI в Python
SSTI возникает, когда пользовательский ввод попадает в шаблонизатор как часть самого шаблона, а не как данные для подстановки. Разница — между «вставить значение в готовую форму» и «дать пользователю нарисовать саму форму».
Безопасно: render_template("page.html", name=user_input) — пользовательский ввод подставляется как переменная в готовый шаблон из файла.
Опасно: render_template_string(user_input) — пользовательский ввод и есть шаблон. Атакующий отправляет {{config.items()}} и получает конфигурацию сервера. Более опасный payload через цепочку __class__.__mro__ приводит к выполнению произвольного кода.
В реальной практике уязвимый код появляется так: разработчик хочет дать пользователю возможность редактировать email-шаблоны через веб-интерфейс и пишет render_template_string(template_from_db), где содержимое шаблона контролирует пользователь. Намерение хорошее, результат — RCE.
Написание Semgrep правила для SSTI
Правила Semgrep описываются в YAML. Каждое содержит id (уникальный идентификатор), patterns (что искать), message (рекомендация разработчику), languages (целевой язык) и severity (критичность: INFO, WARNING, ERROR).
rules:
- id: jinja2-ssti-user-input
patterns:
- pattern: render_template_string($USERINPUT, ...)
- pattern-not: render_template_string("...", ...)
message: >
Пользовательский ввод в render_template_string — возможна SSTI.
Используйте render_template() с файлом шаблона.
languages: [python]
severity: ERROR
metadata:
owasp: "A03:2021 - Injection"
Разберём логику. pattern ищет любой вызов render_template_string с произвольным первым аргументом: $USERINPUT — метапеременная Semgrep, она подхватит любое выражение. Метапеременные пишутся заглавными буквами с префиксом $, а ... (эллипсис) означает «ноль или более дополнительных аргументов». Блок pattern-not исключает случаи, когда первый аргумент — строковый литерал в кавычках: захардкоженный шаблон безопасен. Когда оба условия стоят внутри patterns, они работают как логическое И: правило срабатывает, только если паттерн совпал И исключение не применилось.
Подход распространяется на другие шаблонизаторы. Для Twig (PHP) паттерн выглядит как $TWIG->render($USERINPUT, ...), для Freemarker (Java) — аналогично через process(). Принцип один: метапеременная вместо пользовательского ввода, исключение литералов через pattern-not.
Ограничения обнаружения SSTI через паттерн-матчинг
Правило поймает прямой вызов render_template_string(request.form['tpl']), но пропустит цепочку, где данные проходят через несколько функций: get_template() вызывает process(), а тот передаёт результат в render_template_string(). Для таких сценариев нужен taint-анализ из Semgrep Pro. В OSS-версии обходной путь — добавить оператор pattern-inside с контекстом обработчика HTTP-запроса, чтобы сузить область поиска до функций, принимающих входные данные. Не идеально, но лучше, чем ничего.
Небезопасная десериализация: обнаружение pickle и ObjectInputStream
Суть уязвимости CWE-502
Десериализация — восстановление программного объекта из последовательности байтов. В Python это делает модуль pickle, в Java — класс ObjectInputStream. Уязвимость возникает, когда десериализуемые данные контролирует атакующий: он конструирует payload, который при восстановлении выполняет произвольный код. В Python вызов pickle.loads(data) запустит любой код, если data содержит объект с методом __reduce__. В Java конструкция new ObjectInputStream(stream).readObject() активирует цепочки gadget-классов.
В цепочке атаки десериализация чаще всего эксплуатируется на этапе Initial Access (T1190): атакующий отправляет сериализованный payload через API-эндпоинт, получает выполнение кода и закрепляется в системе. По классификации OWASP — A08:2021 Software and Data Integrity Failures.
Semgrep правило для Python pickle
rules:
- id: python-pickle-insecure-load
pattern-either:
- pattern: pickle.loads(...)
- pattern: pickle.load(...)
- pattern: _pickle.loads(...)
message: >
Десериализация через pickle — CWE-502. Используйте json.loads()
или HMAC-подпись перед десериализацией.
languages: [python]
severity: ERROR
metadata:
cwe: "CWE-502: Deserialization of Untrusted Data"
owasp: "A08:2021"
Оператор pattern-either работает как логическое ИЛИ: правило сработает при любом из перечисленных вызовов. Обратите внимание на _pickle — это C-реализация pickle в CPython, разработчики иногда импортируют её напрямую для скорости.
Для снижения ложных срабатываний можно добавить pattern-not-inside — исключить тестовые файлы или контексты, где данные заведомо доверенные (загрузка из локального кэша, созданного самим приложением). Но на code review я оставляю правило строгим: каждый pickle.loads() заслуживает обсуждения с автором кода. Лучше лишний раз спросить «откуда приходят эти данные?», чем пропустить RCE.
Java ObjectInputStream
Для Java подход аналогичный. Правило вида pattern: new ObjectInputStream(...) с severity: WARNING обнаруживает создание потока десериализации. Сам факт использования ObjectInputStream в коде, который принимает внешние данные, — красный флаг. Безопасная альтернатива — проверка классов перед десериализацией через [ObjectInputFilter](https://docs.oracle.com/en/java/docs/jdk/api/java.base/java/io/ObjectInputFilter.html) (Java 9+) или переход на JSON-сериализацию.
Semgrep vs SonarQube: SAST инструменты для code review
При выборе статического анализатора кода для задач безопасности часто сравнивают Semgrep и SonarQube. Вот архитектурные отличия, которые определяют выбор для обнаружения SSTI и десериализации:
| Критерий | Semgrep OSS | SonarQube Community |
|---|---|---|
| Формат кастомных правил | YAML, синтаксис похож на код — порог входа низкий | Java-плагин через SonarQube API — порог входа высокий |
| SSTI-детекция | Кастомное правило за 10-15 минут | Требует написания Java-плагина |
| Десериализация pickle (Python) | Да, через pattern-either |
Нет нативных правил |
| Десериализация Java | Да, паттерн new ObjectInputStream(...) |
Да, встроенные правила |
| Taint analysis | Только Pro/Enterprise | Встроен, ограничен одним файлом |
| Скорость на PR | Секунды — единицы минут | Минуты — десятки минут, требует сервер |
| CI/CD интеграция | Нативная (GitHub Actions, GitLab CI) | Через SonarScanner, дополнительная настройка |
| Формат вывода | JSON, SARIF — легко парсить | Веб-дашборд, API |
Когда Semgrep — лучший выбор: нужно быстро написать правило под конкретный паттерн, встроить проверку в PR-процесс, основной стек — Python/Go/JS. Когда SonarQube оправдан: уже развёрнут в организации, нужен централизованный дашборд качества кода, основной стек — Java/.NET с типовыми уязвимостями.
Для задач из этой статьи — поиск SSTI и небезопасной десериализации на code review — Semgrep выигрывает по скорости создания правил и простоте интеграции. Написать YAML за 15 минут против разработки Java-плагина — разница на порядок.
Практический блок: от установки до первого finding
Требования к окружению
- ОС: Linux, macOS, Windows (через WSL2)
- Python: 3.8+ (для установки через pip)
- RAM: 2 ГБ минимум, 4 ГБ рекомендуется для кодовых баз свыше 100 тыс. строк
- Сеть: доступ к интернету для загрузки правил из Registry; без интернета — только локальные YAML-файлы
- Права: обычный пользователь, root не нужен
Делай раз: установка и проверка
Установите Semgrep командой pip install semgrep. Проверьте версию: semgrep --version — ожидаемый вывод semgrep 1.x.x (конкретный номер зависит от даты установки). Если предпочитаете Docker: docker pull semgrep/semgrep:latest.
Делай два: создание файлов правил
Сохраните YAML-правила из предыдущих разделов в отдельные файлы: ssti-rule.yaml и deser-rule.yaml. Можно объединить оба правила в один файл — Semgrep принимает несколько элементов в списке rules внутри одного YAML.
Делай три: запуск и интерпретация
# Запуск с кастомными правилами по директории src/
semgrep --config ssti-rule.yaml --config deser-rule.yaml ./src
# JSON-вывод для CI/CD или DefectDojo
semgrep --config ./rules/ --json ./src > results.json
При обнаружении уязвимости Semgrep выведет путь к файлу, номер строки, фрагмент совпавшего кода и сообщение из поля message вашего правила. Если findings нет — вывод пустой, exit code 0. Чтобы блокировать PR при обнаружении проблем, добавьте флаг --error: Semgrep завершится с ненулевым кодом и CI/CD пайплайн прервётся.
Как убедиться, что правило работает
Создайте тестовый файл с заведомо уязвимым кодом — test_ssti.py с вызовом render_template_string(request.args.get('tpl')) — и запустите правило. Semgrep выдал finding — всё работает. Нет — проверьте поле languages (должно совпадать с языком файла) и синтаксис паттерна. Для отладки удобен Semgrep Playground (semgrep.dev/editor) — онлайн-редактор, где правила тестируются на фрагментах кода без локальной установки.
Ограничения Semgrep и когда нужны другие инструменты
Semgrep OSS не видит поток данных между функциями и файлами. Если пользовательский ввод проходит через три функции прежде чем попасть в render_template_string(), паттерн-матчинг это пропустит. Два пути: Semgrep Pro с taint-анализом (платная подписка, $40/контрибьютор/месяц) или CodeQL (бесплатный для публичных репозиториев на GitHub) с межфункциональным dataflow-анализом. Порог входа у CodeQL заметно выше: правила пишутся на языке запросов QL, а не на YAML.
Для Python-проектов Semgrep стоит дополнить Bandit — специализированным SAST-инструментом, который из коробки проверяет pickle, eval, exec и другие опасные конструкции. Bandit не поддерживает кастомные правила в формате Semgrep, но его дефолтный набор хорошо закрывает типовые Python-уязвимости.
Semgrep не анализирует рантайм-поведение: если десериализация происходит через рефлексию или динамическую загрузку классов, статический анализ бессилен. Здесь нужен DAST (Dynamic Application Security Testing — тестирование безопасности работающего приложения) или ручной code review.
Большинство команд, которые внедряют SAST, останавливаются на дефолтных наборах правил и считают задачу закрытой. Это ловушка. Дефолтные правила не покрывают специфику конкретного фреймворка, конкретного шаблонизатора, конкретных паттернов работы с данными в вашем проекте. Я вижу одну и ту же картину: команда запускает semgrep --config auto, получает десять findings по хардкоженным секретам, чинит их и уходит с мыслью «у нас всё чисто». А render_template_string с пользовательским вводом продолжает жить в коде.
Написание одного кастомного правила — 15 минут работы. Набор из пяти правил под конкретный проект — вечер. Эффект — автоматическая проверка на каждом PR до конца жизни репозитория. Соотношение затрат и результата здесь лучше, чем у любого другого security-инструмента, который я внедрял за последние годы. Проблема не в инструменте, а в привычке: разработчики и безопасники привыкли потреблять готовые правила, а не создавать свои. Для тех, кто переходит в AppSec из разработки, умение писать кастомные SAST-правила — конкретный навык, который можно показать на собеседовании. Не сертификат, а правило, которое нашло реальную уязвимость в продакшн-коде. Базу для такого перехода — от понимания типов уязвимостей до практики с инструментами — можно пройти системно на IB Basics.
Эту тему и смежные навыки разбирают на практике в курсе «Тестирование веб-приложений на проникновение (WAPT)» Codeby Academy.