
Технический анализ CVE-2026-33701 — уязвимости небезопасной десериализации в RMI-инструментации OpenTelemetry Java Agent, включая условия эксплуатации, детали исправления и стратегии смягчения последствий.
Серьёзность: Критическая (CVSS v4.0: 9.3)
Затронутые версии: opentelemetry-javaagent < 2.26.1
Исправлено в: 2.26.1 (выпущено 23 марта 2026 года)
OpenTelemetry — пожалуй, самый широко используемый фреймворк наблюдаемости в экосистеме Java на данный момент. Java-агент, в частности, используется тысячами продакшн-сервисов для автоматической инструментации приложений для трассировки, метрик и логирования без необходимости вносить какие-либо изменения в код со стороны разработчика. Вы просто подключаете его как флаг javaagent, и он делает всё за кулисами.
Что делает CVE-2026-33701 интересным — это не только сама уязвимость, но и природа того, как она была внедрена. Агент незаметно регистрирует собственную пользовательскую RMI-конечную точку как побочный эффект своей RMI-инструментации. Разработчики не знают, что она там есть. Её нет ни в каком коде приложения. Это не то, что они настраивали. Агент разместил её автоматически, и в версиях ниже 2.26.1 эта конечная точка десериализовала входящие данные без каких-либо применённых фильтров.
Если приложение уже имело открытый RMI- или JMX-порт, злоумышленник, имеющий сетевой доступ к этому порту, мог отправить специально сформированную сериализованную полезную нагрузку на пользовательскую конечную точку агента и потенциально добиться удалённого выполнения кода с привилегиями процесса JVM. Загвоздка в том, что RCE требует наличия совместимой цепочки гаджетов в classpath приложения. Подробнее об этом позже.
Когда Java-агент OTel подключается к JVM, он инструментирует RMI-вызовы для распространения контекста. Идея в том, что когда RMI-клиент вызывает удалённый метод, агенту необходимо распространить текущий контекст трассировки на серверную сторону, чтобы спаны корректно связывались через границы сервисов.
Для этого агент регистрирует собственный пользовательский RMI-объект, используя жёстко заданный ObjID. В ContextPropagator.java:
public static final ObjID CONTEXT_CALL_ID =
new ObjID("io.opentelemetry.javaagent.context-call".hashCode());
Этот ObjID — то, как агент идентифицирует свою собственную конечную точку в среде выполнения RMI. Когда инструментированный RMI-клиент подключается к серверу, он сначала проверяет, зарегистрирован ли этот ObjID на сервере. Если да, он отправляет на него полезную нагрузку распространения контекста. Если нет, он пропускает распространение контекста и просто выполняет обычный вызов.
Проблема в том, что эта конечная точка регистрируется внутри среды выполнения RMI JVM, а значит, использует тот же транспорт, что и любой RMI- или JMX-порт, открытый приложением. Если приложение имеет настроенный -Dcom.sun.management.jmxremote.port, или если оно экспортирует собственные RMI-сервисы, конечная точка агента доступна на том же порту для любого, кто может к нему подключиться.
Десериализация происходит в ContextPayload.java. В версиях ниже 2.26.1 метод read() выглядел так:
@SuppressWarnings("BanSerializableRead") // fine
public static ContextPayload read(ObjectInput oi) throws IOException {
try {
Object object = oi.readObject();
if (object instanceof Map) {
@SuppressWarnings("unchecked")
Map<String, String> map = (Map<String, String>) object;
return new ContextPayload(map);
}
} catch (ClassCastException | ClassNotFoundException ex) {
logger.log(FINE, "Error reading object", ex);
}
return null;
}
Аннотация @SuppressWarnings("BanSerializableRead") с комментарием // fine на самом деле довольно показательна. Кто-то в команде в какой-то момент отметил это как проблему, было добавлено подавление, и комментарий должен был это оправдать. Очевидно, что это не было нормально.
oi.readObject() без фильтров сериализации — это классический паттерн небезопасной десериализации. Сериализация Java с радостью создаст экземпляр любого класса в classpath во время десериализации, и если злоумышленник может контролировать сериализованный поток, он может выбрать, какие классы будут созданы и в каком порядке. Это основа атак с цепочками гаджетов.
Код действительно проверяет instanceof Map после десериализации, но к этому моменту ущерб уже нанесён. Цепочка гаджетов выполняется во время самого вызова readObject(), до того как происходит какая-либо проверка типов. Проверка instanceof полностью нерелевантна с точки зрения безопасности.
Исправление в 2.26.1 полностью заменяет подход к десериализации. Вместо сериализации объекта Map и вызова readObject() новая реализация вручную считывает записи контекста как примитивные типы:
@Nullable
public static ContextPayload read(ObjectInput oi) throws IOException {
int size = oi.readInt();
if (size > MAX_CONTEXT_ENTRIES) {
logger.log(
FINE,
"RMI context propagation payload size {0} exceeds maximum allowed of {1}, skipping context propagation.",
new Object[] {size, MAX_CONTEXT_ENTRIES});
return null;
}
Map<String, String> map = new HashMap<>();
for (int i = 0; i < size; i++) {
String key = oi.readUTF();
String value = oi.readUTF();
map.put(key, value);
}
return new ContextPayload(map);
}
И сторона записи была обновлена соответствующим образом:
public void write(ObjectOutput out) throws IOException {
int size = context.size();
if (size > MAX_CONTEXT_ENTRIES) {
out.writeInt(0);
return;
}
out.writeInt(size);
for (Map.Entry<String, String> entry : context.entrySet()) {
out.writeUTF(entry.getKey());
out.writeUTF(entry.getValue());
}
}
Этот подход считывает из потока только примитивные типы (целые числа и UTF-строки). Никакого создания экземпляров объектов не происходит. Ни одна цепочка гаджетов не может выполниться через readInt() или readUTF(). Исправление корректно и полно.
Есть также второе изменение, заслуживающее внимания. ObjID, используемый для идентификации пользовательской конечной точки агента, был версионирован:
// До (2.26.0)
new ObjID("io.opentelemetry.javaagent.context-call".hashCode());
// После (2.26.1)
new ObjID("io.opentelemetry.javaagent.context-call-v2".hashCode());
Это означает, что исправленный агент вообще не будет отвечать на старый ObjID. Злоумышленник, нацеленный на старую конечную точку, не получит ответа от исправленного сервера. Это хорошая дополнительная мера усиления защиты поверх исправления десериализации — она фактически ломает любой эксплойт, созданный против конечной точки v1, даже если бы десериализация каким-то образом всё ещё была доступна.
Для эксплуатации должны одновременно выполняться все три следующих условия:
1. Java-агент OpenTelemetry подключён на JDK 16 или ниже
Это самое важное ограничение. JDK 17 внёс значительные изменения во внутренности RMI и обеспечивает гораздо более строгую инкапсуляцию модулей через Java Platform Module System. Большинство цепочек гаджетов, работающих против десериализации Java, полагаются на рефлексию для доступа к внутренним классам JDK или для вызова методов, которые обычно недоступны. JDK 17 ломает эти пути рефлексии по умолчанию из-за строгой инкапсуляции, что означает, что большинство известных цепочек гаджетов просто не работают на JDK 17 и выше.
Однако на JDK 8, 11 и 16 доступ через рефлексию гораздо более разрешителен, и цепочки гаджетов работают как ожидается. Эти версии JDK по-прежнему представляют значительную часть продакшн-развёртываний Java, особенно в старых корпоративных средах.
2. RMI- или JMX-порт доступен злоумышленнику по сети
Приложению необходимо иметь что-то вроде настроенного -Dcom.sun.management.jmxremote.port=9010, или оно должно экспортировать собственные RMI-сервисы. Конечная точка агента OTel использует тот же RMI-транспорт, который уже используется. Если RMI-порт не открыт, конечная точка недоступна.
3. Библиотека, совместимая с цепочками гаджетов, присутствует в classpath приложения
Сам jar-файл агента не содержит классов, совместимых с цепочками гаджетов. Мы подтвердили это, изучив список классов, извлечённых из jar-файла агента 2.26.0. Агент содержит код инструментации OTel, затенённые классы API, определения semconv и инфраструктуру начальной загрузки. Ни один из часто эксплуатируемых источников гаджетов, таких как Commons Collections, Commons BeanUtils или классы Spring Framework, не входит в состав агента.
Это означает, что цепочка гаджетов должна исходить от приложения. Разработчик должен был включить библиотеку, содержащую эксплуатируемые сериализационные гаджеты, что означает, что эксплуатируемость варьируется в зависимости от того, от чего зависит приложение.
Три вышеуказанных условия могут показаться ограничительными, но на самом деле они довольно часто встречаются вместе в реальных корпоративных Java-средах. Рассмотрим типичный продакшн-сценарий: бэкенд-сервис, работающий на JDK 11 или JDK 17 (с флагами --add-opens, которые распространены в контейнерных развёртываниях и могут частично повторно включить цепочки гаджетов), инструментированный агентом OTel для наблюдаемости, с включённым JMX для операционного мониторинга и деревом зависимостей, достаточно богатым, чтобы содержать классы, совместимые с гаджетами.
Ключевое понимание в том, что разработчики доверяют агентам наблюдаемости как пассивной инфраструктуре. Они ожидают, что агент будет наблюдать, а не открывать новые сетевые конечные точки. Созданная здесь поверхность атаки невидима с точки зрения разработчика приложения. Они не писали никакого RMI-кода. Они не настраивали никакой RMI-конечной точки. Агент сделал это молча как следствие инструментации.
Для дальнейшего исследования требования цепочки гаджетов проект ysoserial (публично доступный, широко цитируемый в академических и профессиональных исследованиях безопасности) документирует несколько цепочек гаджетов десериализации Java, которые релевантны для этого класса уязвимостей. Цепочки, такие как CommonsCollections, Spring1 и Spring2, являются хорошо документированными примерами того, как существующие классы библиотек могут быть объединены для достижения выполнения кода через небезопасную десериализацию. Какие конкретные цепочки применимы в данной среде, полностью зависит от того, какие библиотеки есть в classpath этого приложения.
Обновитесь до версии opentelemetry-javaagent 2.26.1 или новее. Это единственное полное исправление.
Если немедленное обновление невозможно, RMI-инструментацию можно полностью отключить, добавив следующее системное свойство в флаги запуска JVM:
-Dotel.instrumentation.rmi.enabled=false
Это отключает инструментацию, которая регистрирует уязвимую конечную точку, полностью устраняя поверхность атаки. Распространение контекста через RMI-вызовы не будет работать, пока установлен этот флаг, но это приемлемо как временная мера.
Независимо от этого CVE, если JMX открыт на сетевом порту без аутентификации, это следует рассматривать как критическую ошибку конфигурации независимо от того, присутствует ли агент OTel или нет.