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

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

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

На аудите Java-сервиса финтех-компании Semgrep подсветил четыре вызова ObjectInputStream.readObject(). Три — шум: тесты и внутренний кэш. Четвёртый — REST-контроллер, который принимает Base64-строку от клиента и десериализует её без какой-либо валидации. SAST-сканер (инструмент статического анализа безопасности кода) пометил строку жёлтым — «потенциальная небезопасная десериализация Java». Между жёлтым флагом и подтверждённой RCE (Remote Code Execution — удалённое выполнение кода на сервере) — три шага ручной работы: уточнение находки Semgrep, разбор скомпилированного байткода через javap и сборка рабочего payload в ysoserial. Ниже — каждый шаг так, как я выполнял его на реальном проекте.

Уязвимость десериализации Java — что ищем и зачем это атакующему

Сериализация — превращение Java-объекта в поток байтов для хранения или передачи по сети. Десериализация — обратный процесс: из байтов восстанавливается объект. В Java за это отвечает класс ObjectInputStream и его метод readObject().

Уязвимость десериализации Java — CWE-502 (Deserialization of Untrusted Data) — возникает, когда приложение принимает сериализованные данные из недоверенного источника (HTTP-запрос, очередь сообщений, файл от пользователя) и вызывает readObject() без проверки того, какой объект восстанавливается. CWE-502 относится к классу нарушений целостности данных: последствия — от модификации данных приложения и отказа в обслуживании до выполнения произвольного кода. В классификации OWASP Top 10 2021 проблема попадает в категорию A08 — Software and Data Integrity Failures (нарушения целостности ПО и данных).

Почему это критично: при десериализации JVM автоматически вызывает специальные методы объекта — readObject(), readResolve(), finalize(). Если в classpath приложения (наборе библиотек, доступных во время выполнения) есть классы с опасной логикой в этих методах, атакующий собирает из них цепочку гаджетов (gadget chain) — последовательность вызовов, которая заканчивается выполнением произвольных команд на сервере.

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

Бизнес-логика атаки для злоумышленника простая: не нужно подбирать пароли, искать SQL-инъекции или ждать действий пользователя. Один HTTP-запрос — и атакующий выполняет команды внутри периметра. В enterprise-средах Java-сервисы часто обрабатывают платёжные данные и работают с повышенными привилегиями: компрометация такого сервиса — прямой путь к денежным транзакциям и персональным данным клиентов, а иногда — к полной компрометации инфраструктуры. По данным Contrast Security, Java-приложения лидируют по доле уязвимостей десериализации среди всех языков, при этом каждое приложение может испытывать до 13 000 атак в месяц.

Статический анализ кода Java — первый проход Semgrep

Первый шаг — обнаружить все точки, где приложение десериализует внешние данные. Автоматический сканер проходит по исходному коду и отмечает опасные паттерны.

Semgrep — открытый инструмент статического анализа кода Java (и других языков), работающий по YAML-правилам. В его официальном реестре есть набор p/java с правилами для безопасности. Правила ловят вызовы ObjectInputStream.readObject(), readUnshared(), XMLDecoder.readObject(), XStream.fromXML() и аналогичные конструкции, связанные с OWASP deserialization рисками.

Зачем: автоматический поиск сужает область ручного code review безопасности с тысяч файлов до десятков конкретных строк. Запуск по проекту (предусловие — установленный Semgrep: pip install semgrep или Docker-образ returntocorp/semgrep):

semgrep --config "p/java" --include="*.java" ./src/

В выводе каждая находка содержит путь к файлу, номер строки и идентификатор правила. Дальше — отделить реальные угрозы от шума:

  • Вызов readObject() в файле из src/test/ — тестовая десериализация, не эксплуатируемая извне. Игнорируем.
  • Сериализованные байты приходят из внутреннего кэша (Redis, Hazelcast) и не контролируются пользователем — риск ниже, хотя и не нулевой: при компрометации кэша атакующий получает тот же вектор.
  • Данные приходят из HTTP-параметра, заголовка, тела запроса или RMI-вызова — приоритетный кандидат на эксплуатацию.

На нашем проекте четвёртое срабатывание оказалось в контроллере, который принимал Base64-строку в POST-параметре data, декодировал её в байты и передавал в new ObjectInputStream(new ByteArrayInputStream(data)). Классический паттерн небезопасной десериализации из CWE-502. Индикатор для чёрного ящика (когда исходников нет): сериализованные Java-объекты начинаются с AC ED 00 05 в hex или rO0 в Base64.

Что SAST не покрывает и почему важен classpath

SAST видит вызов readObject(), но не знает, какие библиотеки лежат в classpath. А без уязвимых библиотек gadget chain собрать невозможно — у атакующего нет «строительных блоков» для цепочки. Это главное ограничение: SAST находит «дверь», но не проверяет, есть ли «ключ».

После SAST-скана нужен анализ зависимостей. В Maven-проекте — mvn dependency:tree, в Gradle — gradle dependencies. Вывод — дерево всех библиотек проекта, включая транзитивные (подтянутые автоматически). На что смотреть при поиске gadget chain для десериализации:

  • Apache Commons Collections версий 3.x до 3.2.2 и 4.0 — самый известный источник gadget-цепочек. Класс InvokerTransformer позволяет вызывать произвольные методы через рефлексию.
  • Commons BeanUtils — содержит BeanComparator, используемый в цепочке CommonsBeanutils1.
  • Spring Framework определённых версий — spring-beans может участвовать в цепочках через PropertyPathFactoryBean.
  • Groovy (версии 2.3.x и 2.4.x до 2.4.4) — классы MethodClosure и ConvertedClosure позволяют выполнить системную команду через механизм замыканий, как подробно разобрано в исследовании PVS-Studio.

На нашем проекте в pom.xml обнаружился commons-collections:3.2.1 — уязвимая версия. Это переводит находку из категории «потенциальная» в «вероятно эксплуатируемая». Но оставался вопрос — заказчик утверждал, что проблему уже закрыли.

Анализ байткода Java через javap — когда исходников недостаточно

Заказчик показал коммит: «Мы добавили проверку типа перед кастом десериализованного объекта, смотрите ветку hotfix/deser-fix». Исходный код контроллера действительно изменился — появилась проверка instanceof перед приведением типа. Проблема: проверка после readObject() не защищает от gadget chain. Вредоносный код выполняется внутри readObject() ещё до того, как приложение проверит тип. Но даже если бы фикс был корректным — попал ли он вообще на прод?

Тут нужен анализ байткода Java. javap — дизассемблер из стандартного JDK. Он показывает инструкции JVM, которые выполняются фактически в .class-файле, а не то, что написано в .java-исходнике.

Три ситуации, когда javap необходим в AppSec:

  1. Верификация патча. Заказчик говорит «мы исправили» — через дифф байткода (сравнение двух версий скомпилированного кода) вы убеждаетесь, что фикс скомпилирован и попал в production-артефакт. В нашем случае выяснилось, что hotfix-ветка не была смёржена в main — на проде работал старый код без какой-либо проверки.

  2. Закрытые зависимости. Коммерческие библиотеки без исходников поставляются в виде JAR с .class-файлами. Если нужно понять, что делает readObject() внутри такой библиотеки — вариант один: разобрать байткод.

  3. Дифф между версиями библиотеки. Чтобы понять, чем патченая версия commons-collections 3.2.2 отличается от уязвимой 3.2.1 — дифф байткода покажет конкретные изменения в критичных классах вроде InvokerTransformer.

Практика диффа: сравниваем скомпилированные классы

Предусловие: установленный JDK (javap входит в стандартную поставку). Работаем из терминала. Цель — сравнить класс InvokerTransformer между двумя версиями commons-collections:

javap -c -p -cp commons-collections-3.2.1.jar \
  org.apache.commons.collections.functors.InvokerTransformer > old.txt
javap -c -p -cp commons-collections-3.2.2.jar \
  org.apache.commons.collections.functors.InvokerTransformer > new.txt
diff old.txt new.txt

Флаги: -c показывает байткод-инструкции (не только сигнатуры методов), -p включает private-методы, -cp указывает JAR-файл как classpath. Ожидаемый результат diff: в версии 3.2.2 класс InvokerTransformer модифицирован — добавлен вызов проверки в readObject(), блокирующий десериализацию через FunctorUtils. Если diff пуст — файл не изменился и вы смотрите не тот артефакт.

На нашем проекте diff подтвердил: JAR на проде содержал 3.2.1 без изменений. «Патч» из исходников контроллера не только не решал проблему архитектурно (проверка instanceof после readObject() бесполезна против gadget chains), но и физически не попал в production-сборку. Дифф байткода вскрыл обе проблемы за пять минут.

Gadget chain и ysoserial — подтверждение эксплуатации

SAST нашёл точку входа, анализ зависимостей подтвердил уязвимую библиотеку, байткод показал отсутствие патча. Осталось доказать эксплуатабельность. Для ysoserial-эксплуатации десериализации это стандартный инструмент.

Как работает цепочка гаджетов Java

Gadget chain (цепочка гаджетов) — последовательность вызовов методов, начинающаяся с автоматически вызываемого при десериализации метода и заканчивающаяся выполнением команды ОС. Каждый «гаджет» — обычный класс из легитимной библиотеки. По отдельности они безвредны. Опасность — когда атакующий собирает их в определённую конфигурацию.

На примере цепочки CommonsCollections1 (одна из классических):

  1. JVM вызывает readObject() у объекта AnnotationInvocationHandler (внутренний класс JDK).
  2. Внутри readObject() происходит обращение к entrySet() на Map, которая является Proxy-объектом (обёрткой, перехватывающей вызовы методов).
  3. Proxy перенаправляет вызов через LazyMap.get()ChainedTransformer.transform()InvokerTransformer.transform().
  4. InvokerTransformer через рефлексию (механизм Java для вызова методов по имени) вызывает Runtime.getRuntime().exec() с командой атакующего.

Вся цепочка отрабатывает до того, как ваш код успеет проверить тип десериализованного объекта. Именно поэтому instanceof после readObject() — пластырь на пулевое ранение.

ysoserial автоматизирует сборку таких цепочек. Утилита содержит десятки готовых gadget chains для разных библиотек: CommonsCollections1–7, CommonsBeanutils1, Spring1–2, Groovy1 и других. Какую цепочку использовать — зависит от библиотек в classpath целевого приложения. Список доступных цепочек виден при запуске java -jar ysoserial-all.jar без аргументов — каждая строка содержит название и требуемую библиотеку.

Генерация payload’а и проверка (предусловие: Java 8, скачанный ysoserial-all.jar из официального репозитория):

java -jar ysoserial-all.jar CommonsCollections1 'id' \
  | base64 -w0 > payload.txt
curl -X POST http://target:8080/api/deserialize \
  -d "data=$(cat payload.txt)"

Как читать результат: если в ответ приходит вывод команды id (uid, имя пользователя, группы) — readObject-уязвимость подтверждена и эксплуатируема. ClassNotFoundException — нужная библиотека отсутствует в classpath, попробуйте другую цепочку. InvalidClassException — возможно, работает фильтр десериализации (JEP 290, ObjectInputFilter), что является правильной защитой.

На нашем проекте CommonsCollections1 вернул вывод id в теле ответа. Уязвимость десериализации Java перешла из жёлтого флага SAST в статус «критическая, с рабочим эксплойтом».

Поиск уязвимостей десериализации — делай раз, делай два, делай три

Полный алгоритм для code review безопасности или пентеста Java-приложения. Предусловия: доступ к исходному коду или скомпилированным артефактам (JAR/WAR), установленные JDK 8+, Semgrep и ysoserial.

Шаг Что делаем Инструмент Результат
1 Находим вызовы readObject Semgrep / CodeQL Список файлов и строк
2 Проверяем classpath на уязвимые библиотеки mvn dependency:tree / gradle dependencies Наличие commons-collections и др.
3 Верифицируем патч через байткод javap + diff Фикс есть или отсутствует
4 Генерируем и отправляем payload ysoserial + curl RCE подтверждено или нет
5 Документируем для отчёта CVE, CWE, CVSS, рекомендации

Шаг 1: SAST-скан. Запустите Semgrep с набором p/java по исходному коду. Отфильтруйте результаты: оставьте вызовы readObject(), readUnshared(), XMLDecoder.readObject(), XStream.fromXML(), где данные приходят из внешнего источника. Тесты и внутренние кэши — в отдельный список с пониженным приоритетом. Если работаете без исходников (чёрный ящик) — ищите в HTTP-трафике строки, начинающиеся с rO0 (Base64) или байты AC ED 00 05 (hex), а также заголовок Content-Type: application/x-java-serialized-object.

Шаг 2: Анализ classpath. Выгрузите дерево зависимостей. Сравните с таблицей поддерживаемых цепочек ysoserial. Обращайте внимание не только на прямые зависимости: транзитивные библиотеки (подтянутые автоматически через зависимости зависимостей) — частый источник gadget-классов. На крупном проекте commons-collections может оказаться в classpath, даже если разработчики не добавляли его явно.

Шаг 3: Верификация через байткод. Если заказчик утверждает, что проблема закрыта — не доверяйте исходникам, проверяйте скомпилированный артефакт. Достаньте JAR/WAR с прода или из CI/CD. Через javap -c -p дизассемблируйте критичный класс и сравните с исходным кодом фикса. Отдельно проверьте версию уязвимой библиотеки в артефакте — она может отличаться от указанной в pom.xml (snapshot-сборки, кэшированные артефакты).

Шаг 4: Подтверждение эксплуатации. Через ysoserial сгенерируйте payload для цепочки, соответствующей найденной библиотеке. Начинайте с безвредных команд: id, hostname, whoami. На production — никаких деструктивных команд. Если первая цепочка не сработала — переберите другие варианты для той же библиотеки (CommonsCollections1–7 используют разные классы, не все могут быть доступны в конкретной версии).

Шаг 5: Отчёт. Если эксплуатация подтверждена — укажите: точку входа (URL, параметр), использованную gadget chain, версию уязвимой библиотеки, CWE-502, ссылку на OWASP A08:2021 и рекомендации. Стандартные рекомендации по устранению: обновить библиотеку до безопасной версии, добавить фильтр десериализации JEP 290 (механизм JDK для ограничения классов, допустимых к десериализации, через ObjectInputFilter) или заменить ObjectInputStream на безопасный формат — JSON через Jackson без полиморфного типирования или Protocol Buffers.

Это воронка: SAST выдаёт десятки находок, анализ classpath отсеивает большинство, байткод-верификация отлавливает «фантомные фиксы», ysoserial даёт финальное доказательство. На каждом шаге вы либо подтверждаете проблему и идёте дальше, либо опровергаете — и останавливаетесь.

Большинство Java-команд, с которыми я работал на аудитах, считают проблему десериализации закрытой: «Мы перешли на JSON — нам не грозит». На практике это иллюзия. Jackson с включённым enableDefaultTyping() или аннотацией @JsonTypeInfo(use=Id.CLASS) уязвим к тем же атакам через другой вектор. XStream, часто живущий в classpath «по историческим причинам», по умолчанию десериализует любой класс. А сервисы на Spring Boot тащат в classpath десятки транзитивных зависимостей — commons-collections оказывается среди них чаще, чем хотелось бы. Фильтры JEP 290 помогают, но требуют белого списка классов, и его нужно поддерживать при каждом обновлении зависимостей. Ни одна команда, с которой я работал, не делала это регулярно. SAST ловит точку входа, но не видит classpath. Ручной дифф байткода ловит несостоявшиеся патчи, но эту работу не масштабируешь на сотни сервисов. AppSec-практика по десериализации сводится к тому, чтобы комбинировать все три подхода и не доверять ни одному из них по отдельности. Если хочешь выстроить эту базу системно, а не собирать из разрозненных статей — IB Basics на codeby.school.

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