Aller au contenu principal

Electron et la Cage mémoire de V8

· 8 mins de lecture

Electron 21 et les versions ultérieures auront la cage de mémoire V8 activée, avec des implications pour certains modules natifs.


Mise à jour (2022/11/01)

Pour suivre les discussions en cours à propos de l'utilisation des modules natifs dans Electron 21+, consultez electron/electron#35801.

Electron 21 permet les pointeurs V8 en bac à sable, suite à la décision de Chrome de faire de même dans Chrome 103. Cela a des implications pour les modules natifs. De plus, nous avons précédemment, dans Electron 14, activé une technologie connexe, la compression de pointeur. Nous n'en avons pas beaucoup parlé sur le moment, mais la compression de pointeur a des implications sur la taille maximale du tas de V8.

Ces deux technologies, lorsqu'elles sont activées, sont significativement bénéfiques pour la sécurité, les performances et l'utilisation de la mémoire. Mais leur activation présente également des inconvénients.

L'inconvénient principal de l'activation des pointeurs dans un bac à sable est que les ArrayBuffers qui pointent vers la mémoire externe (« hors tas ») ne sont plus autorisés. Cela signifie que les modules natifs qui dépendent de cette fonctionnalité dans V8 devront être refactorisés pour continuer à fonctionner dans Electron 20+.

L'inconvénient principal de l'activatiçon de la compression de pointeur est que le tas de V8 est limité à une taille maximale de 4Go. Les détails exacts sont un peu compliqués — par exemple, Les ArrayBuffers sont comptés séparément du reste du tas V8, mais ont leurs propres limites.

Le Groupe de travail Electron Upgrades considère que les avantages de la compression de pointeur et de la cage mémoire de V8 l'emportent sur les désavantages. Il y a trois raisons principales à cela :

  1. Cela conserve Electron plus proche de Chromium. Moins Electron se différencie de Chromium dans des détails internes complexes tels que la configuration de V8, moins nous avons de chances d'introduire accidentellement des bogues ou des vulnérabilités de sécurité. L'équipe de sécurité de Chromium est formidable, et nous voulons nous assurer que nous tirons parti de leur travail. De plus, si un bogue affecte uniquement des configurations qui ne sont pas utilisées dans Chromium, sa correction n'est pas susceptible d'être une priorité pour l'équipe Chromium.
  2. Cela assure de meilleures performances. La compression de pointeur réduit la taille du tas de V8 jusqu'à 40% et améliore les performances du processeur et du GC de 5 à 10%. Pour la grande majorité des applications Electron qui n'atteindront pas dans la limite de taille de tas de 4 Go et n'utilisent pas de modules natifs nécessitant les buffers externes, ce sont des gains de performance significatifs.
  3. C'est plus sûr. Certaines applications Electron exécutent du JavaScript non fiable (en suivant, nous l'espérons, nos recommandations de sécurité !), et, pour ces applications, l'activation de la cage mémoire de V8 protège contre une grande classe de vulnérabilités pernicieuses de V8.

Enfin, il y a des solutions pour les applications qui ont vraiment besoin d'une taille de tas supérieure. Par exemple, il est possible d'inclure avec votre application une copie de Node.js générée avec la compression de pointeur désactivée, et de déplacer le travail nécéssitant beaucoup de mémoire vers un processus enfant. Bien que ce soit quelque peu compliqué, il est également possible de construire une version personnalisée d'Electron avec la compression de pointeur désactivée, si vous décidez d'avoir un compromis différent répondant à votre cas d'utilisation. Et enfin, dans un avenir pas si lointain, wasm64 permettra aux applications construites avec WebAssembly, sur le Web et dans Electron, d'utiliser beaucoup plus que 4 Go de mémoire.


FAQ

Comment savoir si mon application est affectée par ce changement ?

Tenter d'encapsuler la mémoire externe dans un ArrayBuffer causera un plantage à l'exécution, dans Electron 20+.

Si vous n'utilisez aucun module Node natif dans votre application, vous n'êtes pas concernés — il n'y a aucun moyen de déclencher ce plantage exclusivement avec JS. Ce changement n'affecte que les modules natifs de Node qui allouent de la mémoire en dehors du tas de V8 (ex : en utilisant malloc ou new) puis encapsulent la mémoire externe dans un ArrayBuffer. C'est un cas d'utilisation assez rare, mais certains modules utilisent cette technique, et devront être refactorisés afin d'être compatibles avec Electron 20+.

Comment puis-je mesurer la quantité de mémoire de tas V8 que mon application utilise pour savoir si je suis proche de la limite de 4Go ?

Dans le processus de rendu, vous pouvez utiliser performance.memory.usedJSHeapSize, qui retournera l'utilisation du tas de V8 en octets. Dans le processus principal, vous pouvez utiliser process.memoryUsage().heapUsed, qui est comparable.

Qu'est-ce que la cage mémoire de V8 ?

Certains documents l'appellent "le bac à sable V8", mais ce terme peut être confondu facilement avec d'autres types de bac à sable présents dans Chromium, donc je m'en tiendrai au terme "cage mémoire".

Voici un type assez courant d'exploitation de failles de V8 :

  1. Trouver un bogue dans le moteur JIT de V8. Les moteurs JIT analysent le code afin de pouvoir omettre les vérifications lentes de type à l'exécution afin de produire du code machine rapide. Parfois, des erreurs de logique signifient que cette analyse est erronée, et causent l'omission d'une vérification de type qui est réellement nécessaire — par exemple, il pense que x est une chaîne alors que c'est en fait un objet.
  2. Abuser de cette confusion pour écraser quelque octets de mémoire dans le tas de V8, par exemple, un pointeur vers le début d'un ArrayBuffer.
  3. Maintenant vous avez un ArrayBuffer qui pointe où vous voulez, ce qui permet de lire et écrire à n'importe quel emplacement mémoire dans le processus, y compris de la mémoire à laquelle V8 n'a normalement pas accès.

La cage mémoire de V8 est une technique conçue pour prévenir catégoriquement ce type d'attaque. Cela est accompli en ne stockant aucun pointeur dans le tas de V8. Au lieu de cela, toutes les références à une autre mémoire dans le tas de V8 sont stockées sous forme d'offset à partir du début d'une région réservée. Ensuite, même si un attaquant parvient à corrompre l'adresse de début d'un ArrayBuffer, par exemple en exploitant dans V8 une erreur de confusion de type , il ne pourra, au pire, que de lire et écrire de la mémoire dans la cage, ce qu'il pouvait probablement déjà faire. Il y a beaucoup plus de choses à lire sur le fonctionnement de la cage mémoire de V8, je n'entrerai donc pas dans les détails ici — le meilleur endroit est pour commencer probablement le document de design de haut niveau de l'équipe Chromium.

Je veux refactoriser un module natif de Node pour supporter Electron 21+. Comment faire ?

Il y a deux façons de refactoriser un module natif pour qu'il soit compatible avec la cage mémoire de V8. La première est de copier les tampons créés en externe dans la cage mémoire de V8 avant de les passer à JavaScript. C'est généralement une refactorisation simple, mais qui peut introduire des lenteurs lorsque les tampons sont importants. L'autre approche est d'utiliser l'allocateur de mémoire de V8 pour allouer la mémoire que vous avez l'intention de passer à JavaScript. C'est un peu plus complexe, mais évitera la copie, ce qui signifie une meilleure performance pour les tampons de grande taille.

Pour rendre cela plus concret, voici un exemple de module N-API qui utilise des tableaux externes de tampons :

// Créer un tampon alloué dans la mémoire externe.
// |create_external_resource| alloue de la mémoire via malloc().
size_t length = 0;
void* data = create_external_resource(&length);
// Encapsulation dans un Buffer — échouera si la cage mémoire est activée!
napi_value result;
napi_create_external_buffer(
env, length, data,
finalize_external_resource, NULL, &result);

Cela va planter si la cage mémoire est activée, car les données sont allouées en dehors de la cage. En refactorisant pour copier les données dans la cage, nous obtenons :

size_t length = 0;
void* data = create_external_resource(&length);
// Créer un nouveau Buffer en copiant les données dans la mémoire allouée à V8
napi_value result;
void* copied_data = NULL;
napi_create_buffer_copy(env, length, data, &copied_data, &result);
// Pour accéder à la nouvelle copie, |copied_data| est un pointeur vers
// cette copie!

Cela copiera les données dans une zone de mémoire nouvellement allouée contenue dans la cage mémoire de V8. De manière optionnelle, N-API peut également fournir un pointeur vers les données nouvellement copiées, au cas où vous auriez besoin de les modifier ou de les référencer après initialisation.

Refactoriser pour utiliser l'allocateur de mémoire de V8 est un peu plus compliqué, puisqu'il nécessite de modifier la fonction create_external_resource pour utiliser la mémoire allouée par V8, au lieu de malloc. Cela peut être plus ou moins faisable, selon que vous contrôlez ou non la définition de create_external_resource. L'idée est dans un premier temps de créer le tampon en utilisant V8, par exemple avec napi_create_buffer, puis d'initialiser la ressource dans la mémoire allouée par V8. Il est important de conserver un napi_ref à l'objet Buffer pour la durée de vie de la ressource, sinon, V8 peut expulser le Buffer au travers du ramasse-miette et entraîner potentiellement des erreurs d'utilisation de mémoire libérée.