
Технический анализ 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-порт доступен злоумышленнику по сети