Juste à temps ! Mais à quel prix ? Le JIT et ses problèmes...
Vous arrivez juste à temps ! Parlons de la compilation JIT pour just-in-time, ou dynamique : une stratégie d'exécution de code très répandue dans nos navigateurs Web mais qui n'est pas sans défauts pour la sécurité.
Mais pourquoi je montrerais des images de sprays ? Lisez, ça fera sens.
JIT est un terme qui est revenu à plusieurs reprises sur mon blog, et j'ai eu peu d'occasions de l'expliquer en détails. Par "JIT" je fais probablement référence à la compilation just-in-time, aussi parfois appelée dynamique ou encore à la volée. Pour bien comprendre le concept, il faut opposer la compilation JIT à la compilation AOT (signifiant ahead-of-time) ainsi qu'à l'interprétation, deux autres stratégies d'exécution de code.
Différentes stratégies d'exécution de code
La compilation AOT (ahead-of-time)
L'exemple de compilation AOT pourrait vous parler davantage : par exemple, un code écrit dans un langage de programmation tel que le C++ est compilé en code natif afin que ce dernier puisse être exécuté par la machine. Ce code machine est un code de bas niveau qui contient des instructions que le CPU comprend et exécute directement (alors que nous, pauvres humains, ne saurions pas quoi en faire).
Dans le cas de la compilation AOT, le code source lisible par l'humain est transformé en code natif machine en amont de son exécution, à l'aide d'un compilateur. C'est une étape intermédiaire qui se répercute sur le développement logiquement, puisqu'il faut attendre la fin de la compilation avant de pouvoir exécuter le programme (bien qu'il existe des façons d'accélérer la chose pour des builds destinées au développement).
L'inteprétation
D'autres langages tels que le Python ne nécessitent pas cette étape de compilation préalable. Ils embarquent ce qu'on appelle un runtime, un programme déjà écrit dans un langage de bas niveau qui lira et interprétera à la volée le code source de votre programme. Ce dernier n'est donc jamais vraiment compilé à proprement parler, mais exécute souvent du code précompilé en réalité. Les performances obtenues sont nettement inférieures, mais la flexibilité permise est grande et plus adaptée pour certains usages.
Nous verrons d'autres exemples pratiques au cours de cet article, notamment dans le cas du Java et du JavaScript, mais ces derniers sont dans la pratique directement concernés par le JIT.
La compilation JIT (just-in-time)
Une autre stratégie est donc la compilation JIT, qu'il convient d'aborder désormais puisqu'elle reprend des concepts des deux stratégies ci-dessus en promettant la vitesse d'un code compilé, et la flexibilité de l'interprétation.
La compilation JIT nécessite que le code source soit compilé en bytecode au préalable ou à la volée : le bytecode est un code intermédiaire entre le code source et le code machine, que ce soit en complexité ou portabilité. Ce bytecode peut ensuite être interprété, ou bien compilé. Un compilateur JIT va compiler du bytecode dynamiquement au moment de son exécution, permettant des performances presque natives sans sacrifier entièrement le temps en amont nécessaire pour le compiler (AOT).
Si le JIT allie les avantages des stratégies d'interprétation et de compilation en amont, il allie aussi ses défauts : il y aura toujours un délai d'exécution (bien que plus faible qu'une compilation proprement dite), et dans l'intérêt que ce délai ne soit pas trop long le code natif exécuté ne sera pas aussi optimisé que dans une stratégie AOT. Voyez le JIT comme un compromis.
Mais vous le savez désormais, sur ce blog nous avons tendance à voir les choses à travers la loupe de la sécurité. Si jusque-là nous avons parlé de performances, la sécurité est une question évidemment primordiale. Malheureusement, le JIT est une stratégie d'exécution de code qui n'est pas sans compromis sur la sécurité comme nous allons le voir.
Le JIT : une violation à W^X
Mais d'abord, qu'est-ce que W^X ?
W^X signifie write xor execute, "xor" étant le "OU exclusif" : un concept selon lequel une page de la mémoire d'un programme quel qu'il soit doit être accessible en écriture ou en exécution, mais jamais les deux à la fois. Pour information, une page de la mémoire se réfère en pratique à une unité de zone dans la RAM (nous simplifierons les détails).
Sans W^X, il serait possible pour un programme d'écrire (W) des données exécutables (X) dans une zone censée être lue (R). Cette combinaison (RWX) est dangereuse car ouvre la voie à de l'injection malicieuse de code, en plus de tout simplement ne pas être compatible avec un modèle de sécurité moderne qui exige que le code soit signé en amont. En somme, il faut idéalement que la génération dynamique de code soit interdite.
Heureusement, W^X est adopté par plusieurs mitigations implémentées par tous les processeurs modernes (cf. NX bits) et noyaux/OS. Par exemple, une forme basique de W^X existe depuis longtemps sous Windows sous le nom de Data Execution Prevention (DEP). W^X existe aussi sur les systèmes Unix et dérivés (mprotect
, SELinux), et est évidemment massivement présent sur Android et iOS. Android 10 par exemple a drastiquement renforcé ses règles prévenant l'exécution de code tiers par les applications, et GrapheneOS étend cette protection à l'ensemble du système et ses applications.
Malheureusement, JIT est une violation à W^X et on ne peut pas y faire grand chose car cette violation est fondamentalement attendue : du code arbitraire est dynamiquement compilé et immédiatement exécuté depuis la mémoire, ouvrant la voie à des vulnérabilités et exploitations.
Exemple d'attaque : JIT spraying
Un exemple typique d'attaque permise par la compilation JIT est le JIT spraying, un type d'exploit très commun ayant fait des ravages notamment avec le défunt Adobe Flash (on espère qu'il ne reviendra plus jamais).
- En premier lieu, l'idée est de créer des structures de données contenant ce qui pourrait être exécuté comme du code, ce dernier étant malicieux.
- Ensuite, l'ensemble de ce code de haut-niveau est compilé en code natif, et l'attaquant cherchera à l'exécuter par le biais d'un autre bug très classique (comme une buffer overflow).
- Le code malicieux sera exécuté alors que ce n'était pas l'intention de départ du compilateur JIT.
Ce type de problème ne devrait pas arriver avec W^X, puisque le code malicieux n'aurait jamais été dans une zone de mémoire marquée comme exécutable. Mais du fait du fonctionnement de JIT, garantir W^X est impossible, et donc ces vulnérabilités sont rendues possibles. C'est assez commun de retrouver l'utilisation de JIT dans des chaînes de vulnérabilités permettant d'échapper à la sandbox et de compromettre le système : un exemple d'actualité serait Safari qui fut une portée d'entrée d'un des exploits développés dans le contexte de Pegasus.
Où est utilisé le JIT en pratique, et quelles solutions ?
Maintenant que je vous ai assez fait peur, regardons ce qu'il en est en pratique et quelles solutions sont possibles, ainsi que leurs coûts.
Le JIT dans le navigateur
Problème : le JIT est extrêmement commun puisque vous l'utilisez probablement en ce moment même pour exécuter du JavaScript. Sur les navigateurs Chromium, c'est le moteur V8 qui s'occupe de l'interprétation (Ignition) et de la compilation JIT (TurboFan) du JavaScript. Si l'interpétation est utilisée pour du code ponctuel (cold code), le JIT prend la relève pour du code répété (hot code), ce qui fait sens.
Le choix d'adopter le JIT a été délibérément fait par tous les navigateurs existants dans l'optique d'améliorer significativement les performances. Mais si l'on regarde les vulnérabilités liées aux navigateurs, une part très importante de celles-ci sont liées directement à JIT :
Bien sûr, des tentatives de protection ont été mises en place par tous les moteurs depuis plusieurs années, mais comme vous pouvez le constater, on ne peut pas changer la nature intrinsèque d'une implémentation fondamentalement contradictoire avec un concept de sécurité essentiel. Je vous conseille notamment de lire cette analyse de Project Zero sur les protections de JIT adoptées par Safari qui sont peut-être parmi les plus intéressantes, mais ne sont pas si bulletproof que ça.
J'accorde cependant une mention spéciale à la V8 memory cage (ou heap sandbox) qui est en voie d'être adoptée par Chromium :
The goal of the V8 heap sandbox is to prevent an attacker with the ability to corrupt V8 objects from constructing an arbitrary memory read/write primitive. This is achieved through two fundamental mechanisms: the virtual memory cage [...] and the external pointer table.
Quant à Firefox, la situation est loin d'être glorieuse malgré sa prétention de fournir un "W^X JIT". Je vous réfère à madaidan qui a pris le soin d'étudier des études abordant les différents problèmes liés aux mesures de protection (ou plutôt leur absence) mises en place par les navigateurs.
La meilleure protection : désactiver JIT
La solution la plus efficace et radicale est de se passer de JIT. Quoi, c'est possible ? Pourquoi ça ne le serait pas ! Le JavaScript peut très bien être "juste" interprété et c'est notamment ce que propose le moteur V8 avec son mode JIT-less (l'interpréteur Ignition est utilisé).
Pour Chromium et tous les navigateurs dérivés, il faut donc activer le mode JIT-less de V8 en exécutant Chromium avec un argument supplémentaire :
--js-flags="--jitless"
Il n'y a pas de façon de l'activer plus simplement dans les paramètres, ni par un flag, ce qui est dommage. Sur Windows, il faut configurer le raccourci pour ajouter cet argument dans la cible, après l'exécutable. Les utilisateurs de Linux et macOS sauront faire, je pense.
Edge Chromium propose le Super Duper Secure Mode (article que je conseille d'ailleurs de lire) qui est son implémentation du JIT-less prêt à être activé pour l'utilisateur final :
Le mode Balanced est "intelligent" puisqu'il désactive le JIT seulement pour les sites que vous ne fréquentez pas régulièrement, histoire que cela ne gêne pas potentiellement votre navigation. Tandis que le mode Strict désactive le JIT partout, en vous laissant la possibilité d'ajouter des exceptions : c'est ce que je conseille en général.
Par ailleurs, la désactivation de JIT sur Chromium/Edge sous Windows permet l'activation d'ACG (Arbitrary Code Guard) dans le processus de rendu, prévenant totalement la génération de code dynamique en mémoire.
Vanadium, le dérivé de Chromium proposé par GrapheneOS, offre lui aussi un paramètre pour désactiver le JIT :
Sur Firefox, il faut apporter quelques modifications dans about:config
:
user_pref("javascript.options.ion", false);
user_pref("javascript.options.baselinejit", false);
user_pref("javascript.options.native_regexp", false);
user_pref("javascript.options.asmjs", false);
user_pref("javascript.options.wasm", false);
Le navigateur Tor Browser désactive déjà JIT quand il est utilisé dans un mode de sécurité (qui est pour rappel le seul paramètre que vous devriez changer du navigateur) plus élevé que le niveau "normal".
Dans Safari, il n'y a pas de moyen à ma connaissance de désactiver le JIT. Pour la défense d'Apple, de nombreux efforts ont été apportés à la sécurité de leur compilateur JIT ainsi qu'aux mitigations liées à la mémoire de leurs systèmes. Mais ce n'est pas assez, hélas. Je soupçonne une question d'orgueil qui empêche Apple d'aller jusqu'au bout : il ne faut pas que Safari fasse moins dans les benchmarks que ses concurrents "par défaut". C'est dommage sachant que Safari est souvent le talon d'Achille d'iOS.
Cette surenchère à la performance entre les navigateurs depuis 2008 (année d'introduction du JIT dans un navigateur grand public) a conduit à la situation actuelle où cette stratégie est répandue alors qu'elle ne devrait pas forcément l'être.
JIT désactivé sur le web : en pratique ?
Il y a des conséquences potentielles à la désactivation du JIT dans le navigateur :
- Une baisse de performances
- Une baisse de compatibilité
- Qu'en est-il de l'empreinte ?
Dans le premier cas, l'équipe de recherche derrière Microsoft Edge a mené une petite étude pour déterminer le coût de cette désactivation :
Globalement, la majorité du temps, la navigation en sera inchangée. Il serait même très difficile de percevoir la différence au quotidien. Dans certains cas, on constate étonnamment qu'il y a même des améliorations. Sur des benchmarks cependant, la différence peut s'avérer grande (58% mesuré sur Speedometer 2.0), mais doit-on vraiment se fier à des benchmarks alors qu'en situation réelle il y a très peu de compromis ?
Enfin, par "baisse de compatibilité" je me réfère à toutes ces applications web qui cesseront de fonctionner. C'est rare, mais possible, notamment celles qui dépendent lourdement de WebAssembly (wasm) qui est surtout prévu pour une compilation JIT/AOT. Cependant rien n'empêche qu'il soit interprété comme le montre ce projet (et AOT est toujours une option).
De mon expérience, j'ai rarement rencontré ce genre de problème, mais le dashboard de Cloudflare ou encore le client Matrix Element sont problématiques avec le JIT désactivé. Notez qu'ils peuvent être ajoutés en liste blanche sur Edge. Je n'ai aussi pas constaté de baisses de performances visibles à l'oeil nu, que ce soit sur mon PC fixe ou encore mon Pixel 3a.
Je ne souhaitais pas m'étaler longuement sur cet aspect, mais désactiver le JIT comme tout paramètre est un élément qui peut potentiellement rendre votre empreinte plus unique puisqu'il affecte les performances, et que celles-ci peuvent être mesurées. On parle vraiment d'une situation particulière ; mais je pense que l'arrivée du JIT-less activable facilement dans Edge va faire en sorte que de plus en plus de personnes utiliseront ce mode.
En conclusion : désactiver le JIT me semble être un bien meilleur compromis qui peut être appliqué à presque tout le monde. Aucun compromis sur la sécurité, compromis minimal sur les performances et la compatibilité. Le JIT est une surface d'attaque inutile dans la majorité des cas, et je suis ravi de constater que Google et Microsoft commencent à s'en rendre compte.
Le JIT dans Android (et GrapheneOS)
Si cet article était surtout orienté vers les navigateurs, je ne pouvais m'empêcher de faire un aparté sur le JIT chez Android. Les restrictions du JIT entre Android et iOS modernes (surtout Safari/Webkit) sont comparables et un cran au-dessus des autres systèmes d'exploitation traditionnels.
GrapheneOS va encore plus loin :
- Il étend un W^X strict à l'ensemble du système
- Il propose un mode JIT-less facilement activable dans Vanadium
- Il pré-compile le bytecode des applications (compilation AOT)
C'est un point que j'avais omis d'aborder dans mes précédents articles sur GrapheneOS, alors je me disais que c'était l'occasion parfaite. En effet, comme vous le savez déjà peut-être, les applications Android sont dans l'immense majorité des cas du bytecode Java/Kotlin.
En réalité, c'est un bytecode différent du bytecode Java "classique". Il est appelé Dalvik bytecode (au format .dex).
Le moteur ART (Android Runtime) est un mélange de toutes ces stratégies, mais principalement un hybride entre la compilation JIT (pour du hot code : souvent utilisé) et l'interprétation (pour du cold code : rarement utilisé).
Historiquement, le précédent moteur Dalvik disposait aussi de la compilation JIT, et ART utilisait la stratégie AOT (uniquement) lors de ses débuts dans Android 5 avant de préférer JIT à nouveau depuis Android 7, et de réserver AOT pour ce que le moteur considère comme du code adapté (hot code).
Une régression ? Le choix de Google se justifiait peut-être par la nature très vaste du parc d'Android : une application compilée entièrement à l'avance prend davantage de place, en plus d'imposer des temps d'installation longs selon les performances de l'appareil.
GrapheneOS choisit d'utiliser le mode AOT et de désactiver totalement JIT. Les applications sont donc compilées à l'installation, les rendant forcément plus longues. Le point positif c'est que le bytecode une fois compilé s'exécutera rapidement et sans compromis pour la sécurité. Un gain en performance ET en sécurité, avec un compromis sur le temps d'installation seulement : c'est une situation presque 100% gagnante pour une fois.
Je m'attaque donc un peu au mythe de "la sécurité au détriment des performances", quand ce n'est pas toujours le cas !
Je vous remercie d'avoir lu, et à la prochaine fois.