
Depuis ses origines et jusqu’à des mises à jour récentes des versions 8, 11 et 17, les archives Java, étaient comme une boîte de chocolats : on ne pouvait jamais savoir si on allait faire tomber la JVM… ou bien l’Android RunTime dans Android 13.
La méthode utilisée par OpenJDK pour extraire les informations contenues dans une archive Java (ou fichier JAR) était assez peu regardante quant aux ressources utilisées jusque relativement récemment. Si, par analogie, le contenu du JAR était de la confiture, on peut dire que OpenJDK s’en mettait partout sur le tas. Et tout autant que sur les doigts, la confiture ça colle !
Le ramasse-miette (ou garbage collector) a beau se démener, il était assez aisé de geler la machine virtuelle… dans le cas d’OpenJDK et celui d’Android 13.
1. Identification du code vulnérable
1.1 D’où vient le problème ?
Le problème date des origines d’OpenJDK, lors de son premier commit (appelé initial load) en 2007 dans ${java.base}/share/classes/java/util/jar/Attributes.java [1].
Pour traiter un fichier JAR, un programme Java s’aide des métadonnées contenues dans un fichier META-INF/MANIFEST.MF (que l’on appellera fichier Manifest) inclus dans ce même fichier JAR. Ils sont présentés sous la forme de couples : attribut et sa valeur associée. Dans l’exemple ci-dessous, ligne 4, l’attribut Name se voit attribuer la valeur WriteFileTest.class. Un caractère deux points ‘:’ sépare l’un de l’autre et une ligne sépare un couple du suivant. Ce dernier point est vrai sauf dans un cas bien précis qui nous intéresse pour la suite : si une ligne du fichier Manifest dépasse 512 octets. Dans ce cas, on peut continuer à écrire un attribut sur la ligne suivante. Il faut alors que cette nouvelle ligne débute par un espace vide, ou valeur 0x20 en hexadécimal. Parmi les attributs d’un fichier Manifest, on retrouve habituellement sa version, le nom de la classe principale (qui présente une fonction main), le nom de l’entité/développeur qui l’a créée et parfois une signature permettant de vérifier l’intégrité du fichier JAR (voir ligne 5 de l’exemple ci-dessous). C’est ce que l’on retrouve, par exemple, dans ce fichier JAR de test à l’intérieur d’OpenJDK-11 à jdk/test/jdk/java/util/jar/JarEntry/test.jar :
La vulnérabilité que nous abordons dans cet article est localisée dans la méthode int read(Manifest.fastInputStream is, byte[] lbuf,String filename,int lineNumber) de la classe Attributes.java. Cette méthode lit le fichier Manifest ligne à ligne, en y cherchant les attributs les uns après les autres. À la lecture d’un de ces attributs, la méthode adopte une approche incrémentale : en mémorisant chaque ligne entre deux déclarations d’attributs (voir ligne 376 ci-dessous). Pour reprendre l’exemple du pot de confiture, il s’agit d’augmenter incrémentalement sa force sur le couvercle du pot de confiture.
Comme le montre la ligne 388 : si la prochaine ligne commence par un caractère vide (" ", ou 0x20), alors la lecture de la valeur du dernier attribut déclaré n’est pas terminée. Tant qu’il y a une ligne à lire pour un même attribut du fichier Manifest, la méthode de lecture crée à chaque ligne un nouvel objet buf dont la taille égale la somme des lignes précédentes plus la taille de cette nouvelle ligne : `len`. La méthode alors concatène les lignes déjà lues lastline avec la nouvelle ligne qui vient de l’être lbuf (lignes 400 et 401).
Si, et seulement si, la prochaine ligne à être traitée déclare un nouvel attribut (c’est-à-dire qu’elle ne commence pas par un espace, ligne 402), le programme évite le continue; (ligne 404) et peut convertir l’addition des lignes lues (buf) en chaîne de caractères (value, ligne 406). À la prochaine itération de la boucle commencée ligne 376, le programme entrera dans la branche else de la ligne 408, plutôt que celle du if de la ligne 388.
Ce qui apparaît comme une approche « à tâtons », nous apparaîtra alors vite comme très maladroit. On a appliqué trop de force sur notre pot de confiture et celui-ci nous saute des mains pour s’écraser sur le sol à peine a-t-on pu réaliser que chaque tour de l’itération sur les lignes crée un nouveau buffer de résultat intermédiaire ‘buf’ (ligne 399) sur le tas. Sa taille fait la somme de la taille des lignes précédentes plus la taille de la nouvelle ligne, plus un espace de séparation entre chaque ligne de l’attribut en lecture. Rapidement, on crée ainsi des objets de taille sizeof(ligne), sizeof(ligne)x2,…,sizeof(ligne)xN, sizeof(ligne)x(N+1),… Et s’il y a beaucoup de lignes à lire, cela fera beaucoup d’objets qui iront éclabousser un peu partout sur le tas. Ainsi est né le déni de service connu sous le petit nom de CVE-2022-21340.
1.2 Développement du PoC
Maintenant que l’on sait que la méthode pour ouvrir le pot de confiture est susceptible d’exploser, il n’y a plus qu’à s’assurer du désastre en le remplissant de confiture à ras bord. Pour Java et OpenJDK, cela revient à remplir un attribut du fichier Manifest de beaucoup de lignes. En tenant compte du fait que chaque ligne du fichier Manifest ne dépasse pas 512 octets : dégâts garantis !
Un programme Python (on l’appellera generate.py) qui permet de créer la valeur d’un attribut adéquat est le suivant :
Il génère 300.001 lignes : une ligne commençant avec l’attribut “a ” suivie de deux cent trente “b ” (valeur 0x62 20 en hexadécimal). S’en suivent trois cent mille lignes commençant par un espace (0x20), suivi de deux cent trente “b ” (0x62 20) pour chaque ligne.
Il suffit alors de compresser dans un fichier JAR le dossier contenant le fichier Manifest :
Et enfin de demander à une version vulnérable d’OpenJDK d’aller y lire les attributs en utilisant les bons appels API :
Le JAR ouvert, la confiture se répand partout et gèle le programme.
Si on fait varier la taille du fichier JAR, en entrée, on observe bien un déni de service. Si la valeur de l’attribut tient sur deux ou trois lignes, le programme termine en une fraction de seconde, sans erreur. En revanche, comme le montre la figure 1, le temps nécessaire pour décompresser le fichier en entrée n’augmente pas linéairement avec la taille de la valeur de l’attribut. Sur Ubuntu 22.04 avec 32Go de RAM et 512Mo alloués au tas, l’ouverture prend une minute pour un attribut écrit sur 19 Mo, alors qu’elle prend dix minutes pour un attribut écrit sur 60 Mo. Ce qui se confirme encore sur la figure 1, où on peut lire qu’il faut dix-sept minutes en moyenne pour un fichier Manifest constitué d’un attribut écrit sur 80 Mo.
1.3 Mais que fait la police ?
Le ramasse-miettes (ou garbage collector pour les anglophones) patrouille, il travaille, mais il n’est pas de taille, comme le montre la figure 2. On observe via l’outil de visualisation JConsole [2] que le travail est de plus en plus dur : sur les huit premières minutes, ce sont deux mille appels au ramasse-miettes à la minute, puis trois mille par minute au-delà. Toujours sur le matériel évoqué en section 1.2, on observe un second problème au bout d’une demi-heure. Un problème qui en soi n’est pas lié à la CVE-2022-21340, mais qui vient s’y ajouter. JConsole nous fournit l’information que pendant une dernière minute, la fréquence d’appel au ramasse-miettes passe à quatre mille cinq cents collectes à la minute, avant que le programme... termine sur une erreur : java.lang.OutOfMemoryError: Java heap space. Ce deuxième problème vient du fait qu’OpenJDK ne vérifie pas la taille du fichier Manifest en entrée, ou ne limite pas sa taille.
Dans le cas où un fichier Manifest dépasse 108.5Mo, l’attente avant l’erreur peut avoisiner la demi-heure (voir l’axe des abscisses sur la figure 2) pour un tas de 512 Mo.
La saturation s’observe dans le journal du ramasse-miettes :
La dernière collecte se fait alors que seule la moitié du tas est occupée (256 Mo). On constate que le ramasse-miettes n’a alors pas pu libérer plus d’espace. Cela implique que d’un côté tout ce qui est sur le tas doit rester référencé. C’est notamment le cas de lastLine, pour pouvoir le concaténer avec la nouvelle ligne de l’attribut (voir ligne 400 et 401 de la méthode read de lecture des attributs en section 1.1). De l’autre côté, le ramasse-miettes est appelé. Ce qui signifie qu’il n’y a, en l’état, pas la place pour le nouvel objet à référencer (l’objet buf qui fait la taille de lastLine plus une ligne). En conséquence, la somme de la taille des deux dépasse l’espace disponible sur le tas et génère une erreur.
2. Correction de la vulnérabilité
2.1 Utiliser les bons outils
C’est sur le site de suivi des bogues Bugzilla que l’on retrouve le lien vers le correctif [3]. Ce patch, présenté en figure 3, propose d’utiliser la classe java.io.ByteArrayOutputStream pour ouvrir le fichier JAR proprement. Cette classe possède la particularité de créer des tableaux d’octets dont la taille peut être modifiée en mémoire (voir extrait ci-dessous). On utilise alors un seul et même objet plutôt que d’allouer toujours plus de ressource sur le tas. Dans le correctif, fullLine (ligne 370) est un objet de classe java.io.ByteArrayOutputStream. À l’écriture de nouveaux octets, la méthode ensureCapacity() (ligne 21 ci-dessous) vérifie s’il y a la place de le faire. Cela permet d’éviter d’écrire là où d’autres objets pourraient déjà être référencés.
Mais ByteArrayOutputStream contient aussi une méthode reset() qui assigne le champ count (nombre d’octets valides) à zéro. Cela permet de réutiliser le même objet plutôt que d’allouer, à chaque itération, un nouvel espace pour un nouveau tableau d’octets toujours plus grand que le précédent ! C’est intéressant dans notre cas, car cela évite de remplir le tas, et de surcharger le garbage collector de travail.
Lorsque l’on relance le programme avec une version corrigée d’OpenJDK celui-ci termine maintenant en une fraction de seconde, quelle que soit la taille des valeurs des attributs du fichier Manifest. Et ce, même dans le cas où le fichier Manifest sature le tas (lire en 2.2.1).
Fig. 3 : Patch de la CVE-2022-21340.
2.2 Versions affectées par la CVE
2.2.1 OpenJDK
Le code responsable de l’insertion de cette CVE date des débuts d’OpenJDK, en 2007. Le patch correctif est, quant à lui appliqué en septembre 2021 pour OpenJDK-17, en novembre 2021 pour OpenJDK-8 et janvier 2022 dans OpenJDK-11. Il ne sera intégré qu’à partir des versions : 8u322, 11.0.14, et 17.0.2. Si la CVE-2022-21340 est résolue, le programme plante alors toujours lorsqu’un fichier JAR plus grand que le tas est fourni ; mais il a l’amabilité de le faire une fraction de seconde (et non plus en 30 minutes). Il faudra attendre juillet 2023 pour que la taille d’entrée soit limitée dans les trois versions LTS de OpenJDK (8, 11 et 17) [4]. Alors enfin JarFile:getBytes() lance une exception en amont de la lecture, et nous informe que la taille du fichier Manifest est désormais limitée à 16 Mo (8 Mo dans un premier temps).
2.2.2 Android
Là où le bât blesse, c’est en pensant aux programmes qui utilisent des versions non-patchées d’OpenJDK, et notamment Android. Comme ce dernier a mis du temps à mettre en place une automatisation des mises à jour d’OpenJDK dans l’Android Runtime (ART) (pour lire plus à ce sujet, voir [5]), la version vulnérable fut présente jusqu’en février 2023 [6]. Soit plus d’un an et demi après sa publication dans OpenJDK-17. En voulant l’ouvrir (voir code disponible en [7]), quelqu’un a fait tomber notre pot de confiture dans le Tiramisu (Android 13). De manière moins imagée, une application Android essayant d’ouvrir le fichier JAR gèle jusqu’à ce que le système nous propose de fermer l’application qui ne répond pas (vidéo disponible en [8]). Pour un fichier Manifest de taille 2,8 Mo (i<=6000 lignes dans le programme generate.py de la section 1.2), le système met 9 secondes à ouvrir le fichier Manifest. C’est tout juste ce qu’il faut pour que le message de déni de service ne s’affiche pas sur Android si on appuie de manière répétée sur l’activité.
On voit d’ailleurs sur la figure 4, que pour un fichier JAR de grande taille, le ramasse-miettes est appelé fréquemment, ne contenant qu’un objet de grande taille, occupant tout l’espace du tas (“0% free”) et n’arrivant plus rien à libérer.
Lorsque le fichier Manifest dépasse 132 Mo (soit 285.000 lignes) Android Runtime s’arrête assez rapidement, nous informant qu’il ne peut allouer autant de ressources.
Conclusion
De cette histoire, on retiendra que ce n’est pas parce qu’il y a quelqu’un qui nettoie que l’on peut laisser traîner ses déchets, même avec Java. Et si la CVE-2022-21340 n’affecte au final que la disponibilité, on a pu voir qu’assez aisément ce type de vulnérabilité, relativement triviale à exploiter, a perduré la majeure partie de la vie d’OpenJDK… jusqu’à déborder sur Android.
Références
[1] OpenJDK, Initial Load commit de OpenJDK-7, https://hg.openjdk.org/jdk7/jdk7/jdk/file/37a05a11f281/src/share/classes/java/util/jar/Attributes.java, visité le 5 février 2024
[2] OpenJDK, The Java Monitoring and Management Console (jconsole), https://openjdk.org/tools/svc/jconsole/, visité le 7 février 2024
[3] Sean Coffeys, Correctif de CVE-2022-21340, https://github.com/openjdk/jdk17u/commit/f2cb3d0f6cf16d12abd811b42484a0b7df8fd124, visité le 5 février 2024
[4] OpenJDK, Commit limitant la taille du fichier Manifest à 8 puis 16 Mo, https://github.com/openjdk/jdk/commit/ecd0bc1d6205d1d1eca67cbfb9d4deaeb65739aa, https://github.com/openjdk/jdk/commit/e47a84f23dd2608c6f5748093eefe301fb5bf750
[5] T.Riom & A.Bartel, An In-Depth Analysis of Android’s Java Class Library: its Evolution and Security Impact, https://www.abartel.net/static/p/secdev2023-AndroidJCL.pdf, visité le 5 février 2024
[6] Android, Correctif de CVE-2022-21340 dans Android, Android, https://cs.android.com/android/_/android/platform/libcore/+/4c551dbec6cf5f10f6bd269a2f5451ada352e1cc, visité le 5 février 2024
[7] SES group - Umeå Universitet, Application exploitant CVE-2022-21340 sur Android 13, https://github.com/software-engineering-and-security/AndroidsJCL-SecDev23/tree/main/RQ3-Exploit, visité le 5 février 2024
[8] SES group - Umeå Universitet, Vidéo du déni de service sur Android, https://www.youtube.com/shorts/ybUCuvrV3-E, visité le 5 février 2024