
Technical analysis of CVE-2026-33701, an unsafe deserialization vulnerability in OpenTelemetry Java Agent RMI instrumentation, including exploit conditions, patch details, and mitigation strategies.
Severity: Critical (CVSS v4.0: 9.3)
Affected versions: opentelemetry-javaagent < 2.26.1
Patched in: 2.26.1 (released March 23, 2026)
OpenTelemetry is probably the most widely adopted observability framework in the Java ecosystem right now. The Java agent specifically is used by thousands of production services to automatically instrument applications for tracing, metrics and logging without requiring any code changes from the developer. You just attach it as a javaagent flag and it does everything behind the scenes.
What makes CVE-2026-33701 interesting isnt just the vulnerability itself, its the nature of how it got introduced. The agent quietly registers a custom RMI endpoint as a side effect of its RMI instrumentation. Developers dont know its there. Its not in any application code. Its not something they configured. The agent put it there automatically, and for versions below 2.26.1 that endpoint was deserializing incoming data with absolutely no filters applied.
If the application already had an RMI or JMX port exposed, an attacker with network access to that port could send a crafted serialized payload to the agents custom endpoint and potentially achieve remote code execution with the privileges of the JVM process. The catch is that RCE requires a compatible gadget chain to be present on the application classpath. More on that later.
When the OTel Java agent attaches to a JVM it instruments RMI calls for context propagation. The idea is that when an RMI client calls a remote method, the agent needs to propagate the current trace context to the server side so spans connect correctly across service boundaries.
To do this the agent registers its own custom RMI object using a hardcoded ObjID. In ContextPropagator.java:
public static final ObjID CONTEXT_CALL_ID =
new ObjID("io.opentelemetry.javaagent.context-call".hashCode());
This ObjID is how the agent identifies its own endpoint within the RMI runtime. When an instrumented RMI client connects to a server, it first checks if the server has this ObjID registered. If it does, it sends a context propagation payload to it. If not, it skips context propagation and just does the regular call.
The problem is that this endpoint is registered inside the JVM RMI runtime, meaning it shares the same transport as whatever RMI or JMX port the application has open. If the application has -Dcom.sun.management.jmxremote.port set, or if it exports its own RMI services, the agents endpoint is reachable on that same port by anyone who can connect to it.
The deserialization happens in ContextPayload.java. In versions below 2.26.1 the read() method looked like this:
@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;
}
The @SuppressWarnings("BanSerializableRead") annotation with the comment // fine is actually kind of telling. Someone on the team flagged this as a concern at some point, the suppression was added, and the comment was meant to justify it. Clearly it wasnt fine.
oi.readObject() with no serialization filters is the classic unsafe deserialization pattern. Java serialization will happily instantiate any class on the classpath during deserialization, and if the attacker can control the serialized stream they can choose which classes get instantiated and in what order. Thats the basis of gadget chain attacks.
The code does check instanceof Map after deserialization, but by that point the damage is already done. The gadget chain executes during the readObject() call itself, before any type checking happens. The instanceof check is completely irrelevant from a security standpoint.
The fix in 2.26.1 completely replaces the deserialization approach. Instead of serializing a Map object and calling readObject(), the new implementation manually reads the context entries as primitive types:
@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);
}
And the write side was updated to match:
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());
}
}
This approach only reads primitive types (ints and UTF strings) from the stream. There is no object instantiation happening. No gadget chain can execute through readInt() or readUTF(). The fix is correct and complete.
There is also a second change worth noting. The ObjID used to identify the agents custom endpoint was versioned:
// Before (2.26.0)
new ObjID("io.opentelemetry.javaagent.context-call".hashCode());
// After (2.26.1)
new ObjID("io.opentelemetry.javaagent.context-call-v2".hashCode());
This means a patched agent will not respond to the old ObjID at all. An attacker targeting the old endpoint would get no response from a patched server. This is a nice additional hardening measure on top of the deserialization fix, it effectively breaks any exploit that was built against the v1 endpoint even if somehow the deserialization was still reachable.
All three of the following must be true simultaneously for this to be exploitable:
1. OpenTelemetry Java agent attached on JDK 16 or lower
This is the most important constraint. JDK 17 introduced significant changes to the RMI internals and enforces much stricter module encapsulation via the Java Platform Module System. Most gadget chains that work against Java deserialization rely on reflection to access internal JDK classes or to invoke methods that wouldnt normally be accessible. JDK 17 breaks those reflection paths by default because of strong encapsulation, which means the majority of known gadget chains simply dont work on JDK 17 and above.
On JDK 8, 11, and 16 however, reflection access is much more permissive and gadget chains work as expected. These JDK versions still represent a substantial portion of production Java deployments, especially in older enterprise environments.
2. An RMI or JMX port is network-reachable by the attacker
The application needs to have something like -Dcom.sun.management.jmxremote.port=9010 configured, or it needs to export its own RMI services. The OTel agents endpoint piggybacks on whatever RMI transport is already in use. If theres no RMI port open, the endpoint isnt reachable.
3. A gadget-chain-compatible library is present on the application classpath