
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
The agent jar itself does not contain any gadget-chain-compatible classes. We confirmed this by inspecting the class list extracted from the 2.26.0 agent jar. The agent contains OTel instrumentation code, shaded API classes, semconv definitions and bootstrap infrastructure. None of the commonly exploited gadget sources like Commons Collections, Commons BeanUtils, or Spring Framework classes are bundled inside the agent.
This means the gadget chain has to come from the application. The developer has to have included a library that contains exploitable serialization gadgets, which means the exploitability varies depending on what the application depends on.
The three conditions above might sound restrictive but they are actually quite common together in real enterprise Java environments. Consider a typical production scenario: a backend service running on JDK 11 or JDK 17 (with --add-opens flags, which are common in containerized deployments and can partially re-enable gadget chains), instrumented with the OTel agent for observability, with JMX enabled for operational monitoring, and a dependency tree rich enough to contain gadget-compatible classes.
The key insight is that developers trust observability agents as passive infrastructure. They expect the agent to observe, not to open new network-reachable endpoints. The attack surface created here is invisible from the application developers perspective. They didnt write any RMI code. They didnt configure any RMI endpoint. The agent did it silently as a consequence of instrumentation.
For further research on the gadget chain requirement, the ysoserial project (publicly available, widely referenced in academic and professional security research) documents several Java deserialization gadget chains that are relevant to this class of vulnerability. Chains like CommonsCollections, Spring1, and Spring2 are well documented examples of how existing library classes can be chained to achieve code execution through unsafe deserialization. Which specific chains are applicable in a given environment depends entirely on what libraries that application has on its classpath.
Upgrade to opentelemetry-javaagent version 2.26.1 or later. This is the only complete fix.
If immediate upgrade isnt possible, the RMI instrumentation can be disabled entirely by adding the following system property to the JVM startup flags:
-Dotel.instrumentation.rmi.enabled=false
This disables the instrumentation that registers the vulnerable endpoint, removing the attack surface entirely. Context propagation across RMI calls will not work while this flag is set, but that is acceptable as a temporary mitigation.
Independently of this CVE, if JMX is exposed on a network-reachable port without authentication it should be treated as a critical misconfiguration regardless of the OTel agent being present or not.