Перейти к содержимому

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

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

На 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.