Mozilla a publié
Quantum CSS pour Firefox l'année dernière, culminant en huit ans de développement de Rust, un langage de programmation système convivial en mémoire. Il a fallu plus d'un an pour réécrire le composant principal du navigateur dans Rust.
Jusqu'à présent, tous les principaux moteurs de navigation sont écrits en C ++, principalement pour des raisons d'efficacité. Mais avec de grandes performances vient une grande responsabilité: les programmeurs C ++ doivent gérer manuellement la mémoire, ce qui ouvre la boîte de vulnérabilité de Pandora. Rust corrige non seulement ces erreurs, mais ses méthodes empêchent également
les courses de données , permettant aux programmeurs d'implémenter plus efficacement le code parallèle.
Qu'est-ce que la sécurité de la mémoire?
Lorsque nous parlons de création d'applications sécurisées, nous mentionnons souvent la sécurité de la mémoire. Officieusement, nous voulons dire qu'en aucun état le programme ne peut accéder à une mémoire invalide. Causes des atteintes à la sécurité:
- enregistrer le pointeur après avoir libéré de la mémoire (utilisation après libération);
- déréférencer un pointeur nul;
- utilisation de mémoire non initialisée;
- programme tente de libérer deux fois la même cellule (double-free);
- débordement de tampon.
Pour une définition plus formelle, voir
Qu'est-ce que la sécurité de la mémoire de Michael Hicks, ainsi qu'un
article scientifique sur ce sujet.
De telles violations peuvent entraîner un plantage inattendu ou une modification du comportement attendu du programme. Conséquences potentielles: fuite d'informations, exécution de code arbitraire et exécution de code à distance.
Gestion de la mémoire
La gestion de la mémoire est essentielle aux performances et à la sécurité des applications. Dans cette section, nous considérerons le modèle de mémoire de base. L'un des concepts clés est celui des
pointeurs . Ce sont des variables dans lesquelles les adresses mémoire sont stockées. Si nous allons à cette adresse, nous y verrons des données. Par conséquent, nous disons que le pointeur est une référence à ces données (ou pointe vers elles). Tout comme l'adresse du domicile indique aux gens où vous trouver, l'adresse mémoire indique au programme où trouver les données.
Tout dans le programme se trouve à des adresses mémoire spécifiques, y compris les instructions de code. Une utilisation incorrecte des pointeurs peut entraîner de graves vulnérabilités, notamment des fuites d'informations et l'exécution de code arbitraire.
Attribution / libération
Lorsque nous créons une variable, le programme doit allouer suffisamment d'espace en mémoire pour stocker les données de cette variable. Étant donné que chaque processus a une quantité limitée de mémoire, vous avez bien sûr besoin d'un moyen de
libérer des ressources. Lorsque la mémoire est libérée, elle devient disponible pour stocker de nouvelles données, mais les anciennes données y vivent jusqu'à ce que la cellule soit écrasée.
Tampons
Un tampon est une zone de mémoire contiguë dans laquelle plusieurs instances du même type de données sont stockées. Par exemple, l'expression «Mon chat est un batman» sera stockée dans un tampon de 16 octets. Les tampons sont déterminés par l'adresse de départ et la longueur. Afin de ne pas endommager les données de la mémoire voisine, il est important de s'assurer de ne pas lire ou écrire en dehors du buffer.
Contrôle du flux
Les programmes se composent de routines qui s'exécutent dans un ordre spécifique. À la fin du sous-programme, l'ordinateur passe au pointeur stocké à la partie suivante du code (appelée l'
adresse de retour ). Lorsque vous vous rendez à l'adresse de retour, l'une des trois choses suivantes se produit:
- Le processus se poursuit normalement (l'adresse de retour n'est pas modifiée).
- Le processus se bloque (l'adresse a été modifiée et pointe vers une mémoire non exécutable).
- Le processus se poursuit, mais pas comme prévu (l'adresse de retour a changé et le flux de contrôle a changé).
Comment les langues assurent la sécurité de la mémoire
Tous les langages de programmation appartiennent à différentes parties du
spectre . D'un côté du spectre, il y a des langages comme C / C ++. Ils sont efficaces, mais nécessitent une gestion manuelle de la mémoire. D'un autre côté, les langages interprétés avec gestion automatique de la mémoire (par exemple, le comptage des références et le garbage collection (GC)), mais ils sont payants avec les performances. Même les langues avec un garbage collection bien optimisé ne peuvent pas comparer les
performances avec les langues sans GC.
Gestion manuelle de la mémoire
Certaines langues (par exemple, C) nécessitent que les programmeurs gèrent manuellement la mémoire: quand et combien de mémoire allouer, quand la libérer. Cela donne au programmeur un contrôle complet sur la façon dont le programme utilise les ressources, fournissant un code rapide et efficace. Mais cette approche est sujette aux erreurs, en particulier dans les bases de code complexes.
Erreurs faciles à commettre:
- oubliez que les ressources sont gratuites et essayez de les utiliser;
- n'allouez pas suffisamment d'espace pour le stockage des données;
- lire la mémoire en dehors du tampon.
Consignes de sécurité appropriées pour ceux qui gèrent la mémoire manuellementPointeurs intelligents
Les pointeurs intelligents fournissent des informations supplémentaires pour empêcher une mauvaise gestion de la mémoire. Ils sont utilisés pour la gestion automatique de la mémoire et la vérification des bordures. Contrairement à un pointeur classique, un pointeur intelligent est capable de s'autodétruire et n'attendra pas que le programmeur le supprime manuellement.
Il existe différentes options pour une telle construction, qui encapsule le pointeur d'origine dans plusieurs abstractions utiles. Certains pointeurs intelligents
comptent les références à chaque objet, tandis que d'autres mettent en œuvre une stratégie de portée pour limiter la durée de vie du pointeur à certaines conditions.
Lors du comptage des liens, les ressources sont libérées lorsque la dernière référence à l'objet est supprimée. Les implémentations de comptage de référence de base souffrent de performances médiocres, d'une consommation de mémoire accrue et sont difficiles à utiliser dans des environnements multithreads. Si les objets se réfèrent les uns aux autres (liens circulaires), le nombre de références pour chaque objet n'atteindra jamais zéro, des méthodes plus complexes sont donc nécessaires.
Collecte des ordures
Certains langages (par exemple Java, Go, Python) implémentent le
garbage collection . Une partie du runtime appelée garbage collector (GC) surveille les variables et identifie les ressources inaccessibles dans le graphe de liens entre les objets. Dès que l'objet devient indisponible, le GC libère de la mémoire de base pour une réutilisation future. Toute allocation et libération de mémoire se produit sans commande de programmeur explicite.
Bien que le GC garantisse que la mémoire est toujours utilisée correctement, il ne libère pas la mémoire de la manière la plus efficace - parfois la dernière utilisation d'un objet se produit bien avant que le garbage collector ne libère de la mémoire. Les coûts de performance sont prohibitifs pour les applications critiques: vous devez parfois utiliser 5 fois plus de mémoire pour éviter une dégradation des performances.
Possession
Rust utilise la propriété pour garantir des performances élevées et une sécurité de la mémoire. Plus formellement, il s'agit d'un exemple de
typage d'affinité . Tout le code Rust suit certaines règles qui permettent au compilateur de gérer la mémoire sans perdre de temps d'exécution:
- Chaque valeur a une variable appelée le propriétaire.
- Un seul propriétaire peut être à la fois.
- Lorsque le propriétaire sort de la portée, la valeur est supprimée.
Les valeurs peuvent être
transférées ou
empruntées d'une variable à une autre. Ces règles s'appliquent à une partie du compilateur appelée vérificateur d'emprunt.
Lorsqu'une variable sort de la portée, Rust libère cette mémoire. Dans l'exemple suivant, les variables
s1
et
s2
dépassent la portée, les deux essaient de libérer la même mémoire, ce qui conduit à une erreur de double libération. Pour éviter cela, lors du transfert d'une valeur à partir d'une variable, le propriétaire précédent devient invalide. Si le programmeur essaie ensuite d'utiliser une variable non valide, le compilateur rejettera le code. Cela peut être évité en créant une copie complète des données ou en utilisant des liens.
Exemple 1 : transfert de propriété
let s1 = String::from("hello"); let s2 = s1;
Un autre ensemble de règles du vérificateur d'emprunt concerne la durée de vie des variables. Rust interdit l'utilisation de variables non initialisées et de pointeurs pendants vers des objets inexistants. Si vous compilez le code de l'exemple ci-dessous,
r
fera référence à une mémoire qui est libérée lorsque
x
sort du domaine: un pointeur suspendu se produit. Le compilateur surveille toutes les zones et vérifie la validité de tous les transferts, obligeant parfois le programmeur à indiquer explicitement la durée de vie de la variable.
Exemple 2 : pointeur suspendu
let r; { let x = 5; r = &x; } println!("r: {}", r);
Le modèle de propriété fournit une base solide pour un accès correct à la mémoire, empêchant un comportement indéfini.
Vulnérabilités de la mémoire
Les principales conséquences d'une mémoire vulnérable:
- Crash : l'accès à une mémoire non valide peut entraîner une fermeture inattendue de l'application.
- Fuite d'informations : fourniture involontaire de données privées, y compris des informations confidentielles, telles que des mots de passe.
- Exécution de code arbitraire (ACE) : permet à un attaquant d'exécuter des commandes arbitraires sur la machine cible. Si cela se produit sur le réseau, nous l'appelons exécution de code à distance (RCE).
Un autre problème est
une fuite de mémoire lorsque la mémoire allouée n'est pas libérée après la fin du programme. Vous pouvez donc utiliser toute la mémoire disponible: les demandes de ressources sont alors bloquées, ce qui entraînera un déni de service. Il s'agit d'un problème de mémoire qui ne peut pas être résolu au niveau de PL.
Dans le meilleur des cas, avec une erreur de mémoire, l'application se bloque. Dans le pire des cas, un attaquant prend le contrôle d'un programme grâce à une vulnérabilité (ce qui pourrait entraîner de nouvelles attaques).
Abus de mémoire libérée (utilisation après libération, double libération)
Cette sous-classe de vulnérabilités se produit lorsqu'une ressource est libérée, mais un lien vers son adresse est toujours conservé. Il s'agit d'une
puissante méthode de piratage qui peut entraîner un accès hors de portée, une fuite d'informations, l'exécution de code et bien plus encore.
Les langages avec récupération de place et comptage des références empêchent l'utilisation de pointeurs non valides, détruisant uniquement les objets inaccessibles (ce qui peut entraîner une dégradation des performances), et les langages manuels sont vulnérables à cette vulnérabilité (en particulier dans les bases de code complexes). L'outil de vérification d'emprunt dans Rust ne permet pas de détruire des objets pendant qu'il est référencé, donc ces bogues sont supprimés au stade de la compilation.
Variables non initialisées
Si la variable est utilisée avant l'initialisation, ces données peuvent contenir toutes les données, y compris des déchets aléatoires ou des données précédemment supprimées, ce qui entraîne une fuite d'informations (elles sont parfois appelées
pointeurs non valides ). Pour éviter ces problèmes, les langages de gestion de la mémoire utilisent souvent la procédure d'initialisation automatique après l'allocation de mémoire.
Comme dans C, la plupart des variables dans Rust ne sont pas initialisées initialement. Mais contrairement à C, vous ne pouvez pas les lire avant l'initialisation. Le code suivant ne compile pas:
Exemple 3 : utilisation d'une variable non initialisée
fn main() { let x: i32; println!("{}", x); }
Pointeurs nuls
Lorsqu'une application déréférence un pointeur qui s'avère nul, elle accède généralement aux ordures et provoque un plantage. Dans certains cas, ces vulnérabilités peuvent conduire à l'exécution de code arbitraire (
1 ,
2 ,
3 ). Rust a deux types de pointeurs: les
liens et les pointeurs bruts. Les liens sont sûrs, mais les pointeurs bruts peuvent être un problème.
Rust empêche de déréférencer un pointeur nul de deux manières:
- Évitez les pointeurs annulables.
- Évitez de déréférencer les pointeurs bruts.
Rust évite les pointeurs nuls en les remplaçant par le
Option
spécial
Option
. Pour modifier la valeur null possible dans le type
Option
, le langage requiert que le programmeur gère explicitement le cas avec une valeur null, sinon le programme ne compilera pas.
Que faire si les pointeurs qui autorisent une valeur nulle ne peuvent pas être évités (par exemple, lors de l'interaction avec du code dans une autre langue)? Essayez d'isoler les dégâts. Le déréférencement des pointeurs bruts doit se produire dans un bloc non sécurisé isolé. Il
assouplit les règles Rust et résout certaines opérations qui peuvent provoquer un comportement indéfini (par exemple, déréférencer un pointeur brut).
"Tout sur le chekcer d'emprunt ... et cet endroit sombre?"
- Ceci est un bloc dangereux. N'y allez jamais, SimbaDébordement de tampon
Nous avons discuté des vulnérabilités qui peuvent être évitées en restreignant l'accès à la mémoire non définie. Mais le problème est que le débordement de tampon n'accède pas correctement à la mémoire non définie, mais allouée légalement. Comme le bogue d'utilisation après libération, cet accès peut être un problème car il accède à la mémoire libérée, qui contient toujours des informations confidentielles qui ne devraient plus exister.
Les débordements de tampon signifient simplement un accès hors des limites. En raison de la façon dont les tampons sont stockés en mémoire, ils fuient souvent des informations qui peuvent contenir des données sensibles, y compris des mots de passe. Dans les cas plus graves, les vulnérabilités ACE / RCE sont possibles en remplaçant le pointeur d'instruction.
Exemple 4: dépassement de tampon (code C)
int main() { int buf[] = {0, 1, 2, 3, 4};
La protection la plus simple contre les débordements de tampon est de toujours exiger des contrôles de bordure lors de l'accès aux éléments, mais cela conduit à de
mauvaises performances .
Que fait la rouille? Les types de tampons intégrés dans la bibliothèque standard nécessitent des vérifications de bordure pour tout accès aléatoire, mais fournissent également des API d'itérateur pour accélérer les appels séquentiels. Cela garantit que la lecture et l'écriture en dehors des limites ne sont pas possibles pour ces types. Rust favorise les motifs qui nécessitent des vérifications de bordure uniquement aux endroits où vous devez presque certainement les placer manuellement en C / C ++.
La sécurité de la mémoire n'est que la moitié de la bataille
Les failles de sécurité entraînent des vulnérabilités telles que la fuite de données et l'exécution de code à distance. Il existe différentes façons de protéger la mémoire, notamment les pointeurs intelligents et la récupération de place. Vous pouvez même
prouver officiellement la sécurité de la mémoire . Alors que certains langages ont accepté la dégradation des performances pour la sécurité de la mémoire, le concept de propriété de Rust assure la sécurité et minimise les frais généraux.
Malheureusement, les erreurs de mémoire ne sont qu'une partie de l'histoire lorsque nous parlons d'écrire du code sécurisé. Dans le prochain article, nous considérerons la sécurité des threads et les attaques contre le code parallèle.
Exploiter les vulnérabilités de la mémoire: ressources supplémentaires