- La sûreté mémoire et la sûreté des threads ne sont pas des concepts que l’on peut séparer, et sans sûreté des threads il est impossible d’atteindre une véritable sûreté mémoire
- Dans le cas d’un langage non thread-safe comme Go, de simples problèmes de threads peuvent suffire à briser la sûreté mémoire
- Certains langages comme Java assurent la sûreté au niveau du langage grâce à un modèle mémoire de concurrence qui traite même les data races comme un comportement défini
- Go est vulnérable aux data races, et il existe des cas réels d’atteinte à la sûreté mémoire
- La propriété réellement importante à traiter est l’absence d’Undefined Behavior (comportement non défini)
Sans sûreté des threads, on ne peut pas garantir la sûreté mémoire
Confusion de concepts : sûreté mémoire vs sûreté des threads
- La sûreté mémoire fait beaucoup parler d’elle ces derniers temps, mais sa définition reste souvent floue
- Traditionnellement, on parle de sûreté mémoire pour désigner des langages qui empêchent les accès mémoire de type use-after-free ou out-of-bounds
- À l’inverse, la sûreté des threads désigne un programme sans bug de concurrence, et les deux notions sont souvent traitées séparément
- L’auteur estime que cette distinction est peu utile en pratique et insiste sur le fait que ce que nous voulons réellement, c’est l’absence d’Undefined Behavior (UB)
Atteinte à la sûreté mémoire via une data race : l’exemple de Go
- Pour montrer les problèmes créés par le traitement séparé de la sûreté mémoire et de la sûreté des threads, l’auteur prend l’exemple du langage Go
- Go est classé parmi les langages memory-safe, mais dans un programme comme celui ci-dessous, une simple data race peut déjà provoquer une erreur mémoire
modifier globalVar de façon répétée avec des valeurs de types différents (Int, Ptr), pendant qu’une autre goroutine le lit et appelle une méthode
- Comme les deux threads mettent à jour séparément les deux pointeurs internes de
globalVar (données, vtable), une lecture intermédiaire peut observer un état mélangé et provoquer un accès mémoire invalide
- Le programme finit ainsi par tenter de référencer une mauvaise adresse (par exemple
0x2a, soit 42 en hexadécimal), puis s’arrête avec une erreur
- Le même phénomène peut se produire avec les interfaces, slices, etc. de Go, car plusieurs champs ne sont pas mis à jour atomiquement
Comment d’autres langages traitent la concurrence et la sûreté mémoire
- D’autres langages comme Java peuvent eux aussi avoir des data races, mais appliquent un modèle mémoire de concurrence défini qui garantit que le programme ne brise pas le langage lui-même
- Exemple : Java conçoit soigneusement son modèle mémoire pour éviter qu’un environnement multithread n’aboutisse à des erreurs d’exécution fatales (comme un segfault forcé)
- La plupart des langages contrôlent les problèmes de concurrence de l’une des deux façons suivantes
- Définir un modèle mémoire garantissant un comportement cohérent pour tous les programmes concurrents (au prix de limitations pour les optimisations du compilateur et d’une implémentation plus complexe)
- Java, C#, OCaml, JavaScript, WebAssembly, etc.
- Interdire la majorité des data races grâce à un système de types puissant, en ne traitant de manière sûre qu’un petit nombre d’exceptions (Rust,
strict concurrency de Swift)
- Go ne suit aucune de ces deux approches
- Il ne garantit la sûreté mémoire qu’en l’absence de data races
- Il existe un outil de détection des data races, mais dans les programmes réels il est difficile de vérifier tous les cas par des tests
- Des travaux de recherche et des retours du terrain ont déjà signalé de nombreux cas concrets de violation de la sûreté mémoire
Le modèle mémoire de Go et les problèmes de documentation
- La documentation officielle du modèle mémoire de Go indique que la plupart des races ont des conséquences limitées, mais n’explique pas clairement que certaines data races peuvent produire des résultats sans aucune borne
- Certains affirment qu’il est comparable à Java ou JavaScript, mais ces deux langages ont fait bien plus d’efforts que Go pour assurer la sûreté en contexte concurrent
- Ce n’est que dans certaines sections détaillées de la documentation qu’il est mentionné, de manière limitée, que certaines data races peuvent provoquer un comportement totalement non défini
Conclusion : l’absence d’Undefined Behavior (UB) est le véritable objectif
- En pratique, la propriété que les utilisateurs veulent vraiment est que le programme ne brise pas le langage lui-même (absence d’UB)
- Les différentes vulnérabilités de sécurité provoquées par des atteintes à la sûreté mémoire existent parce que de l’UB s’est effectivement produit
- Dès qu’un UB survient, tout comportement ultérieur devient imprévisible, et un attaquant peut en tirer parti
- La différence essentielle entre les langages « sûrs » et « non sûrs » tient à la possibilité qu’un UB se produise
- Plutôt que de découper en sous-catégories comme sûreté mémoire, sûreté des threads ou sûreté des types, le vrai point clé est de savoir si de l’UB peut se produire ou non
- En réalité, la sûreté existe sur un spectre : Go est plus sûr que C, mais ne garantit pas une sûreté complète
- Il est très difficile de « prouver » la sûreté réelle de Go à partir des données disponibles, et il est important de bien comprendre les conséquences parfois contre-intuitives des choix faits par chaque langage
1 commentaires
Avis Hacker News
Swift a le même problème ; j’ai même déjà écrit un programme montrant à quel point Swift peut facilement provoquer des segfaults lorsqu’il accède à une structure de données partagée
Dire que Go est memory-safe au même titre que Rust ou Java est donc un peu exagéré
mapne sont pas thread-safe et qu’il faut faire attention lorsqu’on les modifieJ’aimerais entendre plus en détail ce qui s’est passé chez Dropbox
La memory safety est moins un concept de PLT (théorie des langages de programmation) qu’un terme de sécurité logicielle
Au final, les programmeurs Go connaissent très bien cette différence, et c’est pour cela que Go part du principe : « ne communiquez pas en partageant, partagez en communiquant »
Bien sûr, dans la réalité, ce concept n’a pas été pleinement réalisé, et tout le monde comprend aujourd’hui que Go implique aussi beaucoup de partage et de synchronisation
En pratique, après des années à faire tourner du Go, j’ai rarement vu ce type de bug se produire réellement
Uber a publié une analyse détaillée de bugs apparus dans du code Go, et cet article contient un tableau montrant à quelle fréquence ces problèmes surviennent en pratique
Dans Go, la plupart des problèmes d’accès concurrent à des
mapou dessliceconcernent surtout lesslice, et supposent un phénomène de « torn read », donc ce n’est pas très fréquent en réalitéSi les gens évitent généralement bien ces problèmes, c’est probablement parce qu’ils sont en général assez prudents et comprennent bien les risques de réassigner des variables dans un contexte d’accès concurrent
Le langage fournit des atomics, des channel et des mutex ; en pratique, les mauvais usages en accès concurrent sont donc rares, et il y a aussi le race detector, qui permet de trouver rapidement ce genre de problème
Même s’il y a un coût de performance, je pense qu’un problème de torn read reste quelque chose qu’on peut simplement corriger, et ce n’était pas un gros sujet dans le code Go en production que j’ai vu
Vidéo liée
Même le race detector ne trouvait rien, et personne ne comprenait ce qui se passait
Au final, le compteur de boucle débordait, répétait le même calcul un très grand nombre de fois, et certaines requêtes prenaient parfois 3 minutes au lieu de 100 ms
C’est en production, via
perf, qu’on a fini par détecter indirectement le problème, et mon expérience de débogage en tant que développeur plateforme a beaucoup aidé l’équipeÀ force d’être exposé à tant de cas de race en Go, j’en suis personnellement à souhaiter que Rust soit adopté partout
Par exemple, cette issue demande un gros refactoring du compilateur et prend donc beaucoup de temps
Send/Syncde RustEn pratique, il y a encore peu de code Zig concurrent, donc le problème n’a pas encore vraiment éclaté, mais je pense que lorsque les fonctionnalités async seront davantage utilisées, plusieurs problèmes surgiront d’un coup
ReleaseSafen’est pas totalement à l’abri d’un risque de corruption mémoire dans tous les modes d’optimisation ; par exemple, lorsqu’on déréférence un pointeur dont la durée de vie d’une variable locale est déjà terminéeBien sûr, cela réduit les bugs par rapport à C, mais c’est aussi vrai pour C++, et pourtant personne ne dit que C++ est memory-safe
Bien sûr, cela ne veut pas dire que le risque est absolument nul, mais cela suggère qu’en matière de sécurité des applications Go, ce n’est probablement pas un sujet prioritaire
À l’inverse, pour le code C/C++, 60 à 75 % des vulnérabilités réelles proviennent de problèmes de memory safety
La memory safety est elle aussi un continuum, et j’ai l’impression qu’au-delà d’un certain niveau, le gain marginal diminue
Même un bug inexploitable reste un bug qu’il faut corriger
On passe bien plus de temps en maintenance qu’au développement initial, donc si l’on peut réduire cette maintenance, cela vaut la peine même si le lancement initial est retardé
En revanche, dans Go, la thread safety n’est pas une cause majeure de CVE
Il y a une base théorique, mais dans la réalité ce n’est pas un sujet très saillant
Lorsqu’on partage la mémoire, si une structure de données est corrompue, cela peut entraîner des comportements non sûrs ou incorrects dans d’autres threads
Par exemple, si un thread modifie la taille d’un vecteur pendant qu’un autre y accède, une opération sûre en exécution séquentielle devient dangereuse en concurrence
Go n’y échappe pas non plus
À l’inverse, si un problème de thread safety se limite à un segfault, il ne s’agit peut-être que d’une simple attaque par DoS (déni de service)
Une race condition peut déboucher sur une attaque plus puissante, mais elle est bien plus difficile à déclencher
C’est la principale cause de corruption de données et de races
Dans de nombreuses situations, un modèle à base de processus est meilleur que les threads pour la concurrence, mais il a l’inconvénient d’être trop lourd
Si faire passer toutes les données nécessaires à chaque thread par message passing était le comportement par défaut, je pense que la plupart de ces problèmes disparaîtraient
Cela dit, comme la plateforme nous laisse la liberté d’utiliser des variables globales et de la mémoire partagée, il suffit aussi de choisir de ne pas s’en servir
À l’origine, l’objectif de Rust n’était pas d’être un langage système memory-safe, mais un langage système thread-safe ; la memory safety en a découlé naturellement
En Rust, on peut utiliser la concurrence structurée avec
thread::scopeet autres, ce qui rend le travail avec les threads très confortableVoir ce document
Exemple concret : Dans ce code,
buf.Bytes()transmet une référence directe à la mémoire interne, et l’appel àReset()réutilise la backing memory ;processDataetmainaccèdent donc simultanément à la même mémoire, ce qui provoque une data raceEn Rust, ce code ne compilerait même pas, car il créerait deux références mutables ; le langage forcerait soit un transfert de propriété, soit une copie
En Go, c’est facile de se tromper :
bytes.Buffer.ReadBytes("\n")ou.String()renvoient des copies et sont donc sûrs, mais.Bytes()est dangereuse de la manière montrée iciLes channels de Rust empêchent fondamentalement ce problème grâce aux notions de propriété et de transfert, alors que Go n’a pas ces garde-fous
Au final, cela me donne l’impression d’être plus lent qu’un mutex et plus difficile à utiliser correctement pour un débutant en Go
Autrement dit, les races « sûres » ou les deadlocks « sûrs » deviennent en réalité plus fréquents
En théorie des langages, l’approche race-free de Rust peut sembler séduisante, mais dans les applications réelles, toutes les données importantes finissent de toute façon dans un SGBDR, et si l’on oublie par exemple
FOR UPDATEdans unSELECT, on peut toujours avoir des racesMême si une application Rust n’utilise jamais
unsafe, les races existent toujours selon la base de donnéesGo est structuré de façon à presque interdire les bugs de corruption mémoire, comme le montre l’absence d’exploits réels
Si l’on suit l’argument de cet article, la plupart des langages de haut niveau — à l’exception de Java selon le texte — cesseraient eux aussi d’être memory-safe
Rust est peut-être « plus » sûr que Go, mais la « memory safety » n’est pas un spectre continu ; c’est un concept binaire de réussite ou d’échec
Si l’on veut affirmer qu’un langage est memory-unsafe, il faut nécessairement montrer un POC
type confusion), alors Go n’est pas exempt non plusL’exemple donné dans l’article montre qu’en traitant à tort un
intcomme un pointeur, on peut facilement provoquer une corruption mémoireLa démo utilise volontairement
42pour provoquer un segfault, mais avec une vraie adresse mémoire, on obtiendrait une véritable corruptionSIGSEGV, ce qui constitue une violation de la memory safetyPar conséquent, un langage dans lequel des data races sont possibles ne peut pas être qualifié de memory-safe
out-of-bounds write) due à un torn read sur un slice, sont bel et bien réalisablesJe doute qu’on puisse encore qualifier cela de memory-safe
Pour éviter ce genre de problème, on donne parfois des noms de personnes, comme « courbure de Gauss » (
Gaussian Curvature) ou « intégrales de Riemann » (Riemann Integrals)Il existe aussi des cas où « le sens initial reste plus étroit, puis un sens plus large s’ajoute », comme avec le « groupe de Galois » (
Galois Group)La memory safety ne fait pas exception
Je demande un exemple concret
Dans la FAQ, certaines réponses mentionnant la memory safety ou à propos des unions laissent entendre que Go est memory-safe, mais sans préciser clairement ce que cela signifie
Dans une présentation de 2012, Rob Pike disait « Not purely memory safe », sans même définir ce que signifie « purely »
La documentation du race detector de Go reste elle aussi floue sur la définition de « safe » (document d’exemple)
À l’extérieur, on voit au contraire souvent des affirmations beaucoup plus tranchées présentant Go comme un « memory-safe programming language »
On peut citer par exemple la documentation sécurité de fly.io, ou encore la page de memorysafety.org qui classe Go parmi les langages memory-safe
Pourtant, ce même document décrit aussi les « Out of Bounds Reads and Writes » comme des problèmes de memory safety, et l’erreur Go signalée dans le billet entre justement dans cette catégorie
À tout le moins, il me semble nécessaire que Go et sa communauté clarifient le sens exact de « memory safety »
Tant que de tels cas existent, il serait préférable de ne pas qualifier Go de langage memory-safe sans explication
À l’époque où Go a été créé, l’idée dominante était plutôt que « s’il y a un garbage collector, alors c’est memory-safe », et comparé à C/C++, c’est effectivement beaucoup plus sûr