Aller au contenu

Rust et la sécurité mémoire par construction, garanties et limites

Rust et la sécurité mémoire par construction, garanties et limites

Section intitulée « Rust et la sécurité mémoire par construction, garanties et limites »

Un débordement de tampon peut se corriger, se détecter ou se rendre pénible à exploiter ; Rust prend une quatrième voie, le rendre impossible à compiler. Cette page explique par quel mécanisme le langage écarte les fautes mémoire spatiales et temporelles sans ramasse-miettes, puis où la garantie s’arrête : blocs unsafe, frontière avec le C, logique métier, dépendances. Position défendue : écrire en Rust le nouveau code exposé à des entrées hostiles, ne pas réécrire le reste par principe, et auditer ce que le compilateur ne voit pas.

→ Lire l’explication complète de la marmotte

Sûreté spatiale et sûreté temporelle : les fautes mémoire visées

Section intitulée « Sûreté spatiale et sûreté temporelle : les fautes mémoire visées »

Une faute mémoire est un accès à une zone que le programme n’a pas le droit de toucher à cet instant. Elle se range dans deux familles :

  • Sûreté spatiale : rester dans les bornes de l’objet. Violations : débordement de tampon en lecture ou en écriture, index trop grand ou négatif.
  • Sûreté temporelle : n’accéder à l’objet que pendant qu’il existe. Violations : utilisation après libération (use-after-free), double libération, pointeur pendant vers une variable de pile disparue, lecture de mémoire non initialisée.

La mécanique de la faute est décrite dans Comment un buffer overflow écrase la pile. Les rustines posées après coup (randomisation de l’espace d’adressage, pile non exécutable, canaris, intégrité du flot de contrôle) sont détaillées dans la page sur les protections mémoire. Ces atténuations gênent l’exploitation, elles ne suppriment pas la faute. Rust s’attaque à l’étape d’avant.

L’enjeu se mesure. Microsoft a indiqué en 2019, par son centre de réponse sécurité, qu’environ 70 % des vulnérabilités auxquelles l’éditeur attribue chaque année un identifiant CVE (Common Vulnerabilities and Exposures) relèvent de la sûreté mémoire. Le projet Chromium publie un ordre de grandeur comparable pour ses bugs de sécurité de sévérité haute. Deux bases de code, deux équipes parmi les mieux outillées du monde, même proportion. Le problème n’est donc ni un manque de compétence ni un manque de revue : c’est le langage.

Les repères institutionnels vont dans le même sens :

  • Google rapporte pour Android une baisse marquée de la part des vulnérabilités mémoire à mesure que le nouveau code est écrit dans des langages à mémoire sûre.
  • Le noyau Linux accepte du code Rust depuis la version 6.1 (décembre 2022).
  • La NSA (National Security Agency) a publié en 2022 une fiche recommandant les langages à mémoire sûre.
  • La CISA (Cybersecurity and Infrastructure Security Agency) demande aux éditeurs des feuilles de route de migration.
  • L’ONCD (Office of the National Cyber Director) de la Maison-Blanche a pris la même position en février 2024.

Quand des agences fédérales et un noyau trentenaire tombent d’accord, le sujet a quitté le stade de la mode de conférence.

Pour situer Rust dans la lignée du C, du C++ et des langages à ramasse-miettes, voir la frise Langages informatiques : histoire, inventeurs et usages.

Possession, déplacement et emprunts : le vérificateur d’emprunts

Section intitulée « Possession, déplacement et emprunts : le vérificateur d’emprunts »

Le modèle tient en cinq règles, toutes vérifiées à la compilation par un composant du compilateur appelé vérificateur d’emprunts (borrow checker).

  1. Possession (ownership) : chaque valeur a exactement un propriétaire (une variable, un champ de structure, un élément de collection).
  2. Destruction unique : quand le propriétaire sort de portée, la valeur est détruite et sa mémoire rendue, une seule fois, à un endroit que le compilateur détermine.
  3. Déplacement (move) : affecter la valeur ou la passer à une fonction transfère la possession. L’ancien nom devient inutilisable. Les types triviaux (entiers, booléens) sont copiés à la place.
  4. Emprunt : une valeur se prête par référence, soit à plusieurs lecteurs (&T, emprunt partagé), soit à un seul rédacteur (&mut T, emprunt exclusif), jamais les deux en même temps.
  5. Durées de vie (lifetimes) : aucune référence ne survit à la valeur qu’elle désigne. Le compilateur les infère la plupart du temps ; sinon, une annotation ('a) relie la durée de vie d’une sortie à celle d’une entrée.

Le schéma suivant se lit comme le cycle de vie d’une valeur : qui peut y toucher, à quelle condition, et à quel moment elle disparaît.

Séquence entre six acteurs : Valeur, Propriétaire a, Propriétaire b, Lecteur 1, Lecteur 2 et Rédacteur. 1. Le propriétaire a possède la valeur et la libère en sortant de portée. 2. Un déplacement (let b = a) la transmet au propriétaire b, et a devient inutilisable. 3 et 4. Si aucun emprunt exclusif n'est actif, b accorde un emprunt partagé &T à chacun des deux lecteurs. 5. Si aucun autre emprunt n'est actif, b accorde un emprunt exclusif &mut T au rédacteur. 6. Le lecteur 1 rend son emprunt, toujours avant la destruction de la valeur. 7. Quand plus aucun emprunt n'est vivant, b détruit la valeur une seule fois, en fin de portée.

Chaque règle ferme une classe de fautes :

  • La destruction unique supprime la double libération et l’oubli de libération dans les chemins d’erreur.
  • Le déplacement supprime l’utilisation après libération par l’ancien nom.
  • Les durées de vie suppriment le pointeur pendant.
  • L’exclusivité de l’écriture supprime l’invalidation d’itérateur, cette spécialité du C++ où un ajout dans un vecteur réalloue le tampon sous les pieds d’un pointeur encore en service.
let mut octets = vec![1, 2, 3];
let premier = &octets[0]; // emprunt partagé sur le contenu du vecteur
octets.push(4); // error[E0502]: cannot borrow `octets` as mutable
// because it is also borrowed as immutable
println!("{premier}"); // l'emprunt partagé vit encore ici

Tout cela se passe sans ramasse-miettes ni compteur de références implicite. Les durées de vie n’existent pas à l’exécution, et le binaire ne garde aucune trace de la vérification. La possession partagée existe, mais sur demande explicite : Rc (comptage de références, un seul fil d’exécution) ou Arc (comptage atomique, plusieurs fils).

Voilà le compromis central. Le gain : une preuve mécanique, sans coût à l’exécution, sur toute la base de code sûre. Le prix : du temps de conception et de compilation. Les graphes cycliques, les listes doublement chaînées, les structures qui se référencent elles-mêmes et les rappels croisés entrent mal dans le modèle. Les issues habituelles sont des index dans un tableau, Rc ou Arc, ou un bloc unsafe. Le vérificateur d’emprunts a la souplesse d’un contrôleur de gestion en clôture annuelle. Il facture à la conception ce que le C facture en production, avec les intérêts.

Exemple : une utilisation après déplacement refusée par le compilateur

Section intitulée « Exemple : une utilisation après déplacement refusée par le compilateur »

Pour reproduire : cargo new demo, remplacer src/main.rs par le contenu ci-dessous, puis cargo build.

fn consommer(_tampon: Vec<u8>) {
// la fonction devient propriétaire : le tampon est libéré ici, à la sortie
}
fn main() {
let tampon = vec![0u8; 16];
consommer(tampon); // déplacement : `tampon` n'est plus valide ensuite
println!("{}", tampon.len()); // error[E0382]: borrow of moved value: `tampon`
}

La compilation échoue, et le message désigne à la fois la ligne du déplacement et celle de l’usage fautif. L’équivalent en C (libérer dans la fonction appelée, relire dans l’appelant) compile et lit de la mémoire rendue à l’allocateur.

Trois corrections sont possibles, et chacune est une décision de conception visible dans la signature :

  • prêter (fn consommer(tampon: &[u8])) ;
  • cloner (tampon.clone(), coût explicite) ;
  • faire rendre la valeur par la fonction.

C’est le bénéfice le moins vanté du modèle : la signature dit qui libère. Le contrat qu’une bibliothèque C relègue dans un commentaire d’en-tête, à supposer qu’il existe, est ici vérifié à chaque appel.

Contrôle des bornes, Option, Result, Send et Sync

Section intitulée « Contrôle des bornes, Option, Result, Send et Sync »

Bornes. La taille d’un tableau dynamique ne se prouve pas à la compilation. Rust insère donc un test à chaque indexation t[i]. Hors bornes, le programme panique : arrêt contrôlé du fil d’exécution, avec message et emplacement, au lieu d’une écriture chez le voisin. La faute passe d’une exécution de code arbitraire à un déni de service localisé. L’optimiseur supprime le test quand il prouve l’index valide, cas typique des itérateurs, forme à privilégier. get(i) rend une Option pour traiter l’absence sans paniquer.

Initialisation. Lire une variable avant de lui avoir donné une valeur est refusé à la compilation, sur tous les chemins d’exécution.

Pointeur nul. Une référence Rust n’est jamais nulle. L’absence s’exprime par Option<T> (Some(valeur) ou None), et le compilateur impose de traiter les deux cas avant d’accéder au contenu. Option<&T> occupe la taille d’un pointeur : la garantie ne coûte pas un octet.

Erreurs. Result<T, E> porte soit le résultat, soit l’erreur. L’ignorer déclenche un avertissement, et l’opérateur ? la propage à l’appelant. L’échappatoire s’appelle unwrap() : elle transforme une erreur en panique et mérite une recherche systématique en revue.

Courses de données. Une course de données, c’est deux fils d’exécution qui accèdent à la même mémoire sans synchronisation, dont au moins un en écriture. Deux traits marqueurs l’écartent :

  • Send : la valeur peut être transférée à un autre fil.
  • Sync : la valeur peut être partagée par référence entre fils.

Le compilateur les déduit de la composition des types. Rc n’est pas Send. RefCell n’est pas Sync. Un Mutex<T> enferme la donnée : impossible de la lire sans prendre le verrou, puisque l’accès passe par la garde que le verrou renvoie. La règle d’emprunt (plusieurs lecteurs ou un seul rédacteur) fait le reste.

Classe de fauteCRust sûrRust avec unsafe
Débordement de tamponCompile, corruption silencieusePanique à l’exécutionPossible (accès non vérifié, arithmétique de pointeurs)
Utilisation après libérationCompileRefusée à la compilationPossible par pointeur brut
Double libérationCompileImpossible (destruction unique)Possible
Pointeur pendantCompileRefusé (durées de vie)Possible
Lecture non initialiséeCompile, avertissement éventuelRefusée à la compilationPossible
Déréférencement nulCompilePas de référence nulle, OptionPossible par pointeur brut
Course de donnéesCompileRefusée (Send, Sync)Possible si Send ou Sync est implémenté à tort
Dépassement d’entierIndéfini (signé), modulo (non signé)Panique en debug, modulo en release par défautIdentique
Fuite mémoirePossiblePossible, considérée sûrePossible
Interblocage, erreur de logiquePossiblePossiblePossible

Ce que Rust ne garantit pas : blocs unsafe, frontière FFI et le reste

Section intitulée « Ce que Rust ne garantit pas : blocs unsafe, frontière FFI et le reste »

Le mot-clé unsafe ne coupe pas le compilateur. Il ouvre cinq droits supplémentaires :

  • déréférencer un pointeur brut ;
  • appeler une fonction ou une méthode unsafe ;
  • lire ou modifier une variable statique mutable ;
  • implémenter un trait unsafe (dont Send et Sync à la main) ;
  • accéder aux champs d’une union.

Le typage et les règles d’emprunt sur les références restent vérifiés à l’intérieur du bloc. Ce qui change : la validité des pointeurs, l’absence d’alias interdits et l’initialisation de la mémoire deviennent des promesses de l’auteur.

/// # Safety
/// `ptr` doit désigner `len` octets initialisés, valides pendant tout l'appel.
unsafe fn somme(ptr: *const u8, len: usize) -> u32 {
// SAFETY: garanti par le contrat ci-dessus, à la charge de l'appelant.
let octets = unsafe { std::slice::from_raw_parts(ptr, len) };
octets.iter().map(|&o| u32::from(o)).sum()
}

Deux conséquences d’architecture en découlent.

Premièrement, la garantie réelle se formule ainsi : si tout le code unsafe est correct, aucun code sûr ne peut provoquer de comportement indéfini (undefined behavior : une situation où le langage ne promet plus rien sur ce que fait le programme). La bibliothèque standard en contient beaucoup (les collections en sont faites), les dépendances aussi. Le compilateur a lui-même des défauts de correction connus, rares et suivis publiquement.

Deuxièmement, l’effet d’un unsafe fautif n’est pas local. Un invariant cassé dans un bloc fait planter du code sûr trois modules plus loin. L’unité d’audit est donc le module avec ses champs privés, pas le bloc.

Le schéma suivant montre qui vérifie quoi de part et d’autre de la frontière.

Schéma de flux. Le code sûr est vérifié par le compilateur (possession, emprunts, durées de vie, types, Send et Sync) et par l'exécution (bornes vérifiées, panique en cas d'écart) ; il appelle une abstraction sûre dont la signature porte le contrat. Celle-ci maintient à la main les invariants du bloc unsafe, grâce aux champs privés du module. Dans le bloc unsafe, le compilateur vérifie encore les types et les emprunts de références ; le relecteur humain vérifie la validité des pointeurs bruts, les alias, l'initialisation et les contrats des fonctions unsafe. Le bloc unsafe appelle la bibliothèque C, dont la signature déclarée est crue sur parole, et en reçoit pointeurs et longueurs sans aucune garantie du langage.

La FFI (Foreign Function Interface) est le mécanisme d’appel vers du code compilé depuis un autre langage. Côté Rust, la signature d’une fonction C est une déclaration : rien ne la confronte à l’en-tête réel, et un écart de type ou de convention donne un comportement indéfini.

Trois points restent à la charge de l’auteur :

  • Les défauts du code C restent entiers.
  • La possession à travers la frontière se règle à la main : qui libère, et avec quel allocateur.
  • Une panique s’intercepte avant la frontière : la laisser remonter dans des cadres de pile C donne, selon l’interface déclarée et la version du compilateur, un arrêt immédiat du processus ou un comportement indéfini.

Une enveloppe Rust autour d’une bibliothèque C vulnérable donne une bibliothèque C vulnérable avec une meilleure ergonomie.

  • Erreurs de logique. Contournement d’autorisation, vérification de signature oubliée, injection, traversée de répertoires : tout cela compile sans un avertissement. Rust empêche d’écrire hors du tampon. Il n’empêche pas d’y ranger le mot de passe administrateur en clair, avec une rigueur mémoire irréprochable.
  • Fuites mémoire. Elles sont sûres au sens du langage : cycles de Rc, oubli volontaire d’une valeur, collection qui grossit sans borne sous une entrée hostile.
  • Paniques. Index hors bornes, unwrap() sur une erreur, division par zéro : autant de dénis de service si l’entrée est contrôlée par un attaquant. Dans un noyau ou un système embarqué, la panique est elle-même un incident.
  • Dépassement d’entier. Panique en mode debug, repli modulo en mode release par défaut. Le réglage overflow-checks du profil rétablit la vérification, et les méthodes checked_, saturating_ et wrapping_ rendent l’intention explicite. Une longueur repliée qui alimente du code sûr finit au pire en panique ; la même qui alimente un bloc unsafe redonne un débordement à l’ancienne.
  • Compétition hors courses de données. Le TOCTOU (time-of-check to time-of-use) sur un fichier, l’ordre d’arrivée de deux messages, deux verrous pris en sens inverse : interblocages et situations de compétition logiques compilent très bien.
  • Canaux auxiliaires. Le langage ne promet aucun temps constant, et l’optimiseur reste libre de transformer une comparaison en branchement. Les secrets ne sont pas effacés à la destruction, et un déplacement peut laisser des copies sur la pile. Voir les attaques par canaux auxiliaires.
  • Chaîne d’approvisionnement. Un projet de taille moyenne tire de crates.io des dizaines à des centaines de dépendances transitives. Les scripts de build (build.rs) et les macros procédurales exécutent du code arbitraire à la compilation, avec les droits de celui qui lance le build : poste de développement ou agent d’intégration continue, secrets compris. La sûreté mémoire ne dit rien d’une dépendance malveillante, et le risque se lit comme pour tout autre écosystème de paquets : voir les dépendances transitives, ce risque non choisi.

Conditions d’exploitation : revue des blocs unsafe, dépendances et migration

Section intitulée « Conditions d’exploitation : revue des blocs unsafe, dépendances et migration »

Prérequis. Il faut une chaîne d’outils (compilateur et Cargo) disponible pour la cible. Le niveau de support varie selon l’architecture, point à vérifier avant tout engagement sur de l’embarqué exotique ou une chaîne certifiée. Il faut aussi une équipe formée au modèle de possession : le coût d’entrée est réel et se paie en semaines, pas en heures.

Inventaire et revue des blocs unsafe.

  • Compter les blocs par crate, dépendances comprises.
  • Poser #![forbid(unsafe_code)] sur toute crate qui n’en a pas besoin.
  • Exiger un commentaire // SAFETY: par bloc et une rubrique # Safety par fonction unsafe (un lint Clippy le contrôle).
  • Concentrer l’unsafe dans de petits modules exposés par une API (Application Programming Interface) sûre.
  • Soumettre chaque modification de ces modules à des relecteurs désignés.

Outils dynamiques.

  • Miri interprète les tests et signale les comportements indéfinis du code unsafe (accès hors bornes, utilisation après libération, alias invalides, lecture non initialisée). Il demande le canal nightly du compilateur, il est lent, il ne traverse pas la plupart des appels C et ne voit que les chemins exécutés.
  • Le fuzzing (cargo-fuzz, adossé à libFuzzer) trouve en Rust des paniques, des boucles et des allocations démesurées, c’est-à-dire la surface de déni de service. Priorité aux analyseurs.
  • Les sanitizers d’adresses et de fils servent surtout aux binaires mixtes Rust et C. Leur disponibilité dépend de la cible et du canal du compilateur.

Dépendances.

  • Versionner Cargo.lock pour tout binaire et construire avec --locked.
  • Lancer cargo-audit, qui confronte l’arbre de dépendances à la base d’avis RustSec.
  • Utiliser cargo-deny pour imposer une politique : licences, sources autorisées, crates bannies, avis de sécurité.
  • Utiliser cargo-vet pour consigner qui a relu quelle version, avec import d’audits tiers.
  • Isoler le build : pas de secret dans la tâche de compilation, dépendances mises en miroir ou embarquées dans le dépôt.

La suite logique est la compilation reproductible.

Compilateur. Déclarer la version minimale supportée, ou MSRV (Minimum Supported Rust Version), dans le champ rust-version de Cargo.toml, et figer la chaîne d’outils dans rust-toolchain.toml. Une version stable sort toutes les six semaines, et seule la dernière reçoit des correctifs. La politique tenable consiste donc à suivre, pas à geler : les dépendances relèvent leur propre version minimale, et un compilateur figé finit par bloquer leurs mises à jour de sécurité.

Profil et supervision. Décider explicitement du sort des dépassements d’entier et de la stratégie de panique (déroulement de pile ou arrêt immédiat). Journaliser et compter les paniques : leur taux après un déploiement est un signal de régression. Prévoir la reprise : redémarrage par le superviseur, et traitement des verrous « empoisonnés » (un Mutex standard dont le détenteur a paniqué renvoie une erreur aux suivants).

Migration d’une base C ou C++. L’arbre de décision suivant résume la règle.

Arbre de décision pour un composant à écrire ou à modifier. Première question : nouveau code ou code existant ? Pour du nouveau code, Rust est le choix par défaut si la cible et l'équipe le permettent. Pour du code existant en C ou C++, deuxième question : traite-t-il des entrées non fiables ? Si oui (analyseurs de formats, protocoles réseau, décodeurs), réécriture ciblée en Rust, reliée au reste par une frontière FFI : interface étroite, types élémentaires, revue des blocs unsafe. Si non (code ancien, stable), conservation en C avec fuzzing, atténuations et durcissement.

Le raisonnement s’appuie sur ce que Google rapporte pour Android : les vulnérabilités mémoire se concentrent dans le code neuf ou récemment modifié, et le vieux code se bonifie avec les corrections. Passer le nouveau code en Rust tarit donc la source sans toucher à l’existant.

Trois pièges guettent une réécriture :

  • La réécriture intégrale réintroduit des erreurs de logique corrigées depuis vingt ans, pour un gain nul sur les modules sans entrée hostile.
  • La traduction ligne à ligne du C vers du Rust truffé d’unsafe donne la syntaxe sans la garantie.
  • Une frontière FFI trop large fabrique plus de code non vérifié qu’elle n’en supprime.

Une réécriture « en Rust » vendue sur une diapositive sans décompte des blocs unsafe relève de la peinture fraîche.

Un programme Rust sans unsafe peut-il contenir une faute mémoire ?

Section intitulée « Un programme Rust sans unsafe peut-il contenir une faute mémoire ? »

Pas dans son propre code, sauf défaut du compilateur. Mais il s’appuie sur la bibliothèque standard et sur des dépendances qui contiennent de l’unsafe, parfois du C lié statiquement. L’affirmation « sans unsafe » ne vaut que sur l’arbre de dépendances complet, d’où l’inventaire.

Le contrôle des bornes dégrade-t-il les performances ?

Section intitulée « Le contrôle des bornes dégrade-t-il les performances ? »

Dans la plupart des programmes, le coût est noyé dans le bruit. L’optimiseur élimine les tests qu’il sait prouver, et les itérateurs n’en génèrent pas. Avant de passer à un accès non vérifié dans une boucle critique, mesurer : remplacer un test prévisible par un bloc unsafe sans chiffre à l’appui échange une garantie contre une impression.

Sans cette garantie à la compilation, chaque accès à un tampon et chaque libération reposent sur la vigilance de l’auteur et du relecteur, ligne après ligne, pendant toute la vie du code. La détection bascule alors vers l’exécution : une corruption se manifeste loin de sa cause, parfois jamais, et aucun journal ne signale l’écriture fautive. Les atténuations de la plateforme deviennent la seule barrière entre un octet de trop et une prise de contrôle, alors qu’elles se contournent une à une. Côté audit, la surface n’a plus de frontière : faute de blocs marqués, tout le code qui manipule des pointeurs réclame le niveau de revue qu’un projet Rust réserve à quelques modules.

Un programme range ses données dans la mémoire de l’ordinateur. Deux erreurs classiques : lire ou écrire à côté de la zone prévue, et utiliser une zone déjà rendue au système. Un attaquant sait transformer ces erreurs en prise de contrôle de la machine. Rust les traite avant que le programme existe. Chaque donnée a un seul responsable dans le code ; quand ce responsable disparaît, la donnée est effacée automatiquement, une seule fois. D’autres parties du programme peuvent la consulter, à plusieurs en lecture ou une seule en écriture, jamais les deux ensemble, et jamais après son effacement. Un outil, le compilateur, vérifie ces règles au moment de traduire le texte du programme ; si une règle est violée, il refuse de produire le programme. Ce qui ne peut pas être vérifié à l’avance, par exemple une position dans une liste, est contrôlé pendant l’exécution : en cas d’écart, le programme s’arrête au lieu d’abîmer la mémoire. Il reste des limites. Le programmeur peut lever certains contrôles dans des passages marqués d’un mot spécial, et l’erreur y redevient possible. Le code écrit dans d’autres langages n’est pas vérifié. Les erreurs de raisonnement, les arrêts provoqués par un attaquant et le code récupéré chez d’autres auteurs échappent aussi à la vérification. Il faut donc relire les passages marqués, tester avec des données aberrantes et surveiller ce qui est importé.

À lire aussi

Pages voisines