
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 :
- 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.
- Un programme composé uniquement de code sûr est garanti de maintenir les invariants de sécurité au moment de l'exécution.
- 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 :
[LIST=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.[*]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.
[...[/*]
La fin de cet article est réservée aux abonnés. Soutenez le Club Developpez.com en prenant un abonnement pour que nous puissions continuer à vous proposer des publications.