Java : vers une intégrité par défaut de la JVM
Cet article est paru pour la première fois dans le magazine Programmez! Hors série #16.
La Machine Virtuelle Java (JVM) est un environnement d’exécution qui permet à des programmes écrits en Java (ou dans d’autres langages compilés en bytecode Java) de s’exécuter sur différents systèmes d’exploitation et architectures matérielles.
Dès ses débuts, la JVM a été pensée pour être dynamique : elle peut exécuter du code non présent à la compilation par chargement de code à chaud. Elle peut aussi appeler des librairies natives, et supporte de nombreuses fonctionnalités de monitoring.
Depuis du code Java, il est aussi possible d’appeler dynamiquement du code Java grâce à l’API de réflexion, et même de faire des accès mémoire en passant outre les mécanismes d’allocation de mémoire de Java grâce à la classe Unsafe.
Toutes ces fonctionnalités ont fait de la JVM une des plateformes de choix pour le développement d’application d’entreprise.
Mais depuis sa création, les principes de sécurités ont évolués, les risques inhérents à la sécurité des applications et ses impacts étant de plus en plus grands, la JVM se devait d’évoluer pour limiter sa surface d’exposition aux risques tout en gardant les fonctionnalités qui ont fait son succès. Ces changements sont initiés depuis très longtemps, au moins depuis Java 9 et la modularisation de la JVM, mais ce n’est que récemment qu’une réflexion globale a vue lieux au sein de la Java Enhancement Proposal (JEP) Integrity by Default qui est encore en draft : JEP draft: Integrity by Default. Cette JEP définie ce qu’est l’intégrité par défaut, explique ses raisons et liste les JEP qui y participent. C’est une JEP parapluie qui regroupe de nombreuses autres JEP.
Qu’est-ce que l’intégrité par défaut ?
Voici ce qu’en dit la JEP :
Dans le contexte d’un programme informatique, l’intégrité signifie que les éléments constitutifs du programme, ainsi que le programme lui-même, sont à la fois entiers et valides.
Derrière cela, il y a un principe très simple : la spécification de la JVM doit décrire précisément ce qui est nécessaire pour qu’un programme soit valide et son implémentation doit y obéir. Par exemple : la spécification des tableaux définit qu’un tableau ne peut être accédé qu’à l’intérieur des limites définies lors de sa création ; cette contrainte est garantie par la JVM, qui lève une exception en cas de violation.
Les bénéfices de l’intégrité sont que :
- Le code est prévisible : Les variables ont toujours une valeur définie avant d’être utilisées, et les opérations sur les données sont toujours valides.
- La mémoire est gérée de manière sécurisée : Le risque de plantage dû à une mauvaise gestion de la mémoire est minimisé.
- Les programmes multi-threads sont stables : Les objets conservent un état cohérent, même dans des environnements multitâches.
L’encapsulation est un des principes fondamentaux qui permet l’intégrité de la JVM.
L’encapsulation consiste à regrouper des données et les méthodes qui les manipulent au sein d’une seule entité, généralement une classe. Cela permet de protéger les données d’accès externes et de modifications non autorisées, en garantissant qu’elles ne peuvent être manipulées que par le biais d’interfaces bien définies.
L’encapsulation amène de nombreux avantages : exactitude d’un programme, maintenabilité, évolutivité, sécurité et performance.
Attardons-nous sur ceux qui sont directement liés à l’intégrité de la JVM :
- Exactitude : L’encapsulation assure que les données sont accédées et modifiées de manière contrôlée, ce qui prévient les effets de bord et les erreurs inattendues.
- Sécurité : En restreignant l’accès aux données, l’encapsulation contribue à protéger les informations sensibles contre les accès non autorisés.
Néanmoins, il existe des API dans le kit de développement Java (JDK) qui peuvent contourner l’encapsulation :
- AccessibleObject::setAccessible(boolean) : Cette méthode permet la réflexion profonde, permettant d’accéder à des champs et des méthodes privés, même si normalement inaccessibles.
- sun.misc.Unsafe : Cette classe offre des méthodes pour accéder à des méthodes et des champs privés, ainsi qu’à des champs finaux.
- Java Native Interface (JNI) : JNI permet au code natif d’interagir avec des objets Java sans respecter les limites d’encapsulation.
- Instrumentation API : Cette API permet aux agents de modifier le bytecode de méthodes, ce qui peut contourner l’encapsulation.
L’intégrité par défaut de la JVM nécessite donc de restreindre par défaut le fonctionnement de ces API. L’idée n’est pas de les supprimer, mais d’en réduire la portée ou d’obliger le développeur à sciemment les autoriser de manière à contrôler leur utilisation.
Restreindre la réflexion profonde
Depuis Java 9 et l’introduction de la modularité (JEP 261: Module System), il est possible de restreindre la réflexion profonde en utilisant les modules Java, car la méthode AccessibleObject::setAccessible(boolean)
respecte les limites des modules : une classe d’un module ne peut pas modifier l’accessibilité d’un champ d’une classe d’un autre module.
Ce changement, initié avec Java 9 et la modularité du JDK a été mis en place progressivement, les accès non autorisé ont été tout d’abord découragé via l’émission d’un warning au lancement de l’application, puis prohibé en Java 16. Il est toujours possible d’autoriser la réflexion profonde soit globalement (--illegal-access=permit
) soit au cas par cas via l’ouverture de module (--add-opens
).
Restreindre l’utilisation d’Unsafe
La classe sun.misc.Unsafe
inclut des méthodes qui effectuent une variété d’opérations de bas niveau sans aucun contrôle de sécurité.
Un composant qui utilise Unsafe compromet l’intégrité de la JVM.
Unsafe est rarement utilisé par une application Java, néanmoins de nombreux frameworks se reposent sur lui ainsi que de nombreux agents Java.
Au fil des ans, de nombreux remplacements via des API supportées ont vu le jour, l’utilisation d’Unsafe est donc de moins en moins nécessaire.
La manipulation de bas niveau des objets dans la heap peut désormais être effectuée de manière plus sûre via l’API VarHandle, et la manipulation des données dans la mémoire hors heap peut désormais être effectuée de manière plus sûre via l’API MemorySegment.
Depuis Java 23 et la JEP 471: Deprecate the Memory-Access Methods in sun.misc.Unsafe for Removal, les méthodes d’accès mémoire d’Unsafe sont dépréciées, mais leur utilisation toujours permise. Il est possible d’en restreindre l’utilisation via l’option ligne de commande : --sun-misc-unsafe-memory-access
.
L’utilisation de ces méthodes sera progressivement restreinte dans les prochaines versions de Java. À partir de Java 24 elles émettent un warning dans les logs de la JVM lors de leur première utilisation.
Restreindre l’utilisation de JNI
Avec Java 24 et la JEP 472: Prepare to Restrict the Use of JNI, l’accès aux librairies natives sera restreint aussi bien pour JNI que pour la nouvelle API Foreign Function and Memory (FFM).
C’était déjà le cas pour l’API FFM depuis Java 22.
Une librairie native ne respecte pas l’intégrité de la JVM, car elle peut :
- Avoir un comportement non défini (undefined behavior) qui peut entraîner un crash de la JVM.
- Échanger de la donnée via des byte buffers direct.
- Accéder à des champs et des méthodes sans contrôle d’accès.
- Appeler des fonctions de la JVM de manière incorrecte.
Autoriser l’accès aux librairies natives nécessite l’utilisation de l’option ligne de commande --enable-native-access
ou l’attribut de manifeste Enable-Native-Access
qui peut soit être le nom d’un module ou ALL-UNNAMED
pour autoriser tout le code du classpath.
Pour l’instant, l’utilisation d’une librairie JNI non autorisée émettra un warning au lancement de l’application, mais leur utilisation sera progressivement restreinte dans les prochaines versions de Java.
Restreindre l’utilisation de l’API d’instrumentation
Un agent est un composant qui peut modifier le code d’une application pendant que celle-ci est en cours d’exécution.
Il peut donc compromettre l’intégrité de la JVM de nombreuses façons.
Depuis Java 21 et la JEP 451: Prepare to Disallow the Dynamic Loading of Agents, le chargement dynamique d’agent Java est restreint.
L’option ligne de commande -XX:+DisableAttachMechanism
permet de contrôler le chargement dynamique d’agent, elle est pour l’instant à true par défaut.
Pour l’instant, le chargement dynamique d’agent Java non autorisée émettra un warning au lancement de l’application, mais leur utilisation sera progressivement restreinte dans les prochaines versions de Java. Il sera alors nécessaire de déclarer tous les agents Java lors du lancement de la JVM via l’option ligne de commande --agent
.
Conclusion
La sécurité des applications est de plus en plus importante et c’est une bonne chose que Java évolue pour plus de sécurité par défaut. Cela met aussi plus de pouvoir dans les mains des développeurs qui vont pouvoir mieux maîtriser quelle librairie ou module peut réaliser quelle action (réflexion, chargement de librairie native…).