IdentifiantMot de passe
Loading...
Mot de passe oublié ?Je m'inscris ! (gratuit)

Vous êtes nouveau sur Developpez.com ? Créez votre compte ou connectez-vous afin de pouvoir participer !

Vous devez avoir un compte Developpez.com et être connecté pour pouvoir participer aux discussions.

Vous n'avez pas encore de compte Developpez.com ? Créez-en un en quelques instants, c'est entièrement gratuit !

Si vous disposez déjà d'un compte et qu'il est bien activé, connectez-vous à l'aide du formulaire ci-dessous.

Identifiez-vous
Identifiant
Mot de passe
Mot de passe oublié ?
Créer un compte

L'inscription est gratuite et ne vous prendra que quelques instants !

Je m'inscris !

Sécuriser par la conception : compléter la transition vers des langages à mémoire sûre par des améliorations de la sécurité du code C++ existant
Selon Google

Le , par Jade Emy

30PARTAGES

6  0 
Google vient de publier le livre blanc "Secure by Design: Google’s Perspective on Memory Safety". Google envisage une transition progressive vers des langages à sécurité mémoire tels que Java, Go et Rust pour le nouveau code et les composants particulièrement à risque. Mais Google reconnait aussi qu'il faut compléter cette transition par des améliorations de la sécurité du code C++ existant, voici son point de vue sur la sécurité de la mémoire :



Résumé

L'année 2022 a marqué le 50e anniversaire des vulnérabilités liées à la sécurité de la mémoire, signalées pour la première fois par Anderson. Un demi-siècle plus tard, nous sommes toujours confrontés à des bogues de sécurité de la mémoire malgré des investissements substantiels pour améliorer les langages non sécurisés.

Comme d'autres, les données et les recherches internes de Google sur les vulnérabilités montrent que les bogues de sécurité de la mémoire sont très répandus et constituent l'une des principales causes de vulnérabilités dans les bases de code non sûres pour la mémoire. Ces vulnérabilités mettent en danger les utilisateurs finaux, notre industrie et la société dans son ensemble.

Chez Google, nous avons des dizaines d'années d'expérience dans la résolution, à grande échelle, de grandes catégories de vulnérabilités qui étaient autrefois aussi répandues que les problèmes de sécurité de la mémoire. Sur la base de cette expérience, nous pensons qu'une sécurité élevée de la mémoire ne peut être obtenue qu'au moyen d'une approche "Secure-by-Design" centrée sur l'adoption globale de langages offrant des garanties rigoureuses en matière de sécurité de la mémoire. Par conséquent, nous envisageons une transition progressive vers des langages à sécurité mémoire.

Au cours des dernières décennies, Google a développé et accumulé des centaines de millions de lignes de code C++ qui sont utilisées activement et font l'objet d'un développement actif et continu. Cette très grande base de code existante pose des problèmes importants pour la transition vers la sécurité de la mémoire :

  • D'une part, nous ne voyons pas de voie réaliste pour une évolution du C++ vers un langage offrant des garanties rigoureuses de sécurité de la mémoire, y compris la sécurité temporelle.
  • D'autre part, une réécriture à grande échelle du code C++ existant dans un langage différent et sûr pour la mémoire semble très difficile et restera probablement irréalisable.


Cela signifie que nous allons probablement exploiter une base de code C++ très importante pendant un certain temps. Nous considérons donc qu'il est important de compléter la transition vers des langages à mémoire sécurisée pour le nouveau code et les composants particulièrement à risque par des améliorations de la sécurité du code C++ existant, dans la mesure du possible. Nous pensons que des améliorations substantielles peuvent être obtenues par une transition progressive vers un sous-ensemble de langage C++ partiellement sûr pour la mémoire, complété par des fonctions de sécurité matérielle lorsqu'elles sont disponibles.

Définition des bogues de sécurité de la mémoire

Les bogues de sécurité de la mémoire surviennent lorsqu'un programme permet l'exécution d'instructions qui lisent ou écrivent dans la mémoire, alors que le programme est dans un état où l'accès à la mémoire constitue un comportement non défini. Lorsqu'une telle instruction est accessible dans un état de programme sous contrôle adverse (par exemple, traitement d'entrées non fiables), le bogue représente souvent une vulnérabilité exploitable (dans le pire des cas, permettant l'exécution d'un code arbitraire).

Définition d'une sécurité mémoire rigoureuse

Dans ce contexte, nous considérons qu'un langage est rigoureusement sûr en mémoire s'il :

  • utilise par défaut un sous-ensemble sûr bien délimité, et
  • garantit qu'un code arbitraire écrit dans le sous-ensemble sûr est empêché de provoquer une violation de la sécurité spatiale, temporelle, de type ou d'initialisation.


Ceci peut être établi par toute combinaison de restrictions à la compilation et de protections à l'exécution, à condition que les mécanismes d'exécution garantissent que la violation de la sécurité ne peut pas se produire.

À quelques exceptions près, bien définies, tout le code devrait pouvoir être écrit dans le sous-ensemble sûr bien délimité.

Dans les nouveaux développements, le code potentiellement dangereux ne devrait se trouver que dans les composants/modules qui optent explicitement pour l'utilisation de constructions non sûres en dehors du sous-ensemble de langage sûr, et qui exposent une abstraction sûre dont la solidité a été examinée par des experts. Les constructions non sûres ne doivent être utilisées qu'en cas de nécessité, par exemple pour des raisons de performances critiques ou dans le code qui interagit avec des composants de bas niveau.

Lorsque l'on travaille avec du code existant dans un langage non sûr pour la mémoire, le code non sûr doit être limité aux utilisations suivantes :

  • le code écrit dans un langage sûr qui fait des appels à une bibliothèque mise en œuvre par une ancienne base de code écrite dans un langage non sûr.
  • l'ajouts/modifications de code à des bases de code existantes non sûres, où le code est trop profondément entremêlé pour que le développement dans un langage sûr soit pratique.


Impact des vulnérabilités liées à la sécurité de la mémoire

Les bogues de sécurité de la mémoire sont responsables de la majorité (~70%) des vulnérabilités graves dans les grandes bases de code C/C++. Voici le pourcentage de vulnérabilités dues à l'insécurité de la mémoire :

  • Chrome : 70% des vulnérabilités élevées/critiques
  • Android : 70% des vulnérabilités élevées/critiques
  • Serveurs Google : 16 à 29 % des vulnérabilités
  • Projet Zéro : 68 % des journées zéro dans la nature
  • Microsoft : 70 % des vulnérabilités avec CVE


Les erreurs de sécurité de la mémoire continuent d'apparaître en tête des listes des "bogues les plus dangereux", telles que le Top 25 et le Top 10 des vulnérabilités exploitées connues du CWE. Les recherches internes de Google sur les vulnérabilités démontrent à plusieurs reprises que l'absence de sécurité de la mémoire affaiblit d'importantes limites de sécurité.

Comprendre les bogues de sécurité de la mémoire

Catégories de bogues de sécurité de la mémoire

Il peut être utile de distinguer un certain nombre de sous-classes de bogues de sécurité de la mémoire qui diffèrent par leurs solutions possibles et leur impact sur les performances et l'expérience des développeurs :

  • Les bogues de sécurité spatiale (par exemple, "dépassement de tampon", "accès hors limites") surviennent lorsqu'un accès à la mémoire se réfère à une mémoire située en dehors de la région allouée à l'objet accédé.
  • Les bogues de sécurité temporelle surviennent lorsqu'un accès à la mémoire d'un objet se produit en dehors de la durée de vie de l'objet. C'est le cas, par exemple, lorsqu'une fonction renvoie un pointeur sur une valeur de sa pile ("use-after-return"), ou en raison d'un pointeur sur une mémoire allouée au tas qui a depuis été libérée, et éventuellement réallouée pour un autre objet ("use-after-free").

    Dans les programmes concurrents, il est courant que ces bogues se produisent en raison d'une mauvaise synchronisation des threads, mais lorsque la violation initiale de la sécurité se situe en dehors de la durée de vie de l'objet, nous la classons comme une violation de la sécurité temporelle.
  • Les bogues de sécurité de type surviennent lorsqu'une valeur d'un type donné est lue à partir d'une mémoire qui ne contient pas de membre de ce type. C'est le cas, par exemple, lorsque la mémoire est lue après un cast de pointeur invalide.
  • Les bogues de sécurité d'initialisation surviennent lorsque la mémoire est lue avant d'être initialisée. Cela peut conduire à des divulgations d'informations et à des bogues de sécurité de type/temporels.
  • Les bogues de sécurité liés à la course aux données résultent de lectures et d'écritures non synchronisées par différents threads, qui peuvent accéder à un objet dans un état incohérent. D'autres formes de bogues de sécurité peuvent également résulter d'une synchronisation incorrecte ou manquante, mais nous ne les classons pas dans la catégorie des bogues de sécurité liés à l'espace de données et ils sont traités ci-dessus. Ce n'est que lorsque les lectures et les écritures sont par ailleurs correctes, à l'exception du fait qu'elles ne sont pas synchronisées, qu'elles sont considérées comme des bogues de sécurité liés à l'évolution des données.

    Une fois qu'une violation de la sécurité de l'espace de données s'est produite, l'exécution ultérieure peut provoquer d'autres bogues de sécurité. Nous les classons dans la catégorie des bogues de sécurité liés à la course aux données, car la violation initiale est strictement un problème lié à la course aux données, sans qu'aucun autre bogue ne soit évident.


La classification utilisée ici correspond à peu près à la taxonomie de sécurité de la mémoire d'Apple.

Dans les langages non sécurisés tels que C/C++, il incombe au programmeur de s'assurer que les conditions préalables de sécurité sont remplies afin d'éviter d'accéder à une mémoire non valide. Par exemple, pour la sécurité spatiale, lors de l'accès aux éléments d'un tableau via un index (par exemple, a[i] = x), il incombe au programmeur de s'assurer que l'index se trouve dans les limites de la mémoire allouée de manière valide.

Nous excluons actuellement la sécurité de la trace des données de la prise en compte de la sécurité rigoureuse de la mémoire pour les raisons suivantes :

  • La sécurité de la course aux données est une classe de bogues à part entière, qui ne recouvre que partiellement la sécurité de la mémoire. Par exemple, Java ne fournit pas de garanties en matière de sécurité des courses de données, mais les courses de données en Java ne peuvent pas entraîner la violation des invariants d'intégrité du tas de données de bas niveau (corruption de la mémoire).
  • À l'heure actuelle, nous ne disposons pas du même niveau de preuve concernant l'insécurité des courses de données, qui entraîne des problèmes systémiques de sécurité et de fiabilité pour les logiciels écrits dans des langages par ailleurs rigoureusement sûrs pour la mémoire (par exemple, Go).


Pourquoi les bogues de sécurité de la mémoire sont-ils si difficiles à résoudre ?

Les bogues de sécurité de la mémoire sont assez courants dans les grandes bases de code C++. L'intuition qui sous-tend la prévalence des bogues de sécurité de la mémoire est la suivante :

Premièrement, dans les langages non sûrs, les programmeurs doivent s'assurer que la condition préalable de sécurité de la mémoire de chaque instruction est remplie juste avant son exécution, dans n'importe quel état du programme qui pourrait être atteint, potentiellement sous l'influence d'entrées adverses dans le programme.

Deuxièmement, les instructions non sûres qui peuvent entraîner des bogues de sécurité de la mémoire sont très courantes dans les programmes C/C++ - il y a beaucoup d'accès aux tableaux, de déréférencements de pointeurs et d'allocations du tas.

Enfin, il est difficile, même avec l'aide d'un outil, de raisonner sur les conditions préalables de sécurité et de déterminer si le programme les garantit dans tous les états possibles du programme. Par exemple :

  • Raisonner sur l'intégrité d'un pointeur/index implique d'envelopper l'arithmétique des entiers, ce qui est tout à fait non intuitif pour les humains.
  • Le raisonnement sur la durée de vie des objets du tas implique souvent des invariants complexes et subtils pour l'ensemble du programme. Même le cadrage local et la durée de vie peuvent être subtils et surprenants.


"De nombreux bogues potentiels" combinés à un "raisonnement difficile sur les conditions préalables de sécurité" et au fait que "les humains font des erreurs" se traduisent par un nombre relativement important de bogues réels.

Les tentatives d'atténuation du risque de vulnérabilité de la mémoire par l'éducation des développeurs et des approches réactives (y compris l'analyse statique/dynamique pour trouver et corriger les bogues, et diverses mesures d'atténuation des exploits) n'ont pas réussi à réduire l'incidence de ces bogues à un niveau tolérable. En conséquence, des vulnérabilités graves continuent d'être causées par cette catégorie de failles, comme nous l'avons vu plus haut.

S'attaquer aux bogues de sécurité de la mémoire

S'attaquer à la sécurité de la mémoire nécessite une approche sur plusieurs fronts :

  • Prévenir les bogues de sécurité de la mémoire par un codage sûr.
  • Atténuer les bogues de sécurité de la mémoire en rendant leur exploitation plus coûteuse.
  • Détecter les bogues de sécurité de la mémoire, le plus tôt possible dans le cycle de développement.


Nous pensons que ces trois éléments sont nécessaires pour résoudre le problème de la sécurité de la mémoire à l'échelle de Google. D'après notre expérience, il est nécessaire de mettre l'accent sur la prévention par le biais d'un codage sûr pour atteindre durablement un niveau d'assurance élevé.

Prévention des bogues de sécurité de la mémoire par un codage sûr

Notre expérience chez Google montre qu'il est possible d'éliminer des classes de problèmes à grande échelle en supprimant l'utilisation de constructions de codage sujettes à la vulnérabilité. Dans ce contexte, nous considérons qu'une construction n'est pas sûre si elle peut potentiellement manifester un bogue (par exemple, une corruption de la mémoire) à moins qu'une condition préalable de sécurité ne soit satisfaite au moment de son utilisation. Les constructions non sûres mettent à la charge du développeur la responsabilité de garantir la condition préalable. Notre approche, que nous appelons "Safe Coding", traite les constructions de codage non sûres elles-mêmes comme des dangers (c'est-à-dire indépendamment et en plus de la vulnérabilité qu'ils peuvent causer), et est centrée sur l'assurance que les développeurs ne rencontrent pas de tels dangers lors de la pratique régulière du codage.

En substance, le codage sûr demande que les constructions dangereuses soient interdites par défaut et que leur utilisation soit remplacée par des abstractions sûres dans la plupart des codes, avec des exceptions soigneusement examinées. Dans le domaine de la sécurité de la mémoire, les abstractions sûres peuvent être fournies en utilisant :

  • Des invariants de sécurité garantis statiquement ou dynamiquement, empêchant l'introduction de bogues. Des vérifications au moment de la compilation et des mécanismes émis par le compilateur ou fournis au moment de l'exécution garantissent que des classes particulières de bogues ne peuvent pas se produire. Par exemple, au moment de la compilation, l'analyse de la durée de vie empêche les bogues de se produire :

    • Au moment de la compilation, l'analyse de la durée de vie empêche un sous-ensemble de bogues de sécurité temporelle.
    • Au moment de l'exécution, l'initialisation automatisée des objets garantit l'absence de lectures non initialisées.
  • Détection d'erreurs au moment de l'exécution, application des invariants de sécurité de la mémoire en levant une erreur lorsqu'une violation de la sécurité de la mémoire est détectée au lieu de poursuivre l'exécution avec une mémoire corrompue. Les bogues sous-jacents existent toujours et devront être corrigés, mais les vulnérabilités sont éliminées (à l'exception des attaques par déni de service). Par exemple :

    • Une recherche dans un tableau peut offrir une détection d'erreur de sécurité spatiale en vérifiant que l'index donné est à l'intérieur des limites. Les vérifications peuvent être supprimées lorsque la sécurité est prouvée de manière statique.
    • Un cast de type peut offrir une détection d'erreur de sécurité de type en vérifiant que l'objet casté est une instance du type résultant (par exemple, ClassCastException en Java ou CastGuard pour C++).



Dans le domaine de la sécurité de la mémoire, l'approche du codage sûr est incarnée par des langages sûrs, qui remplacent les constructions non sûres par des abstractions sûres telles que les vérifications des limites d'exécution, les références collectées par le garbage, ou les références ornées d'annotations de durée de vie vérifiées de manière statique.

L'expérience montre que les problèmes de sécurité de la mémoire sont en effet rares dans les langages sûrs à ramasse-miettes tels que Go et Java. Cependant, le ramassage des ordures s'accompagne généralement d'un surcoût d'exécution important. Plus récemment, Rust est apparu comme un langage qui incarne l'approche du codage sûr basée principalement sur la discipline de type vérifiée à la compilation, ce qui entraîne des surcoûts d'exécution minimes.

Les données montrent que le codage sécurisé fonctionne pour la sécurité de la mémoire, même dans les environnements sensibles aux performances. Par exemple, Android 13 a introduit 1,5 million de lignes de Rust sans aucune vulnérabilité en matière de sécurité de la mémoire. Cela a permis d'éviter des centaines de vulnérabilités en matière de sécurité de la mémoire : "La quantité de nouveau code dangereux pour la mémoire entrant dans Android a diminué, tout comme le nombre de vulnérabilités en matière de sécurité de la mémoire. [...] 2022 a été la première année où les vulnérabilités liées à la sécurité de la mémoire n'ont pas représenté la majorité des vulnérabilités d'Android. Bien que la corrélation ne signifie pas nécessairement la causalité, [...] le changement est un écart majeur par rapport aux tendances de l'ensemble de l'industrie énumérées ci-dessus qui ont persisté pendant plus d'une décennie".

Autre exemple, Cloudflare indique que son proxy HTTP Rust est plus performant que NGINX et qu'il a "servi quelques centaines de milliers de milliards de requêtes et [n'a] encore jamais connu de panne due à notre code de service".

En appliquant un sous-ensemble de mécanismes préventifs de sécurité de la mémoire à un langage non sûr tel que le C++, nous pouvons partiellement prévenir des classes de problèmes de sécurité de la mémoire. Par exemple :

  • Un RFC de renforcement des tampons peut éliminer un sous-ensemble de problèmes de sécurité spatiale en C++.
  • De même, un RFC sur la sécurité des limites peut éliminer un sous-ensemble de problèmes de sécurité spatiale en C.
  • Les annotations de durée de vie en C++ peuvent éliminer un sous-ensemble de problèmes de sécurité temporelle.


Atténuation des risques d'exploitation

Les mesures d'atténuation compliquent l'exploitation des vulnérabilités de la sécurité de la mémoire, plutôt que de corriger la cause première de ces vulnérabilités. Par exemple, les mesures d'atténuation comprennent la mise en bac à sable des bibliothèques non sûres, l'intégrité du flux de contrôle et la prévention de l'exécution des données.

Alors que les abstractions sûres empêchent la corruption de la mémoire, refusant aux attaquants les primitives d'exploitation, les mesures d'atténuation des exploits supposent que la mémoire peut être corrompue. Les mesures d'atténuation des risques visent à empêcher les attaquants de passer de certaines primitives d'exploitation à l'exécution de code sans restriction.

Les attaquants contournent régulièrement ces mesures d'atténuation, ce qui soulève la question de leur valeur en termes de sécurité. Pour être utiles, les mesures d'atténuation devraient obliger les attaquants à enchaîner des vulnérabilités supplémentaires ou à inventer une nouvelle technique de contournement. Au fil du temps, les techniques de contournement deviennent plus précieuses pour les attaquants que n'importe quelle vulnérabilité isolée. L'avantage pour la sécurité d'une
d'une atténuation bien conçue réside dans le fait que les techniques de contournement devraient être beaucoup plus rares que les vulnérabilités.

Les mesures d'atténuation des exploits sont rarement gratuites ; elles ont tendance à entraîner des frais généraux d'exécution qui ne représentent généralement qu'un faible pourcentage à un chiffre.
Elles offrent un compromis entre sécurité et performance, que nous pouvons ajuster en fonction des besoins de chaque charge de travail. Les frais généraux d'exécution peuvent être réduits en construisant des mesures d'atténuation directement dans le silicium, comme cela a été fait pour l'authentification des pointeurs, la pile d'appels fictifs, les plateformes d'atterrissage et les clés de protection. En raison des frais généraux et des coûts d'opportunité des caractéristiques matérielles, les considérations relatives à l'adoption de ces techniques et à l'investissement dans celles-ci sont nuancées.

D'après notre expérience, le sandboxing est un moyen efficace d'atténuer les vulnérabilités liées à la sécurité de la mémoire et il est couramment utilisé chez Google pour isoler les bibliothèques fragiles ayant un historique de vulnérabilités. Toutefois, l'adoption du sandboxing se heurte à plusieurs obstacles :

  • Le sandboxing peut entraîner des frais généraux importants en termes de latence et de bande passante, ainsi que des coûts liés au remaniement du code. Cela nécessite parfois la réutilisation des instances de sandbox entre les requêtes, ce qui affaiblit les mesures d'atténuation.
  • La création d'une politique de sandbox suffisamment restrictive pour constituer une mesure d'atténuation efficace peut s'avérer difficile pour les développeurs, en particulier lorsque les politiques de sandbox sont exprimées à un faible niveau d'abstraction, comme les filtres d'appels système.
  • Le sandboxing peut entraîner des risques de fiabilité, lorsque des chemins de code inhabituels (mais bénins) sont exercés en production et déclenchent des violations de la politique de sandboxing.


Dans l'ensemble, l'atténuation des exploits est un outil essentiel pour améliorer la sécurité d'une vaste base de code C++ préexistante et profitera également à l'utilisation résiduelle de constructions non sûres dans des langages à mémoire sûre.

Détection des bogues liés à la sécurité de la mémoire

L'analyse statique et le fuzzing sont des outils efficaces pour détecter les bogues de sécurité de la mémoire. Ils réduisent le volume de bogues de sécurité de la mémoire dans notre base de code au fur et à mesure que les développeurs corrigent les problèmes détectés.

Cependant, d'après notre expérience, la recherche de bogues ne permet pas à elle seule d'atteindre un niveau d'assurance acceptable pour les langages à mémoire non sécurisée. À titre d'exemple, le récent zero-day de haute sévérité de webp (CVE-2023-4863) a affecté un code largement exploré. La vulnérabilité n'a pas été détectée malgré une couverture élevée (97,55 % dans le fichier concerné). Dans la pratique, de nombreux bogues liés à la sécurité de la mémoire ne sont pas détectés, comme le montre le flux constant de vulnérabilités liées à la sécurité de la mémoire dans un code bien testé qui n'est pas sûr pour la mémoire.

En outre, le fait de trouver des bogues n'améliore pas en soi la sécurité. Les bogues doivent être corrigés et les correctifs déployés. Certains éléments suggèrent que les capacités de détection des bogues dépassent les capacités de correction des bogues. Par exemple, syzkaller, notre fuzzer de noyau, a trouvé plus de 5 000 bogues dans le noyau Linux en amont, de sorte qu'à tout moment, il y a des centaines de bogues ouverts (dont une grande partie est probablement liée à la sécurité), un nombre qui augmente régulièrement depuis 2017.

Nous pensons néanmoins que la recherche de bogues est une partie essentielle de la lutte contre l'insécurité de la mémoire. Les techniques de recherche de bogues qui exercent une pression moindre sur la capacité de correction des bogues sont particulièrement précieuses :

  • " Shifting-left ", comme le fuzzing en presubmit, réduit le taux de nouveaux bugs expédiés en production. Les bogues détectés plus tôt dans le cycle de développement des logiciels (SDLC) sont moins coûteux à corriger, ce qui augmente notre capacité de correction des bogues.
  • Les techniques de recherche de bogues, comme l'analyse statique, peuvent également suggérer des corrections, qui peuvent être fournies par l'intermédiaire de l'IDE ou de demandes d'extraction, ou appliquées automatiquement pour modifier le code existant de manière proactive.
  • Les outils de recherche de bogues tels que les assainisseurs, qui identifient les causes profondes et génèrent des rapports de bogues exploitables, aident les développeurs à résoudre les problèmes plus rapidement, ce qui augmente également notre capacité de correction des bogues.


En outre, les outils de recherche de bogues trouvent des classes de bogues au-delà de la sécurité de la mémoire, ce qui élargit l'impact de l'investissement dans ces outils. Ils peuvent trouver des problèmes de fiabilité, de correction et d'autres problèmes de sécurité, par exemple :

  • Le fuzzing basé sur les propriétés détecte les entrées qui violent les invariants au niveau de l'application, tels que les propriétés de correction encodées par les développeurs. Par exemple, cryptofuzz a trouvé plus de 150 bogues dans les bibliothèques de cryptographie.
  • Le fuzzing détecte les bogues liés à l'utilisation des ressources (par exemple, les récursions infinies) et les pannes simples qui affectent la disponibilité. En particulier, la détection des erreurs d'exécution (par exemple, la vérification des limites) transforme les vulnérabilités de sécurité de la mémoire en erreurs d'exécution, qui restent un problème de fiabilité et de déni de service.
  • Les progrès dans la détection des vulnérabilités au-delà de la sécurité de la mémoire sont prometteurs.


Plongée en profondeur : Safe Coding appliqué à la sécurité de la mémoire

Google a développé Safe Coding, une approche évolutive visant à réduire considérablement l'incidence des classes communes de vulnérabilités et à obtenir un degré élevé d'assurance quant à l'absence de vulnérabilités.

Au cours de la dernière décennie, nous avons appliqué cette approche avec beaucoup de succès à l'échelle de Google, principalement pour ce que l'on appelle les vulnérabilités d'injection, y compris l'injection SQL et XSS. Bien qu'à un niveau technique très différent des bogues de sécurité de la mémoire, il existe des parallèles pertinents :

  • Comme les bogues de sécurité de la mémoire, les bogues d'injection se produisent lorsqu'un développeur utilise une construction de code potentiellement dangereuse et ne parvient pas à garantir sa condition préalable de sécurité.
  • Le respect de la condition préalable dépend d'un raisonnement complexe sur les invariants de flux de données de l'ensemble du programme ou de l'ensemble du système. Par exemple, la construction potentiellement dangereuse se produit dans le code côté navigateur, mais les données peuvent arriver via plusieurs microservices et un magasin de données côté serveur. Il est donc difficile de savoir d'où viennent réellement les données et si la validation nécessaire a été correctement appliquée en cours de route.
  • Les constructions potentiellement dangereuses sont courantes dans les bases de code typiques.


Comme pour les bogues liés à la sécurité de la mémoire, "plusieurs milliers de bogues potentiels" ont conduit à des centaines de bogues réels. Les approches réactives (examen du code, tests en stylo, fuzzing) ont été largement infructueuses.

Pour résoudre ce problème à grande échelle et avec une grande assurance, Google a appliqué le Safe Coding au domaine des vulnérabilités par injection. Cette démarche a été couronnée de succès et a permis de réduire considérablement, voire d'éliminer complètement, les vulnérabilités XSS. Par exemple, avant 2012, les frontaux web comme GMail avaient souvent quelques douzaines de XSS par an ; après avoir remanié le code pour se conformer aux exigences du Safe Coding, les taux de défauts sont tombés à près de zéro. L'interface web de Google Photos (qui a été développée depuis le début sur un cadre d'application web qui applique complètement le Safe Coding) n'a eu aucune vulnérabilité XSS signalée dans toute son histoire. Dans les sections suivantes, nous examinons plus en détail comment l'approche Safe Coding s'applique à la sécurité de la mémoire, et nous établissons des parallèles avec son utilisation réussie pour éliminer des classes de vulnérabilités dans le domaine de la sécurité du web.

Des abstractions sûres

D'après notre expérience, la clé de l'élimination des classes de bogues consiste à identifier les constructions de programmation (API ou constructions natives du langage) qui causent ces bogues, puis à éliminer l'utilisation de ces constructions dans la pratique courante de la programmation. Cela nécessite l'introduction de constructions sûres avec des fonctionnalités équivalentes, qui prennent souvent la forme d'abstractions sûres autour des constructions dangereuses sous-jacentes.

Par exemple, les XSS sont causés par l'utilisation d'API de plates-formes Web qui ne sont pas sûres à appeler avec des chaînes de caractères partiellement contrôlées par l'attaquant. Pour éliminer l'utilisation de ces API sujettes aux XSS dans notre code, nous avons introduit un certain nombre d'abstractions sûres équivalentes, conçues pour garantir collectivement que les conditions préalables de sécurité sont remplies lorsque les constructions non sûres sous-jacentes (API) sont invoquées. Il s'agit notamment d'enveloppes d'API sûres, de types de vocabulaire avec des contrats de sécurité et de systèmes de création de modèles HTML sûrs.

Les abstractions sûres visant à garantir les conditions préalables de sécurité de la mémoire peuvent prendre la forme d'API enveloppantes dans un langage existant (par exemple, les pointeurs intelligents à utiliser à la place des pointeurs bruts, y compris MiraclePtr qui protège 50 % des problèmes d'utilisation après libération contre l'exploitation dans le processus du navigateur de Chrome), ou de constructions étroitement liées à la sémantique du langage (par exemple, le ramassage des miettes dans Go/Java ; les durées de vie vérifiées statiquement dans Rust).

La conception de constructions sûres doit tenir compte d'un compromis à trois voies entre les coûts d'exécution (CPU, mémoire, taille binaire, etc.), les coûts de développement (friction du développeur, charge cognitive, temps de construction) et l'expressivité. Par exemple, le ramassage des miettes fournit une solution générale pour la sécurité temporelle, mais peut entraîner une variabilité problématique des performances. Les durées de vie de Rust combinées avec le vérificateur d'emprunts assurent la sécurité entièrement à la compilation (sans coût d'exécution) pour de grandes classes de code ; cependant, le programmeur doit faire plus d'efforts en amont pour démontrer que le code est en fait sûr. Il en va de même pour le typage statique, qui nécessite un effort initial plus important que le typage dynamique, mais qui permet d'éviter un grand nombre d'erreurs de type au moment de la compilation.

Parfois, les développeurs doivent choisir d'autres idiomes pour éviter les surcharges d'exécution. Par exemple, la surcharge liée à la vérification des limites lors de la traversée indexée d'un vecteur peut être évitée en utilisant une boucle "range-for".

Pour réussir à réduire l'incidence des bogues, une collection d'abstractions sûres doit être suffisamment expressive pour permettre à la plupart des codes d'être écrits sans avoir recours à des constructions non sûres (ni à un code alambiqué et non idiomatique qui est techniquement sûr, mais difficile à comprendre et à maintenir).

Sûr par défaut, peu sûr par exception

D'après notre expérience, il ne suffit pas de mettre à la disposition des développeurs des abstractions sûres sur une base facultative (par exemple, suggérées par un guide de style), car trop de constructions non sûres, et donc trop de risques de bogues, ont tendance à subsister.
Au contraire, pour obtenir un degré élevé d'assurance qu'une base de code est exempte de vulnérabilités, nous avons jugé nécessaire d'adopter un modèle dans lequel les constructions non sûres ne sont utilisées qu'à titre exceptionnel, par l'intermédiaire du
compilateur.

Ce modèle se compose des éléments clés suivants :

  1. Il est possible de décider au moment de la construction si un programme (ou une partie d'un programme, par exemple un module) contient des constructions dangereuses.
  2. Un programme composé uniquement de code sûr est garanti de maintenir les invariants de sécurité au moment de l'exécution.
  3. Les constructions non sûres ne sont pas autorisées à moins qu'elles ne soient explicitement autorisées/optées, c'est-à-dire que le code est sûr par défaut.


Dans nos travaux sur les vulnérabilités par injection, nous avons atteint la sécurité à grande échelle en limitant l'accès aux API non sûres grâce à la visibilité au niveau du langage et au moment de la construction et, dans certains cas, grâce à des vérifications statiques personnalisées.

Dans le contexte de la sécurité de la mémoire, la sécurité à l'échelle exige que le langage interdise par défaut l'utilisation de constructions non sûres (par exemple, l'indexation non vérifiée dans les tableaux/tampons). Les constructions non sûres devraient provoquer une erreur à la compilation, à moins qu'une partie du code ne soit explicitement incluse dans le sous-ensemble non sûr, comme nous le verrons dans la section suivante. Par exemple, Rust n'autorise les constructions non sûres qu'à l'intérieur de blocs non sûrs clairement délimités.

Solidité : Code non sûr encapsulé de manière sûre

Comme indiqué ci-dessus, nous supposons que les abstractions sûres disponibles sont suffisamment expressives pour permettre à la plupart des codes d'être écrits en utilisant uniquement des constructions sûres. Dans la pratique, cependant, nous nous attendons à ce que la plupart des grands programmes nécessitent l'utilisation de constructions non sûres dans certains cas. En outre, les abstractions sûres elles-mêmes seront souvent des API enveloppantes pour les constructions non sûres sous-jacentes. Par exemple, l'implémentation d'abstractions sûres autour de l'allocation/désallocation de la mémoire du tas doit en fin de compte traiter la mémoire brute, par exemple mmap.

Lorsque les développeurs introduisent (même en petite quantité) du code non sûr, il est important de le faire sans annuler les avantages d'avoir écrit la majeure partie d'un programme en utilisant uniquement du code sûr.

À cette fin, les développeurs doivent adhérer au principe suivant : les utilisations de constructions non sûres doivent être encapsulées dans des API dont la sécurité peut être démontrée.

En d'autres termes, le code non sûr doit être encapsulé derrière une API qui est saine pour tout code arbitraire (mais bien typé) appelant cette API. Il doit être possible de démontrer, et d'examiner/vérifier, que le module expose une surface d'API sûre sans faire d'hypothèse sur le code appelant (autre que son bon typage).

Par exemple, supposons que l'implémentation d'un type utilise une construction potentiellement dangereuse. Il incombe alors à l'implémentation du type de s'assurer de manière indépendante que la précondition de la construction non sûre est respectée lorsqu'elle est invoquée. L'implémentation ne doit pas faire d'hypothèses sur le comportement de ses appelants (en dehors du fait qu'ils sont bien typés), par exemple que ses méthodes sont appelées dans un certain ordre.

Dans notre travail sur les vulnérabilités d'injection, ce principe est incorporé dans des directives pour l'utilisation de ce qu'on appelle les conversions non vérifiées (qui représentent du code non sûr dans notre discipline de type vocabulaire). Dans la communauté Rust, cette propriété est appelée Soundness : un module avec des blocs unsafe est sound si un programme composé de ce module, combiné avec un Rust arbitraire bien typé et sûr, ne peut pas présenter de comportement indéfini.

Ce principe peut être difficile ou impossible à respecter dans certaines situations, comme lorsqu'un programme dans un langage sûr (Rust ou Go) fait appel à du code C++ non sûr. La bibliothèque non sécurisée peut être enveloppée dans une abstraction "raisonnablement sûre", mais il n'existe aucun moyen pratique de démontrer que l'implémentation est réellement sûre et ne présente pas de bogue de sécurité de la mémoire.

Examen du code non sécurisé par des experts

Le raisonnement sur le code non sécurisé est difficile et peut être source d'erreurs, en particulier pour les non-experts :

  • Raisonner sur la question de savoir si un module contenant des constructions non sûres expose en fait une abstraction sûre nécessite une expertise dans le domaine.

    • Par exemple, dans le domaine de la sécurité web, décider si une conversion non vérifiée dans le type de vocabulaire SafeHtml est sûre nécessite une compréhension détaillée de la spécification HTML et des règles d'échappement et d'assainissement des données applicables.
    • Décider si un code Rust avec unsafe est sain nécessite une compréhension profonde de la sémantique Rust unsafe et des limites du comportement non défini (un domaine de recherche actif).
  • D'après notre expérience, les développeurs qui se concentrent sur la résolution d'un problème ne semblent pas apprécier l'importance d'encapsuler en toute sécurité le code non sécurisé et n'essaient pas de concevoir une abstraction sécurisée. Un examen par des experts est nécessaire pour orienter ces développeurs vers une encapsulation sûre et pour les aider à concevoir une abstraction sûre appropriée.


Dans le domaine de la sécurité du web, nous avons jugé nécessaire de rendre obligatoire l'examen par des experts des constructions non sûres dans de nombreux cas, comme pour les nouvelles utilisations des conversions non vérifiées. Sans examen obligatoire, nous avons observé un grand nombre d'utilisations inutiles/non fondées de constructions non sûres, ce qui a dilué notre capacité à raisonner sur la sécurité à l'échelle. Les exigences en matière d'examen obligatoire doivent tenir compte de l'impact sur les développeurs et de la largeur de bande de l'équipe d'examen, et ne sont probablement appropriées que si elles sont suffisamment rares.

Sécurité de l'ensemble du programme et raisonnement compositionnel

En fin de compte, notre objectif est d'assurer une posture de sécurité adéquate pour l'ensemble d'un binaire.

Les binaires incluent généralement un grand nombre de dépendances directes et transitives de bibliothèques. Celles-ci sont généralement maintenues par de nombreuses équipes différentes au sein de Google, ou même à l'extérieur dans le cas de codes tiers. Or, un bogue de sécurité de la mémoire dans l'une des dépendances peut potentiellement entraîner une vulnérabilité de la sécurité du binaire dépendant.

Un langage sûr, associé à une discipline de développement garantissant que le code dangereux est encapsulé dans des abstractions saines et sûres, peut nous permettre de raisonner de manière évolutive sur la sécurité de grands programmes :

  • Les composants écrits uniquement dans le sous-ensemble sûr du langage sont par construction sûrs et exempts de violations de la sécurité.
  • Les composants qui contiennent des constructions dangereuses exposent des abstractions sûres au reste du programme. Pour ces composants, l'examen par des experts fournit une assurance solide de leur solidité et du fait qu'ils ne causeront pas de violations de la sécurité lorsqu'ils seront combinés avec d'autres composants arbitraires.


Lorsque toutes les dépendances transitives appartiennent à l'une de ces deux catégories, nous avons l'assurance que l'ensemble du programme est exempt de violations de la sécurité. Il est important de noter que nous n'avons pas besoin de raisonner sur la façon dont chaque composant interagit avec tous les autres composants du programme ; au contraire, nous pouvons arriver à cette conclusion en nous basant uniquement sur le raisonnement concernant chaque composant de façon isolée.

Pour maintenir et garantir les assertions sur la sécurité de l'ensemble du programme dans le temps, en particulier pour les binaires critiques en matière de sécurité, nous avons besoin de mécanismes pour garantir les contraintes sur le "niveau de solidité" de toutes les dépendances transitives d'un binaire (c'est-à-dire pour savoir si elles consistent uniquement en du code sûr ou si elles ont fait l'objet d'une révision par des experts pour ce qui est de la solidité).

Dans la pratique, certaines dépendances transitives auront un niveau d'assurance plus faible quant à leur solidité. Par exemple, une dépendance OSS d'un tiers peut utiliser des constructions non sûres, mais n'est pas structurée de manière à exposer des abstractions sûres proprement délimitées dont la solidité peut être effectivement vérifiée. Ou encore, une dépendance peut consister en un wrapper FFI dans un code hérité écrit entièrement dans un langage non sûr, ce qui rend impossible une vérification de la solidité avec un degré d'assurance élevé.

Les binaires critiques en matière de sécurité peuvent vouloir exprimer des contraintes telles que "toutes les dépendances transitives sont soit exemptes de constructions non sûres ou sont vérifiées par des experts, avec les exceptions spécifiques suivantes", les exceptions pouvant faire l'objet d'un examen plus approfondi (par exemple, une couverture fuzz étendue). Cela permet aux propriétaires d'un binaire critique de maintenir un niveau bien compris et acceptable de risque résiduel d'insécurité.

Garanties de sécurité de la mémoire et compromis

L'application des principes de codage sûr à la sécurité de la mémoire d'un langage de programmation et de son écosystème (bibliothèques, outils d'analyse de programmes) implique des compromis, principalement entre les coûts encourus au moment du développement (par exemple, la charge cognitive imposée aux développeurs) et au moment du déploiement et de l'exécution.

La présente section donne un aperçu des approches possibles des sous-classes de sécurité de la mémoire et des compromis qui y sont associés.

Sécurité spatiale

La sécurité spatiale est relativement simple à intégrer dans l'écosystème d'un langage et d'une bibliothèque. Le compilateur et les types de conteneurs tels que les chaînes et les vecteurs doivent s'assurer que tous les accès sont vérifiés pour être dans les limites. Les vérifications peuvent être supprimées si elles s'avèrent inutiles sur la base d'une analyse statique ou d'invariants de type. En règle générale, cela signifie que les implémentations de types ont besoin de métadonnées (taille/longueur) pour effectuer les vérifications.

Les approches comprennent :

  • Les contrôles de limites incorporés dans les API (par exemple, std::vector::operator[] avec des assertions de sécurité).
  • Les contrôles de limites insérés par le compilateur, éventuellement aidés par des annotations.
  • Support matériel tel que les capacités CHERI avec vérification des limites.


Les langages sûrs tels que Rust, Go, Java, etc., et leurs bibliothèques standard, imposent des contrôles de limites pour tous les accès indexés. Ils ne sont supprimés que s'il est prouvé qu'ils sont redondants.

Il semble plausible, mais cela n'a pas été démontré pour les bases de code à grande échelle comme la monorepo de Google ou le noyau Linux, qu'un langage non sûr comme le C ou le C++ puisse être sous-ensemble pour atteindre la sécurité spatiale.

Les vérifications de limites entraînent un surcoût d'exécution faible mais inévitable. Il appartient au développeur de structurer le code de telle sorte que les vérifications de limites puissent être évitées lorsqu'elles s'accumuleraient autrement pour atteindre un surcoût significatif.

Sécurité des types et de l'initialisation

Rendre un langage sûr du point de vue du type et de l'initialisation peut inclure les éléments suivants :

  • Interdire les constructions de code non sûres du point de vue du type, telles que les unions (non marquées) et reinterpret_cast.
  • L'instrumentation du compilateur qui initialise les valeurs sur la pile (sauf si le compilateur peut prouver que la valeur ne sera pas lue avant une écriture explicite ultérieure).
  • Les implémentations de types de conteneurs qui garantissent que les éléments (accessibles) sont initialisés.


Dans les langages à typage statique, la sécurité des types peut être assurée principalement au moment de la compilation, sans surcharge au moment de l'exécution. Dans les langages à typage statique, la sécurité des types peut être assurée principalement à la compilation, sans surcharge à l'exécution. Cependant, il existe un potentiel de surcharge à l'exécution dans certains scénarios :

  • Les unions doivent inclure un discriminateur au moment de l'exécution et être représentées comme une construction de niveau supérieur sûre (par exemple, les types de somme). Dans certains cas, la surcharge de mémoire qui en résulte peut être optimisée, par exemple Option<NonZeroUsize> en Rust.
  • Il peut y avoir des initialisations superflues de valeurs qui ne sont jamais lues, mais d'une manière que le compilateur ne peut pas prouver. Dans les cas où la surcharge est significative (par exemple, l'initialisation par défaut de grands vecteurs), il est de la responsabilité du programmeur de structurer le code de telle sorte que les initialisations superflues puissent être évitées, par exemple par l'utilisation de reserve and push ou de types optionnels.


Sécurité temporelle

La sécurité temporelle est fondamentalement un problème beaucoup plus difficile que la sécurité spatiale : Pour la sécurité spatiale, il est possible d'instrumenter un programme de manière relativement peu coûteuse de sorte que la précondition de sécurité puisse être vérifiée par un contrôle d'exécution peu coûteux (contrôle des limites). Dans les cas les plus courants, il est facile de structurer le code de manière à ce que les vérifications des limites puissent être évitées (par exemple, en utilisant des itérateurs).

En revanche, il n'existe pas de moyen direct d'établir la condition préalable de sécurité pour la sécurité temporelle des objets alloués au tas.

Les pointeurs et les allocations vers lesquelles ils pointent, qui à leur tour peuvent contenir des pointeurs, induisent un graphe dirigé (éventuellement cyclique). Le graphe induit par la séquence d'allocations et de désallocations d'un programme arbitraire peut devenir arbitrairement complexe. Dans le cas général, il est impossible de déduire les propriétés de ce graphe à partir d'une analyse statique du code du programme.

Lorsqu'une allocation est libérée, tout ce qui est disponible est le nœud du graphe correspondant à cette allocation. Il n'existe aucun moyen efficace a priori (en temps constant) de déterminer s'il existe encore une autre arête entrante (c'est-à-dire un autre pointeur encore accessible dans cette allocation). La désallocation d'une allocation pour laquelle il existe encore des pointeurs entrants invalide implicitement ces pointeurs (les transforme en pointeurs "pendants"). Un déréférencement futur d'un tel pointeur invalide entraînerait un comportement indéfini et un bogue de type "use after free".

Le graphe étant orienté, il n'existe pas non plus de moyen efficace (à temps constant, ou même linéaire dans le nombre de pointeurs liés) de trouver tous les pointeurs encore accessibles dans l'allocation sur le point d'être supprimée. S'il était disponible, ce moyen pourrait être utilisé pour invalider explicitement ces pointeurs ou pour différer la désallocation jusqu'à ce que tous les pointeurs entrants soient supprimés du graphe.

Par conséquent, chaque fois qu'un pointeur est déréférencé, il n'existe aucun moyen efficace de déterminer si cette opération constitue un comportement indéfini parce que la destination du pointeur a déjà été libérée.

Il existe généralement trois façons d'obtenir des garanties rigoureuses de sécurité temporelle :

  1. Garantir, par une vérification à la compilation, qu'un pointeur/référence ne peut pas survivre à l'allocation vers laquelle il pointe. Par exemple, Rust met en œuvre cette approche par le biais du vérificateur d'emprunt et de la règle d'exclusivité. Ce mode prend en charge la sécurité temporelle des objets du tas et de la pile.
  2. Avec le support de l'exécution, s'assurer que les allocations ne sont désallouées que lorsqu'il n'y a plus de pointeurs valides vers elles.
  3. Avec le support de l'exécution, s'assurer que les pointeurs deviennent invalides lorsque l'allocation vers laquelle ils pointent est désallouée, et lever une erreur si un tel pointeur invalide est déréférencé plus tard.


Plusieurs variantes des points 2 et 3 ont été conçues et entraînent un coût d'exécution non négligeable. Le comptage de références et le ramassage des ordures offrent tous deux la sécurité souhaitée mais peuvent être coûteux. La mise en quarantaine des désallocations est une mesure d'atténuation efficace, mais elle ne garantit pas totalement la sécurité et entraîne néanmoins des frais généraux. Le marquage de la mémoire repose sur du matériel spécialisé et ne fournit qu'une atténuation probabiliste (à moins d'être combiné avec MarkUs).

Dans tous les cas, pour la sécurité temporelle, il n'y a pas de repas bon marché (et encore moins gratuit). Soit les développeurs structurent et annotent le code de manière à ce qu'un vérificateur à la compilation (par exemple, le vérificateur d'emprunts de Rust) puisse prouver statiquement la sécurité temporelle, soit nous payons le surcoût d'exécution pour atteindre la sécurité ou même atténuer partiellement ces bogues.

Malheureusement, les problèmes de sécurité temporelle restent une grande partie des problèmes de sécurité de la mémoire, comme l'indiquent divers rapports :

  • Chrome : 51% des vulnérabilités de sécurité de la mémoire élevées/critiques
  • Android : 20 % des vulnérabilités de sécurité de la mémoire élevées/critiques en 2022
  • Project Zero : 33 % des exploits de sécurité de la mémoire dans la nature.
  • Microsoft : 32% des CVE de sécurité de la mémoire [5]
  • GWP-ASan : trouve 4 fois plus d'UAF que d'OOB dans plusieurs écosystèmes.


Techniques d'exécution et compromis

Un large éventail de techniques d'instrumentation du temps d'exécution a été exploré pour traiter la sécurité temporelle, mais elles s'accompagnent toutes de compromis difficiles. Elles doivent tenir compte de la concurrence lorsqu'elles sont utilisées dans des programmes multithreads et, dans de nombreux cas, elles ne font qu'atténuer ces bogues sans garantir la sécurité.

  • Le comptage de références, soit pour fournir la durée de vie correcte, soit pour détecter et prévenir les durées de vie incorrectes. Parmi les variantes de cette technique, on peut citer std:shared_ptr, Rc/Arc de Rust, le comptage automatique des références dans Swift ou Objective-C, et l'expérience de Chrome avec DanglingPointerDetector . L'exclusivité forcée peut être utilisée avec le comptage de références pour réduire son coût, mais pas pour l'éliminer.
  • Piles de ramasse-miettes. L'exclusivité forcée peut également être combinée avec le GC pour réduire l'encombrement.
  • La mise en quarantaine des désallocations, basée sur le comptage de références et l'empoisonnement des allocations, comme le propose BackupRefPtr de Chrome, ou combinée à la traversée et à l'invalidation des pointeurs vers les désallocations mises en quarantaine, comme le propose MarkUs. Ces approches évitent d'interférer avec la synchronisation du destructeur, mais peuvent ne fournir qu'une atténuation partielle plutôt qu'une véritable sécurité temporelle dans certains cas. Elles peuvent être considérées comme des variantes du comptage de références et du ramasse-miettes qui n'interfèrent pas avec la synchronisation du destructeur tout en empêchant la réallocation derrière des pointeurs pendants, mais qui introduisent des valeurs toxiques (et le comportement indéfini qui en résulte) dans le système d'exécution si l'on y accède après qu'ils ont été libérés.
  • Le marquage de la mémoire étiquette les pointeurs et les régions de mémoire allouées avec l'une des quelques étiquettes (couleurs). Lorsque la mémoire est désallouée et réallouée, elle est recolorée selon une stratégie définie. Cela invalide implicitement les pointeurs restants qui auraient encore l'"ancienne" couleur. Dans la pratique, l'ensemble des étiquettes/couleurs est restreint (par exemple, 16 dans le cas de l'ARM MTE). Ainsi, dans la plupart des cas, il fournit une atténuation probabiliste plutôt qu'une véritable sécurité, car il y a une chance non négligeable (par exemple, 6,25 %) que les pointeurs pendants ne soient pas marqués comme invalides parce qu'ils ont été recolorés de manière aléatoire avec la même couleur. Le MTE entraîne également des frais généraux d'exécution importants. Le marquage de la mémoire accélère également les approches MarkUs et *Scan, en fournissant une sécurité temporelle forte.


Aperçu de la sécurité du langage de production

Cette section donne un bref aperçu des propriétés de sécurité de la mémoire des langages de production actuels et futurs de Google, ainsi que de certains langages qui pourraient jouer un rôle dans un avenir plus lointain.

Langages JVM (Java, Kotlin)

Dans Java et Kotlin, le code non sécurisé en mémoire est clairement délimité et limité à l'utilisation de l'interface native Java (JNI). Les bibliothèques standard du JDK s'appuient sur un grand nombre de méthodes natives pour invoquer des primitives système de bas niveau et pour utiliser des bibliothèques natives, par exemple pour l'analyse d'images. Ces dernières ont été affectées par des vulnérabilités en matière de sécurité de la mémoire (par exemple CESA-2006-004 , Sun Alert 1020226.1 ).

Java est un langage à sécurité de type. JVM garantit la sécurité spatiale grâce à des contrôles de limites d'exécution et la sécurité temporelle grâce à un tas de données collecté par le ramasse-miettes.

Java n'étend pas les principes de codage sûr à la concurrence : un programme bien typé peut avoir des courses de données. Cependant, la JVM garantit que les courses de données ne peuvent pas violer la sécurité de la mémoire. Par exemple, une course aux données peut entraîner la violation d'invariants de niveau supérieur et le déclenchement d'exceptions, mais ne peut pas entraîner de corruption de la mémoire.

Go

En Go, le code non sûr pour la mémoire est clairement délimité et confiné au code utilisant le paquetage unsafe (à l'exception du code non sûr pour la mémoire résultant des courses de données, voir ci-dessous).

Go est un langage à sécurité de type. Le compilateur Go s'assure que toutes les valeurs sont initialisées par défaut avec la valeur zéro de leur type, assure la sécurité spatiale via des vérifications de limites d'exécution, et la sécurité temporelle via un tas de données (garbage-collected heap). À l'exception du paquetage unsafe, il n'y a pas de possibilité de créer des pointeurs de manière non sécurisée.

Go n'étend pas les principes de codage sûr à la concurrence : Un programme Go bien typé peut avoir des courses aux données. De plus, les courses de données peuvent conduire à la violation des invariants de sécurité de la mémoire.

Rust

En Rust, le code non sûr pour la mémoire est clairement délimité et confiné dans des blocs non sûrs. Rust est un langage à sécurité de type. Safe Rust impose l'initialisation de toutes les valeurs et garantit la sécurité spatiale en ajoutant des contrôles de limites lorsque cela est nécessaire. Le déréférencement d'un pointeur brut n'est pas autorisé en Safe Rust.

Rust est le seul langage mature, prêt pour la production, qui assure la sécurité temporelle sans mécanismes d'exécution tels que le garbage collection ou le refcounting universellement appliqué, pour de grandes classes de code. Rust assure la sécurité temporelle grâce à des contrôles compilés sur la durée de vie des variables et des références.

Les contraintes imposées par le vérificateur d'emprunts empêchent la mise en œuvre de certaines structures, en particulier celles qui impliquent des graphes de référence cycliques. La bibliothèque standard Rust comprend des API qui permettent d'implémenter de telles structures en toute sécurité, mais avec un surcoût d'exécution (basé sur le comptage des références).

En plus de la sécurité de la mémoire, le sous-ensemble sûr de Rust garantit également la sécurité de la course des données (" Fearless Concurrency "). Incidemment, la sécurité de la course des données permet à Rust d'éviter les surcharges inutiles lors de l'utilisation de mécanismes de sécurité temporelle à l'exécution : Rc et Arc implémentent tous deux des pointeurs comptés par référence. Cependant, le type de Rc l'empêche d'être partagé entre les threads, de sorte que Rc peut utiliser en toute sécurité un compteur non atomique moins coûteux.

Carbon

Carbon est un langage expérimental qui succède à C++ et dont l'objectif explicite est de faciliter la migration à grande échelle à partir des bases de code C++ existantes. En 2023, les détails de la stratégie de sécurité de Carbon sont encore en suspens. Carbon 0.2 prévoit d'introduire un sous-ensemble sûr qui fournit des garanties rigoureuses en matière de sécurité de la mémoire. Cependant, il devra conserver une stratégie de migration efficace pour le code C++ non sécurisé existant. La gestion des mélanges de code Carbon non sûr et sûr nécessitera des garde-fous similaires à ceux utilisés pour les mélanges de C++ et d'un langage sûr comme Rust.

Bien que nous nous attendions à ce que le code Carbon nouvellement écrit soit dans son sous-ensemble memorysafe, le code Carbon issu d'une migration à partir du C++ existant s'appuiera probablement sur des constructions Carbon non sûres. Nous pensons qu'une migration ultérieure automatisée et à grande échelle de Carbon non sûr vers Carbon sûr sera difficile et souvent irréalisable. L'atténuation du risque de sécurité de la mémoire dans le code non sûr restant sera basée sur le durcissement via des modes de construction (similaires à notre traitement du code C++ hérité). Le mode de construction renforcé activera des mécanismes d'exécution qui tenteront d'empêcher l'exploitation des bogues de sécurité de la mémoire.

Un C++ plus sûr

Compte tenu du volume important de C++ préexistant, nous reconnaissons qu'une transition vers des langages à mémoire sécurisée pourrait prendre des décennies, au cours desquelles nous développerons et déploierons du code constitué d'un mélange de langages sûrs et non sûrs. Par conséquent, nous pensons qu'il est nécessaire d'améliorer la sécurité du C++ (ou du langage qui lui succédera, le cas échéant).

Bien que la définition d'un sous-ensemble de C++ rigoureusement sûr pour la mémoire qui soit suffisamment ergonomique et facile à maintenir reste une question de recherche ouverte, il pourrait en principe être possible de définir un sous-ensemble de C++ qui offre des garanties raisonnablement fortes en matière de sécurité de la mémoire. Les efforts en matière de sécurité du C++ devraient adopter une approche itérative et axée sur les données pour définir un sous-ensemble C++ plus sûr : identifier les principaux risques en matière de sécurité et de fiabilité, et déployer des garanties et des mesures d'atténuation ayant l'impact et le retour sur investissement les plus élevés.

Un tremplin pour une transition progressive

Un sous-ensemble C++ plus sûr constituerait un tremplin vers une transition vers des langages à mémoire sûre. Par exemple, l'application d'une initialisation définie ou l'interdiction de l'arithmétique des pointeurs dans un code C++ simplifiera une éventuelle migration vers Rust ou Safe Carbon. De même, l'ajout de durées de vie au C++ améliorera l'interopérabilité avec Rust. Par conséquent, en plus de cibler les principaux risques, les investissements dans la sécurité du C++ devraient donner la priorité aux améliorations qui accéléreront et simplifieront l'adoption progressive de langages sûrs pour la mémoire.

En particulier, une interopérabilité sûre, performante et ergonomique est un ingrédient clé pour une transition progressive vers la sécurité de la mémoire. Android et Apple suivent tous deux une stratégie de transition centrée sur l'interopérabilité, avec Rust et Swift respectivement.

Pour ce faire, nous avons besoin d'un meilleur outil d'interopérabilité et d'une meilleure prise en charge des bases de code en langage mixte dans l'outil de construction existant. En particulier, l'outil d'interopérabilité de qualité production existant pour C++/Rust suppose une surface d'API étroite. Cela s'est avéré suffisant pour certains écosystèmes, comme Android, mais d'autres écosystèmes ont des exigences supplémentaires. Une interopérabilité plus fidèle permet une adoption progressive dans d'autres écosystèmes, comme c'est déjà le cas pour Swift et comme l'explore Crubit pour Rust. Pour Rust, il reste des questions ouvertes, comme la manière de garantir que le code C++ ne viole pas la règle d'exclusivité du code Rust, ce qui créerait de nouvelles formes de comportements non définis.

En remplaçant les composants un par un, les améliorations en matière de sécurité sont apportées en continu et non pas en une seule fois à la fin d'une longue réécriture. Il est à noter qu'une réécriture complète peut éventuellement être réalisée avec cette stratégie incrémentale, mais sans les risques typiquement associés aux réécritures complètes de grands systèmes. En effet, pendant cette période, le système reste une base de code unique, testée en permanence et livrable.

MTE

Memory Tagging est une fonction du processeur, disponible dans ARM v8.5a, qui permet de marquer les régions de mémoire et les pointeurs avec l'une des 16 étiquettes. Lorsqu'elle est activée, le déréférencement d'un pointeur avec une étiquette mal assortie provoque une erreur.

De multiples fonctions de sécurité peuvent être construites au-dessus de MTE, par exemple :

  • Détection de l'utilisation après la libération et du dépassement des limites. Lorsque la mémoire est désallouée (ou réallouée), elle est aléatoirement réétiquetée. Cela invalide implicitement les pointeurs restants, qui auraient toujours l'"ancienne" étiquette. Dans la pratique, l'ensemble des étiquettes est petit (16). Il s'agit donc d'une atténuation probabiliste plutôt que d'une véritable sécurité, car il existe une probabilité non négligeable (6,25 %) que les pointeurs pendants ne soient pas marqués comme invalides (parce qu'ils ont été réétiquetés de manière aléatoire avec la même étiquette).

    • De même, cette méthode permet de détecter les bogues hors limites de manière probabiliste.
    • Il peut détecter de manière déterministe les débordements linéaires entre allocations, en supposant que l'allocateur garantisse que des allocations consécutives ne partagent jamais la même étiquette.
    • Il peut être possible de construire une prévention déterministe de l'utilisation sans limite du tas en plus de l'MTE en utilisant une analyse supplémentaire de type GC comme MarkUs.
  • Détection échantillonnée de l'utilisation après coup et du dépassement des limites. La même chose que ci-dessus, mais seulement sur une fraction des allocations afin de réduire suffisamment les frais généraux d'exécution pour un déploiement à grande échelle.

    Avec la MTE échantillonnée, les exploits devraient réussir après quelques tentatives : les attaques ne seront pas arrêtées. Cependant, les tentatives ratées génèrent du bruit (c'est-à-dire des pannes de MTE) que nous pouvons inspecter.


En utilisant ces deux techniques, la MTE peut permettre de :

  • Les bogues sont trouvés plus tôt dans le cycle de développement durable. La MTE non échantillonnée devrait être suffisamment bon marché pour être déployé dans la présoumission et les canaris.
  • Plus de bogues sont détectés en production. La MTE échantillonné permet un taux d'échantillonnage supérieur de 3 ordres de grandeur par rapport au GWP-ASan pour le même coût.
  • Des rapports de crash exploitables. La MTE synchrone signale l'endroit où le bogue s'est produit, au lieu de provoquer un plantage dû aux effets secondaires d'un bogue dont l'origine est difficile à déterminer. En outre, la MTE échantillonnée peut être combinée avec l'instrumentation du tas pour fournir des rapports de bogues avec une fidélité similaire à celle de GWP-ASan.
  • Amélioration de la fiabilité et de la sécurité au fur et à mesure que ces bogues sont corrigés.
  • Une diminution du retour sur investissement des exploits pour les attaquants. Les attaquants doivent soit trouver des vulnérabilités supplémentaires pour contourner de manière déterministe la MTE, soit risquer d'être détectés.

    • La vitesse de réaction des défenseurs dépendra de leur capacité à distinguer les tentatives d'exploitation des autres violations de la MTE. Les tentatives d'exploitation peuvent se dissimuler dans le bruit des violations de la MTE qui se produisent organiquement.
    • Même s'il n'est pas possible de distinguer les tentatives d'exploitation des violations organiques du MTE, la MTE devrait réduire la fenêtre d'exploitation, c'est-à-dire la fréquence et la durée pendant lesquelles un attaquant peut réutiliser un exploit donné. Plus les violations de l'ETM sont corrigées rapidement, plus la fenêtre d'exploitation est courte, ce qui réduit le retour sur investissement des exploits.
    • Cela souligne l'importance de corriger rapidement les violations de l'ETM pour atteindre le potentiel de sécurité de l'ETM. Pour y parvenir sans submerger les développeurs, l'ETM doit être associé à un travail proactif visant à réduire le volume de bogues.



La MTE non échantillonnée peut également être déployé pour atténuer les exploits, en protégeant de manière déterministe contre 10 à 15 % des bogues liés à la sécurité de la mémoire (en supposant qu'il n'y ait pas d'analyse de type GC). Cependant, en raison de la mémoire non triviale et de la surcharge d'exécution, nous nous attendons à ce que les déploiements en production se fassent principalement dans des charges de travail à faible encombrement, mais critiques pour la sécurité.

Malgré ses limites, nous pensons que la MTE est une voie prometteuse pour réduire le volume de bogues de sécurité temporelle dans les grandes bases de code C++ existantes. Il n'existe actuellement aucune alternative pour la sécurité temporelle du C++ qui puisse être déployée de manière réaliste à grande échelle.

CHERI

CHERI est un projet de recherche intriguant qui a le potentiel de fournir des garanties rigoureuses de sécurité de la mémoire pour le code C++ existant (et peut-être Carbon en mode renforcé), avec un effort de portage minimal. Les garanties de sécurité temporelle de CHERI reposent sur la mise en quarantaine de la mémoire désallouée et la révocation par balayage, et la question de savoir si la surcharge d'exécution sera acceptable pour les charges de travail de production reste ouverte.

Au-delà de la sécurité de la mémoire, les capacités de CHERI permettent également d'autres atténuations de sécurité intéressantes, telles que le sandboxing à grain fin.

Conclusion

Cinquante ans plus tard, les bogues de sécurité de la mémoire restent parmi les faiblesses logicielles les plus tenaces et les plus dangereuses. Étant l'une des principales causes de vulnérabilité, ils continuent d'entraîner des risques importants pour la sécurité. Il est de plus en plus évident que la sécurité de la mémoire est une propriété nécessaire des logiciels sûrs. Par conséquent, nous nous attendons à ce que l'industrie accélère la transition vers la sécurité de la mémoire au cours de la prochaine décennie. Nous sommes encouragés par les progrès déjà réalisés par Google et d'autres grands fabricants de logiciels.

Nous pensons qu'une approche "Secure-by-Design" (Sécuriser par la conception) est nécessaire pour une sécurité élevée de la mémoire, ce qui nécessite l'adoption de langages avec des garanties rigoureuses de sécurité de la mémoire. Étant donné le long délai nécessaire à la transition vers des langages de sécurité de la mémoire, il est également nécessaire d'améliorer la sécurité des bases de code C et C++ existantes dans la mesure du possible, en éliminant les classes de vulnérabilité.

Source : "Secure by Design: Google’s Perspective on Memory Safety" (Google)

Et vous ?

Quel est votre avis sur le sujet ?

Voir aussi :

Existe-t-il un consensus au sein de l'industrie sur l'abandon de C/C++ ? Sécuriser par la conception : Le point de vue de Google sur la sécurité de la mémoire

Retour aux éléments de base : un chemin vers des logiciels sûrs et mesurables. Un rapport de la Maison Blanche sur la sécurité de la mémoire et sur la qualité de la cybersécurité

Une erreur dans cette actualité ? Signalez-nous-la !