
Analyse technique de CVE-2026-33701, une vulnérabilité de désérialisation non sécurisée dans l'instrumentation RMI de l'agent Java OpenTelemetry, incluant les conditions d'exploitation, les détails du correctif et les stratégies d'atténuation.
Sévérité : Critique (CVSS v4.0 : 9.3)
Versions concernées : opentelemetry-javaagent < 2.26.1
Corrigé dans : 2.26.1 (publié le 23 mars 2026)
OpenTelemetry est probablement le framework d'observabilité le plus largement adopté dans l'écosystème Java actuellement. L'agent Java en particulier est utilisé par des milliers de services de production pour instrumenter automatiquement les applications pour le tracing, les métriques et la journalisation, sans nécessiter aucune modification de code de la part du développeur. Il suffit de l'attacher comme flag javaagent et il fait tout en arrière-plan.
Ce qui rend CVE-2026-33701 intéressant, ce n'est pas seulement la vulnérabilité elle-même, c'est la nature de la façon dont elle a été introduite. L'agent enregistre silencieusement un endpoint RMI personnalisé comme effet secondaire de son instrumentation RMI. Les développeurs ne savent pas qu'il est là. Il n'est dans aucun code applicatif. Ce n'est pas quelque chose qu'ils ont configuré. L'agent l'a mis là automatiquement, et pour les versions antérieures à 2.26.1, cet endpoint désérialisait les données entrantes sans aucun filtre appliqué.
Si l'application avait déjà un port RMI ou JMX exposé, un attaquant ayant un accès réseau à ce port pouvait envoyer une charge utile sérialisée spécialement conçue à l'endpoint personnalisé de l'agent et potentiellement obtenir une exécution de code à distance avec les privilèges du processus JVM. Le hic, c'est que l'exécution de code à distance nécessite qu'une chaîne de gadgets compatible soit présente sur le classpath de l'application. Plus de détails à ce sujet plus tard.
Lorsque l'agent Java OTel s'attache à une JVM, il instrumente les appels RMI pour la propagation de contexte. L'idée est que lorsqu'un client RMI appelle une méthode distante, l'agent doit propager le contexte de trace actuel au côté serveur afin que les spans se connectent correctement à travers les frontières de service.
Pour ce faire, l'agent enregistre son propre objet RMI personnalisé en utilisant un ObjID codé en dur. Dans ContextPropagator.java :
public static final ObjID CONTEXT_CALL_ID =
new ObjID("io.opentelemetry.javaagent.context-call".hashCode());
Cet ObjID est la façon dont l'agent identifie son propre endpoint dans le runtime RMI. Lorsqu'un client RMI instrumenté se connecte à un serveur, il vérifie d'abord si le serveur a cet ObjID enregistré. Si c'est le cas, il envoie une charge utile de propagation de contexte. Sinon, il ignore la propagation de contexte et effectue simplement l'appel normal.
Le problème est que cet endpoint est enregistré dans le runtime RMI de la JVM, ce qui signifie qu'il partage le même transport que le port RMI ou JMX que l'application a ouvert. Si l'application a -Dcom.sun.management.jmxremote.port défini, ou si elle exporte ses propres services RMI, l'endpoint de l'agent est accessible sur ce même port par quiconque peut s'y connecter.
La désérialisation se produit dans ContextPayload.java. Dans les versions antérieures à 2.26.1, la méthode read() ressemblait à ceci :
@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;
}
L'annotation @SuppressWarnings("BanSerializableRead") avec le commentaire // fine est en fait assez révélatrice. Quelqu'un dans l'équipe a signalé cela comme une préoccupation à un moment donné, la suppression a été ajoutée, et le commentaire était destiné à la justifier. Clairement, ce n'était pas correct.
oi.readObject() sans filtres de sérialisation est le schéma classique de désérialisation non sécurisée. La sérialisation Java instanciera volontiers n'importe quelle classe sur le classpath pendant la désérialisation, et si l'attaquant peut contrôler le flux sérialisé, il peut choisir quelles classes sont instanciées et dans quel ordre. C'est la base des attaques par chaîne de gadgets.
Le code vérifie bien instanceof Map après la désérialisation, mais à ce stade, les dégâts sont déjà faits. La chaîne de gadgets s'exécute pendant l'appel readObject() lui-même, avant que toute vérification de type ne se produise. La vérification instanceof est complètement hors de propos d'un point de vue sécurité.
Le correctif dans 2.26.1 remplace complètement l'approche de désérialisation. Au lieu de sérialiser un objet Map et d'appeler readObject(), la nouvelle implémentation lit manuellement les entrées de contexte comme des types primitifs :
@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);
}
Et le côté écriture a été mis à jour pour correspondre :
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());
}
}
Cette approche ne lit que des types primitifs (entiers et chaînes UTF) depuis le flux. Aucune instanciation d'objet ne se produit. Aucune chaîne de gadgets ne peut s'exécuter via readInt() ou readUTF(). Le correctif est correct et complet.
Il y a aussi un deuxième changement qui mérite d'être noté. L'ObjID utilisé pour identifier l'endpoint personnalisé de l'agent a été versionné :
// Avant (2.26.0)
new ObjID("io.opentelemetry.javaagent.context-call".hashCode());
// Après (2.26.1)
new ObjID("io.opentelemetry.javaagent.context-call-v2".hashCode());
Cela signifie qu'un agent corrigé ne répondra pas du tout à l'ancien ObjID. Un attaquant ciblant l'ancien endpoint n'obtiendrait aucune réponse d'un serveur corrigé. C'est une mesure de durcissement supplémentaire intéressante en plus du correctif de désérialisation, elle casse effectivement toute exploitation construite contre l'endpoint v1 même si, d'une manière ou d'une autre, la désérialisation était toujours accessible.
Les trois conditions suivantes doivent être vraies simultanément pour que cela soit exploitable :
1. Agent Java OpenTelemetry attaché sur JDK 16 ou inférieur
C'est la contrainte la plus importante. JDK 17 a introduit des changements significatifs dans les internals RMI et impose une encapsulation de modules beaucoup plus stricte via le Java Platform Module System. La plupart des chaînes de gadgets qui fonctionnent contre la désérialisation Java reposent sur la réflexion pour accéder aux classes internes du JDK ou pour invoquer des méthodes qui ne seraient normalement pas accessibles. JDK 17 casse ces chemins de réflexion par défaut en raison de la forte encapsulation, ce qui signifie que la majorité des chaînes de gadgets connues ne fonctionnent tout simplement pas sur JDK 17 et supérieur.
Sur JDK 8, 11 et 16 cependant, l'accès par réflexion est beaucoup plus permissif et les chaînes de gadgets fonctionnent comme prévu. Ces versions du JDK représentent encore une part substantielle des déploiements Java en production, en particulier dans les environnements d'entreprise plus anciens.
2. Un port RMI ou JMX est accessible sur le réseau par l'attaquant
L'application doit avoir quelque chose comme -Dcom.sun.management.jmxremote.port=9010 configuré, ou elle doit exporter ses propres services RMI. L'endpoint de l'agent OTel s'appuie sur le transport RMI déjà en usage. S'il n'y a pas de port RMI ouvert, l'endpoint n'est pas accessible.
3. Une bibliothèque compatible avec les chaînes de gadgets est présente sur le classpath de l'application
Le jar de l'agent lui-même ne contient aucune classe compatible avec les chaînes de gadgets. Nous l'avons confirmé en inspectant la liste des classes extraites du jar de l'agent 2.26.0. L'agent contient du code d'instrumentation OTel, des classes API shaded, des définitions semconv et de l'infrastructure de bootstrap. Aucune des sources de gadgets couramment exploitées comme Commons Collections, Commons BeanUtils ou les classes Spring Framework n'est incluse dans l'agent.
Cela signifie que la chaîne de gadgets doit provenir de l'application. Le développeur doit avoir inclus une bibliothèque contenant des gadgets de sérialisation exploitables, ce qui signifie que l'exploitabilité varie selon les dépendances de l'application.
Les trois conditions ci-dessus peuvent sembler restrictives mais elles sont en réalité assez courantes ensemble dans les environnements Java d'entreprise réels. Considérons un scénario de production typique : un service backend fonctionnant sur JDK 11 ou JDK 17 (avec des flags --add-opens, courants dans les déploiements conteneurisés et pouvant partiellement réactiver les chaînes de gadgets), instrumenté avec l'agent OTel pour l'observabilité, avec JMX activé pour la supervision opérationnelle, et un arbre de dépendances suffisamment riche pour contenir des classes compatibles avec les gadgets.
Le point clé est que les développeurs font confiance aux agents d'observabilité comme à une infrastructure passive. Ils s'attendent à ce que l'agent observe, pas qu'il ouvre de nouveaux endpoints accessibles sur le réseau. La surface d'attaque créée ici est invisible du point de vue du développeur d'application. Ils n'ont écrit aucun code RMI. Ils n'ont configuré aucun endpoint RMI. L'agent l'a fait silencieusement comme conséquence de l'instrumentation.
Pour des recherches plus approfondies sur l'exigence de chaîne de gadgets, le projet ysoserial (publiquement disponible, largement référencé dans la recherche en sécurité académique et professionnelle) documente plusieurs chaînes de gadgets de désérialisation Java pertinentes pour cette classe de vulnérabilité. Des chaînes comme CommonsCollections, Spring1 et Spring2 sont des exemples bien documentés de la façon dont les classes de bibliothèques existantes peuvent être enchaînées pour obtenir une exécution de code via une désérialisation non sécurisée. Les chaînes spécifiques applicables dans un environnement donné dépendent entièrement des bibliothèques présentes sur le classpath de cette application.
Mettez à niveau vers opentelemetry-javaagent version 2.26.1 ou ultérieure. C'est le seul correctif complet.
Si une mise à niveau immédiate n'est pas possible, l'instrumentation RMI peut être entièrement désactivée en ajoutant la propriété système suivante aux flags de démarrage de la JVM :
-Dotel.instrumentation.rmi.enabled=false
Cela désactive l'instrumentation qui enregistre l'endpoint vulnérable, supprimant entièrement la surface d'attaque. La propagation de contexte à travers les appels RMI ne fonctionnera pas tant que ce flag est défini, mais c'est acceptable comme mesure d'atténuation temporaire.
Indépendamment de cette CVE, si JMX est exposé sur un port accessible sur le réseau sans authentification, cela doit être traité comme une mauvaise configuration critique, que l'agent OTel soit présent ou non.