Rust et la sécurité mémoire par construction, garanties et limites
- Accueil
- Fondations
- Programmation
- 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.
Dans une bibliothèque où chaque livre n’a qu’un seul emprunteur inscrit sur sa fiche, personne ne peut déchirer une page pendant qu’un autre la lit, ni rendre deux fois le même livre. Rust applique ce genre de règlement aux programmes : les bêtises les plus dangereuses sont refusées au guichet, avant même que le programme existe.
→ 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).
- Possession (ownership) : chaque valeur a exactement un propriétaire (une variable, un champ de structure, un élément de collection).
- 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.
- 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.
- 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. - 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.
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 vecteuroctets.push(4); // error[E0502]: cannot borrow `octets` as mutable // because it is also borrowed as immutableprintln!("{premier}"); // l'emprunt partagé vit encore iciTout 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 faute | C | Rust sûr | Rust avec unsafe |
|---|---|---|---|
| Débordement de tampon | Compile, corruption silencieuse | Panique à l’exécution | Possible (accès non vérifié, arithmétique de pointeurs) |
| Utilisation après libération | Compile | Refusée à la compilation | Possible par pointeur brut |
| Double libération | Compile | Impossible (destruction unique) | Possible |
| Pointeur pendant | Compile | Refusé (durées de vie) | Possible |
| Lecture non initialisée | Compile, avertissement éventuel | Refusée à la compilation | Possible |
| Déréférencement nul | Compile | Pas de référence nulle, Option | Possible par pointeur brut |
| Course de données | Compile | Refusée (Send, Sync) | Possible si Send ou Sync est implémenté à tort |
| Dépassement d’entier | Indéfini (signé), modulo (non signé) | Panique en debug, modulo en release par défaut | Identique |
| Fuite mémoire | Possible | Possible, considérée sûre | Possible |
| Interblocage, erreur de logique | Possible | Possible | Possible |
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 »Ce qu’un bloc unsafe autorise exactement
Section intitulée « Ce qu’un bloc unsafe autorise exactement »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(dontSendetSyncà 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.
La frontière FFI avec du code C
Section intitulée « La frontière FFI avec du code C »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.
Le reste de la liste
Section intitulée « Le reste de la liste »- 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-checksdu profil rétablit la vérification, et les méthodeschecked_,saturating_etwrapping_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 blocunsaferedonne 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# Safetypar fonctionunsafe(un lint Clippy le contrôle). - Concentrer l’
unsafedans 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.lockpour 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.
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’
unsafedonne 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.
🐝 Ce qui arrive si on s’en passe
Section intitulée « 🐝 Ce qui arrive si on s’en passe »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.
🦫 Explication de la marmotte 🍫
Section intitulée « 🦫 Explication de la marmotte 🍫 »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
- WannaCry et MS17-010 : fenêtre d'exposition et correctifsWannaCry, EternalBlue et MS17-010 montrent comment les deux mois entre un correctif publié et un correctif déployé deviennent la fenêtre d'exposition d'un parc Windows.
- Mettre en place une gestion récurrente des vulnérabilitésGuide pratique pour inventorier les actifs, scanner les vulnérabilités, prioriser les correctifs et organiser un cycle de patch management récurrent.
- Comment les attaquants utilisent l'IA : de la CVE au zero-dayCe que l'intelligence artificielle apporte réellement aux attaquants d'après les rapports publiés par OpenAI, Anthropic, Google et Microsoft, l'accélération de l'exploitation des vulnérabilités publiées, la recherche de zero-days assistée par IA et ce que les défenseurs doivent changer.
- Prioriser les vulnérabilités avec CVSS 4.0, EPSS, KEV et expositionCe que mesurent CVSS 4.0, EPSS et le catalogue KEV, ce qu'ils ne mesurent pas, et comment les croiser avec le contexte d'exposition pour décider quoi corriger en premier.
Pages voisines
La citent
- Méthodes formelles TLA+ et Coq (Rocq), où elles valent leur coût
- Non-interférence et contrôle de flux d'information en pratique
- Vérifier un protocole cryptographique avec Tamarin ou ProVerif
Rust et la sécurité mémoire par construction, garanties et limites
Elle cite
- ASLR DEP NX canaries et CFI contre les exploits mémoire
- Vérifier une chaîne de compilation reproductible
- Attaques par canaux auxiliaires et secrets
- Comment corriger une attaque path traversal
- Comment un buffer overflow écrase la pile
- Exploiter une race condition TOCTOU sur fichier privilégié
- Langages informatiques : histoire, inventeurs et usages
- Maîtriser le risque des dépendances transitives (npm, pip, Maven, Go)
