- Comparer directement le nombre de CVE de Rust et de C/C++ fait facilement passer à côté d’une différence de critère sur ce qui est considéré comme une vulnérabilité de sûreté mémoire relevant d’un « problème de bibliothèque »
- En C/C++, même si un appel d’API incorrect provoque de l’UB ou un segfault, cela est généralement traité comme un mauvais usage du code utilisateur, et cette simple possibilité n’est pas systématiquement enregistrée comme CVE
- L’appel
curl_getenv(NULL) dans libcurl peut être compilé sans avertissement et provoquer un segfault à l’exécution, mais il n’est généralement pas considéré comme une vulnérabilité de curl
- En Rust, si un bug mémoire se produit uniquement via des appels à une API sûre, sans
unsafe dans le code utilisateur, cela est considéré comme un soundness bug de la bibliothèque
- Ainsi, certaines CVE Rust sont enregistrées selon des critères plus stricts qu’en C/C++, ce qui rend difficile d’évaluer la sûreté mémoire à partir de la simple comparaison du nombre brut de CVE
Pourquoi la comparaison du nombre de CVE est trompeuse
- Les CVE sont une base de données qui classe et recense les vulnérabilités de sécurité logicielle
- Une vulnérabilité peut venir d’un simple bug de logique applicative, ou d’un problème de sûreté mémoire plus facile à exploiter
- En comparant le nombre de CVE de Rust et de C/C++, certains avancent que Rust « n’est pas réellement memory-safe » ou « ne vaut pas la peine d’être adopté »
- Mais il existe une grande différence dans la manière dont les deux écosystèmes traitent les vulnérabilités potentielles liées à la sûreté mémoire
Des vulnérabilités restent possibles en Rust
- Les programmes Rust peuvent eux aussi provoquer de l’UB et des bugs de sûreté mémoire
- Dans la plupart des cas, ces problèmes nécessitent le mot-clé
unsafe
- Il est faux d’affirmer qu’un programme Rust ne peut jamais rencontrer d’UB
- Des vulnérabilités générales sans lien avec la sûreté mémoire sont également possibles en Rust
- Par exemple, oublier un contrôle d’autorisation pour accéder à un tableau de bord administrateur peut arriver dans n’importe quel langage
Exemple de bibliothèque C : curl_getenv(NULL)
curl est une bibliothèque réseau en C, largement utilisée et bien maintenue
curl_getenv dans libcurl est une fonction d’abstraction portable permettant de récupérer la valeur d’une variable d’environnement sur plusieurs systèmes d’exploitation
- Le programme C suivant passe un pointeur
NULL à curl_getenv
#include <curl/curl.h>
int main(void) {
curl_getenv(NULL);
}
- Ce programme peut être compilé sans avertissement avec
gcc test.c -otest -lcurl -Wall -Wextra
- À l’exécution, il peut provoquer un segfault, ce qui peut être vu comme un bug de sûreté mémoire et une vulnérabilité potentielle
- Pourtant, ce type d’exemple n’est généralement pas considéré comme une vulnérabilité de
curl
En C/C++, la simple possibilité d’un mauvais usage ne devient pas une CVE
- Dans un cas comme
curl_getenv(NULL), le problème est généralement considéré comme un mauvais usage de l’API
- L’emplacement du défaut est alors attribué au code applicatif, et non à la bibliothèque ou à l’API
- Cette pratique s’explique principalement par deux raisons
- Le système de types limité du C rend difficile l’expression précise des contrats d’API, invariants, préconditions et postconditions
- Il n’est pas pratique de documenter tous les mauvais usages possibles
- En pratique, la documentation de
curl_getenv ne précise pas que l’appel avec NULL est interdit ni qu’il peut provoquer un segfault
- En C/C++, il est très facile de déclencher accidentellement de l’UB ; si chaque vulnérabilité potentielle était signalée comme CVE, la plupart des bibliothèques seraient submergées par un très grand nombre de CVE
- En conséquence, en C/C++, les CVE portent généralement non pas sur « l’existence d’une API pouvant être mal utilisée », mais sur des cas précis de mauvais usage
En Rust, la frontière de responsabilité des API sûres est différente
- En Rust, si l’on suppose qu’un simple appel sûr comme
hyper::foo(None) suffit à faire segfaulter un programme, cela peut constituer une CVE de hyper
- Si aucun bloc
unsafe n’existe dans le programme utilisateur mais qu’un bug mémoire survient, c’est qu’il doit y avoir un soundness bug dans la bibliothèque concernée
- En Rust, si l’utilisation d’une API de bibliothèque sûre, quelle qu’elle soit, peut provoquer un bug mémoire, cela est considéré comme un bug de la bibliothèque, pas du code utilisateur
- On dira alors que cette API est unsound ou qu’elle comporte une faille de soundness
- Même si aucun problème n’a encore été observé dans un programme réel, une CVE peut être créée dès lors qu’un simple usage d’API sûre peut provoquer un bug mémoire
safe et unsafe rendent la responsabilité explicite
- En Rust, il est plus clair qu’en C/C++ de répondre à la question : « cette fonction est-elle utilisée correctement du point de vue de la sûreté mémoire ? »
- Si la fonction appelée n’est pas marquée
unsafe, elle doit pouvoir être utilisée en toute sécurité
- Si la fonction appelée est
unsafe, son appel nécessite un bloc unsafe, ce qui rend les zones à risque visibles lors de la revue de code et dans la base de code
- Cette distinction contribue à rendre la sûreté mémoire de Rust praticable à grande échelle
- Si le code utilisateur n’emploie pas
unsafe et qu’il n’y a pas de bug du compilateur, il devient difficile d’attribuer au code utilisateur la responsabilité d’une cause potentielle de bug mémoire
- Si une bibliothèque n’expose pas d’interface
unsafe, l’utilisateur ne devrait pas pouvoir l’utiliser d’une manière qui provoque un bug mémoire
- Même si la bibliothèque utilise
unsafe en interne et introduit un bug, la correction se fait dans la bibliothèque et les utilisateurs redeviennent protégés contre les bugs mémoire
Le nombre brut de CVE ne suffit pas pour comparer la sûreté mémoire
- Si l’on appliquait la même logique au C,
curl_getenv devrait lui aussi être compté comme une CVE de curl, mais le C ne dispose pas d’une distinction comme safe et unsafe en Rust
- En pratique, presque tout le code C est implicitement proche de
unsafe, ce qui empêche d’appliquer directement les critères de Rust
- Même si des développeurs de bibliothèques C/C++ créent des bibliothèques sûres et robustes, les très nombreux programmes C qui les utilisent peuvent facilement introduire des problèmes de sûreté mémoire par mauvais usage des API
- Cette différence ne concerne pas seulement
curl, mais quasiment toutes les bibliothèques C/C++ ainsi que les bibliothèques standard de ces deux langages
- Des comparaisons brutes de chiffres, comme le nombre de CVE par ligne de code entre Rust et C/C++, peuvent induire en erreur lorsqu’on cherche à évaluer la sûreté mémoire
1 commentaires
Avis sur Lobste.rs
C’est peut-être une question naïve, mais si beaucoup de problèmes en C/C++ viennent d’un comportement indéfini, pourquoi ne pas simplement le définir ?
Premièrement, il y a des vestiges historiques dont plus personne ne se soucie vraiment, et qu’on pourrait donc « simplement définir » ; comme l’a dit @fanf, un travail est en cours. Par exemple, un fichier source contenant un littéral de chaîne non terminé relève réellement d’un comportement indéfini en C.
Deuxièmement, certaines choses pourraient être définies, mais avec un coût en performances. L’exemple classique est le débordement d’entier signé : si on le définissait simplement comme circulaire, ce ne serait plus un comportement indéfini, mais les compilateurs ne pourraient plus faire d’optimisations fondées sur l’hypothèse que « cela n’arrive jamais ». Il y a beaucoup de gens issus du monde des compilateurs dans les comités, et ils ont tendance à être obsédés par les benchmarks, donc cela ne sera probablement pas corrigé facilement. Cela dit, il y a quand même du mouvement ; par exemple, P2723 propose d’initialiser implicitement à zéro toutes les variables locales qui auraient autrement été non initialisées en C++.
Troisièmement, il y a des cas qu’il est difficile de définir de manière raisonnable. Un bon exemple est le use-after-free. À moins d’imposer à tout le monde un lourd système de capabilities runtime comme Fil-C, ou d’ajouter des annotations de durée de vie à la Rust dans toute la langue, on voit mal comment limiter l’éventail des comportements possibles après un use-after-free. On pourrait dire : « en cas de use-after-free, on accède à la mémoire présente à cet emplacement au moment donné, ou bien on segfault / on s’arrête », mais cela n’aide personne. Cela reste dangereux, cela produit toujours des CVE, et on ne peut toujours pas dire de manière utile ce que le programme peut ou ne peut pas faire ensuite : c’est juste un comportement indéfini sous un autre nom.
Malheureusement, comme cette troisième catégorie a un impact de loin le plus important, « simplement définir » une partie des cas est utile, mais ne change pas fondamentalement la situation d’ensemble.
À ma connaissance, la bibliothèque n’a pas encore vraiment été traitée dans son ensemble, mais les fonctions qui prennent un paramètre de taille ont été modifiées pour se comporter raisonnablement avec des pointeurs nuls. C’était lié à un changement du langage autorisant l’addition de 0 à un pointeur nul. Il y a beaucoup de fonctions qu’on pourrait corriger de façon similaire, mais pour
getenv(), il vaudrait probablement mieux se coordonner avec POSIX.Ces gains de performance sont presque tous marginaux, et au mieux minimes. S’il existe une fonction qui appelle
rm -rf /mais qui, en pratique, n’est jamais appelée, et qu’on crée un appel de pointeur de fonction contenant un comportement indéfini, alors le compilateur est techniquement autorisé à générer du code qui appelle inconditionnellement la fonction qui efface le disque. Au fond, ce n’est qu’une mauvaise conception de la spécification et un héritage du passé.for (int ii = 0; ii < something; ii++)peut ignorer la possibilité desomething == INT_MAXparce que le débordement d’entier signé est indéfini, ce qui permet diverses transformations de boucle.En Rust, l’équivalent est réparti entre fonctions sûres et fonctions
unsafe. Les fonctions sûres peuvent être un peu plus lentes, et les fonctionsunsafepermettent un comportement indéfini si elles sont mal utilisées. Voiri32::wrapping_add()eti32::unchecked_add().Si le C permettait de marquer certaines fonctions comme
unsafeet ajoutait une notation autorisant leur usage dans des zones spécifiques, on pourrait commencer à définir des variantes sûres. Mais à un moment, l’effort pour changer le C — et, plus important encore, pour changer l’état d’esprit de ceux qui le contrôlent — n’est plus proportionné à l’objectif, et il devient plus simple de chercher un langage plus adapté à ce but.En C, si l’on passe à
freeun pointeur vers un objet du tas puis qu’on accède à cet objet, c’est un comportement indéfini. Dans CHERIoT, ce cas est défini comme provoquant un trap, mais cela n’est possible que parce que nous avons créé le matériel qui le permet. Le standard doit prendre en charge des matériels très variés, donc la question est : que définir exactement ?Il existe essentiellement deux approches. La première consiste à retarder la libération et à dire que l’objet ne disparaît pas tant qu’il existe encore des pointeurs vers lui. Cela nécessite quelque chose qui ressemble à un ramasse-miettes, avec une surcharge inacceptable pour de nombreux usages du C. La seconde consiste à définir un système de types capable de connaître l’emplacement de tous les pointeurs vers un objet et de les invalider. Rust a choisi cette seconde approche ; c’est pourquoi, en Rust, implémenter des structures de données qui ne sont pas des arbres nécessite
unsafeou des fonctionnalités de la bibliothèque standard qui utilisentunsafe. On peut intégrer ce genre de chose à la conception d’un langage, mais il est presque impossible de l’ajouter après coup.Les erreurs de limites sont similaires. Sur les systèmes CHERI, les limites d’un objet ou d’un sous-objet font intrinsèquement partie du pointeur, donc un accès hors limites déclenche un trap. Sur d’autres plateformes, un pointeur n’est qu’un mot contenant une adresse. Après des opérations arithmétiques, il n’y a plus de moyen de remapper vers l’objet d’origine, donc la question devient : d’où viennent les limites ? Des outils comme AddressSanitizer stockent les limites dans des structures séparées et imposent des vérifications lors de l’arithmétique sur les pointeurs, mais la surcharge mémoire et performance est telle qu’en production il vaut bien mieux utiliser Java qu’un C avec ASan activé, et on écrira probablement aussi le code plus vite.
Je pensais que le déréférencement d’un pointeur nul était un comportement bien défini.
Il y a un point dans cet article qui me gêne.
Un SEGFAULT est une attaque par déni de service, au même titre qu’une panic.
Les deux appartiennent à la même catégorie d’erreurs, et quand on pense à la sûreté mémoire, on pense généralement plutôt à des choses comme l’écrasement de pile, la corruption de données ou la corruption de code. Ce genre de choses est bien, bien plus difficile en Rust, et on peut aussi les rendre plus difficiles jusqu’à un certain point en C.
L’article entier semblait surtout dire que le système de types du C est médiocre. En C++, on peut empêcher ce genre d’erreurs, et en C aussi, en utilisant l’attribut
nonnullde GCC pour faire remonter le passage deNULLà une fonction jusqu’au rang d’erreur de compilation.Personnellement, je pense qu’un accès hors limites aurait été un exemple meilleur et plus représentatif.
Une panic est une vérification de sécurité intégrée au programme ; elle se produit de manière fiable et son comportement est clairement défini.
Un segfault, c’est une opération mémoire invalide que le système d’exploitation a détectée, et cela ne se produit que pour des adresses situées hors des pages présentes dans l’espace mémoire virtuel du programme. Beaucoup de bugs de segfault peuvent donc être manipulés vers une forme d’exécution de code arbitraire.
Les résultats peuvent sembler similaires dans les cas normaux, mais ce sont fondamentalement deux choses différentes.