
Análise técnica da CVE-2026-33701, uma vulnerabilidade de desserialização insegura na instrumentação RMI do OpenTelemetry Java Agent, incluindo condições de exploração, detalhes do patch e estratégias de mitigação.
Gravidade: Crítica (CVSS v4.0: 9.3)
Versões afetadas: opentelemetry-javaagent < 2.26.1
Corrigido em: 2.26.1 (lançado em 23 de março de 2026)
O OpenTelemetry é provavelmente o framework de observabilidade mais amplamente adotado no ecossistema Java atualmente. O agente Java especificamente é usado por milhares de serviços de produção para instrumentar automaticamente aplicações para rastreamento, métricas e logging, sem exigir nenhuma alteração de código por parte do desenvolvedor. Você apenas o anexa como uma flag javaagent e ele faz tudo nos bastidores.
O que torna a CVE-2026-33701 interessante não é apenas a vulnerabilidade em si, é a natureza de como ela foi introduzida. O agente registra silenciosamente um endpoint RMI personalizado como efeito colateral de sua instrumentação RMI. Os desenvolvedores não sabem que ele está lá. Não está em nenhum código de aplicação. Não é algo que eles configuraram. O agente o colocou lá automaticamente, e para versões anteriores à 2.26.1, esse endpoint estava desserializando dados recebidos sem absolutamente nenhum filtro aplicado.
Se a aplicação já tivesse uma porta RMI ou JMX exposta, um atacante com acesso de rede a essa porta poderia enviar um payload serializado especialmente criado para o endpoint personalizado do agente e potencialmente alcançar execução remota de código com os privilégios do processo JVM. O detalhe é que a RCE requer uma cadeia de gadgets compatível presente no classpath da aplicação. Mais sobre isso adiante.
Quando o agente Java OTel se anexa a uma JVM, ele instrumenta chamadas RMI para propagação de contexto. A ideia é que quando um cliente RMI chama um método remoto, o agente precisa propagar o contexto de rastreamento atual para o lado do servidor, para que os spans se conectem corretamente através das fronteiras de serviço.
Para fazer isso, o agente registra seu próprio objeto RMI personalizado usando um ObjID codificado. Em ContextPropagator.java:
public static final ObjID CONTEXT_CALL_ID =
new ObjID("io.opentelemetry.javaagent.context-call".hashCode());
Este ObjID é como o agente identifica seu próprio endpoint dentro do runtime RMI. Quando um cliente RMI instrumentado se conecta a um servidor, ele primeiro verifica se o servidor tem este ObjID registrado. Se tiver, ele envia um payload de propagação de contexto para ele. Se não, ele pula a propagação de contexto e apenas faz a chamada regular.
O problema é que este endpoint é registrado dentro do runtime RMI da JVM, o que significa que ele compartilha o mesmo transporte que qualquer porta RMI ou JMX que a aplicação tenha aberta. Se a aplicação tem -Dcom.sun.management.jmxremote.port definido, ou se ela exporta seus próprios serviços RMI, o endpoint do agente é alcançável nessa mesma porta por qualquer pessoa que possa se conectar a ela.
A desserialização acontece em ContextPayload.java. Em versões anteriores à 2.26.1, o método read() era assim:
@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;
}
A anotação @SuppressWarnings("BanSerializableRead") com o comentário // fine é na verdade bastante reveladora. Alguém na equipe sinalizou isso como uma preocupação em algum momento, a supressão foi adicionada, e o comentário foi destinado a justificá-la. Claramente não estava tudo bem.
oi.readObject() sem filtros de serialização é o padrão clássico de desserialização insegura. A serialização Java instanciará felizmente qualquer classe no classpath durante a desserialização, e se o atacante puder controlar o fluxo serializado, ele pode escolher quais classes são instanciadas e em que ordem. Essa é a base dos ataques de cadeia de gadgets.
O código verifica instanceof Map após a desserialização, mas nesse ponto o dano já está feito. A cadeia de gadgets executa durante a própria chamada readObject(), antes que qualquer verificação de tipo aconteça. A verificação instanceof é completamente irrelevante do ponto de vista de segurança.
A correção na 2.26.1 substitui completamente a abordagem de desserialização. Em vez de serializar um objeto Map e chamar readObject(), a nova implementação lê manualmente as entradas de contexto como tipos primitivos:
@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);
}
E o lado de escrita foi atualizado para corresponder:
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());
}
}
Esta abordagem apenas lê tipos primitivos (ints e strings UTF) do fluxo. Não há instanciação de objetos acontecendo. Nenhuma cadeia de gadgets pode executar através de readInt() ou readUTF(). A correção está correta e completa.
Há também uma segunda mudança que vale a pena notar. O ObjID usado para identificar o endpoint personalizado do agente foi versionado:
// Antes (2.26.0)
new ObjID("io.opentelemetry.javaagent.context-call".hashCode());
// Depois (2.26.1)
new ObjID("io.opentelemetry.javaagent.context-call-v2".hashCode());
Isso significa que um agente corrigido não responderá ao ObjID antigo de forma alguma. Um atacante mirando o endpoint antigo não obteria resposta de um servidor corrigido. Esta é uma boa medida adicional de endurecimento além da correção de desserialização, ela efetivamente quebra qualquer exploit que foi construído contra o endpoint v1, mesmo que de alguma forma a desserialização ainda fosse alcançável.
Todas as três condições a seguir devem ser verdadeiras simultaneamente para que isso seja explorável:
1. Agente Java OpenTelemetry anexado em JDK 16 ou inferior
Esta é a restrição mais importante. O JDK 17 introduziu mudanças significativas nos internals do RMI e impõe encapsulamento de módulos muito mais estrito através do Java Platform Module System. A maioria das cadeias de gadgets que funcionam contra a desserialização Java depende de reflexão para acessar classes internas do JDK ou para invocar métodos que normalmente não seriam acessíveis. O JDK 17 quebra esses caminhos de reflexão por padrão devido ao encapsulamento forte, o que significa que a maioria das cadeias de gadgets conhecidas simplesmente não funciona no JDK 17 e superior.
No JDK 8, 11 e 16, no entanto, o acesso por reflexão é muito mais permissivo e as cadeias de gadgets funcionam como esperado. Essas versões do JDK ainda representam uma parcela substancial das implantações Java em produção, especialmente em ambientes empresariais mais antigos.