7 points par GN⁺ 2025-07-25 | 1 commentaires | Partager sur WhatsApp
  • 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

 
GN⁺ 2025-07-25
Avis Hacker News
  • C’est arrivé dans mon équipe chez Dropbox : écrire dans une structure de données sur un serveur Go sans synchronisation faisait partie d’une sorte de rite de passage pour les nouveaux ingénieurs, qui provoquaient alors régulièrement des segfaults
    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é
  • Swift est en train d’essayer de résoudre ce problème, mais dans le monde réel il existe déjà énormément de code non sûr, donc l’évolution est très lente et douloureuse
  • Je me pose une question : en général, il est bien indiqué dans la spécification de Go que les structures de base comme map ne sont pas thread-safe et qu’il faut faire attention lorsqu’on les modifie
    J’aimerais entendre plus en détail ce qui s’est passé chez Dropbox
  • Je veux souligner que l’expression « memory safety au sens de Rust ou Java » n’est pas ici une définition terminologique rigoureuse
    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
  • Pour prendre un peu de recul, il faudrait se demander combien il existe réellement de cas déformés où Go n’est pas memory-safe, ou quelle est la probabilité qu’un programme Go ne le soit pas en pratique
  • Java non plus n’est pas memory-safe au sens de Rust
  • Ce sujet revient souvent, un peu comme les problèmes récurrents de soundness holes en Rust ; ce n’est certainement pas un faux problème, mais la probabilité de tomber dessus par hasard reste assez faible
    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 map ou des slice concernent surtout les slice, 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
  • J’ai déjà passé plusieurs mois à traquer un bug de data race en Go
    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
  • Les mainteneurs de Rust considèrent eux aussi les soundness holes comme des bugs
    Par exemple, cette issue demande un gros refactoring du compilateur et prend donc beaucoup de temps
  • Uber dit que les programmes Go « exposent 8 fois plus de concurrence » que des microservices Java ; je me demande ce que cela signifie ici d’utiliser « concurrence » comme un nom dénombrable
  • Zig prétend lui aussi offrir la memory safety, mais il n’a pas de concept équivalent aux types Send/Sync de Rust
    En 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
  • Même un programme Zig monothread compilé en ReleaseSafe n’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ée
  • La prétention de Zig à la memory safety relève presque de la blague
    Bien 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
  • Dans du vrai code, sauf si c’est conçu de manière malveillante, je n’ai jamais vu de code Go vulnérable à cause d’une data race
    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
  • J’ai pourtant déjà vu du code Go réellement vulnérable à cause de data races
  • J’ai le sentiment que la douleur de la maintenance est bien plus grande que celle des CVE
    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é
  • Si la memory safety est importante, c’est parce que la plupart des CVE des programmes C proviennent de bugs de memory safety
    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
  • Ce qui compte vraiment, c’est ce qu’un thread peut faire
    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
  • Les problèmes typiques de memory safety en C ont de fortes chances de mener à de la RCE (exécution de code à distance)
    À 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
  • Même si un CVE est plus grave, une corruption de données ou un crash causé par un bug de threading reste un bug qu’il faut bien que quelqu’un trie, analyse et corrige
  • La triste réalité, c’est que la plupart des langages utilisant les threads fournissent par défaut des variables globales et un accès illimité à la mémoire partagée
    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
  • Rust est un exemple représentatif de langage moderne qui peut intégrer la thread safety dans son système de types
    À 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::scope et autres, ce qui rend le travail avec les threads très confortable
  • Le message passing peut provoquer davantage de problèmes logiques (race conditions, interblocages, etc.) que le partage mémoire, donc ce n’est pas une solution miracle
  • En Go, on a plutôt tendance à mettre l’accent sur la communication entre goroutine (avec des channels, etc.) que sur le partage direct de mémoire
    Voir ce document
  • Même en faisant passer des objets entre goroutine via des channels, Go n’a pas de notions comme les types sendable, la propriété, ou les références en lecture seule, donc il n’est pas facile d’écrire du code sûr
    Exemple concret :
    func processData(lines <-chan []byte) {
     for line := range lines {
      fmt.Printf("processing line: %v\n", line)
     }
    }
    
    func main() {
     lines := make(chan []byte)
     go processData(lines)
    
     var buf bytes.Buffer
     for range 3 {
      buf.WriteString("mock data, assume this got read into the buffer from a file or something")
      lines <- buf.Bytes()
      buf.Reset()
     }
    }
    
    Dans ce code, buf.Bytes() transmet une référence directe à la mémoire interne, et l’appel à Reset() réutilise la backing memory ; processData et main accèdent donc simultanément à la même mémoire, ce qui provoque une data race
    En 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 ici
    Les 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
  • Dans les vrais programmes golang, le modèle « communiquer par le partage » produit en masse des problèmes logiques, et le partage mémoire finit donc par être courant
    Autrement dit, les races « sûres » ou les deadlocks « sûrs » deviennent en réalité plus fréquents
  • Les discussions sur les bugs de concurrence ont souvent tendance à ignorer le fait que, dans la plupart des applications, la majorité des bugs vraiment importants viennent plutôt d’une mauvaise utilisation des verrous, des transactions ou de l’isolation transactionnelle dans la base de données
    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 UPDATE dans un SELECT, on peut toujours avoir des races
    Même si une application Rust n’utilise jamais unsafe, les races existent toujours selon la base de données
  • Le terme « memory safety » est apparu à l’origine pour expliquer un concept complexe, mais avec le temps son sens s’est élargi ou au contraire resserré
    Go 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
  • Si la partie importante du terme memory safety est la « confusion de type » (type confusion), alors Go n’est pas exempt non plus
    L’exemple donné dans l’article montre qu’en traitant à tort un int comme un pointeur, on peut facilement provoquer une corruption mémoire
    La démo utilise volontairement 42 pour provoquer un segfault, mais avec une vraie adresse mémoire, on obtiendrait une véritable corruption
  • Une data race permet à un programme d’entrer dans un état que la spécification du langage ne reconnaît pas, par exemple un arrêt forcé avec SIGSEGV, ce qui constitue une violation de la memory safety
    Par conséquent, un langage dans lequel des data races sont possibles ne peut pas être qualifié de memory-safe
  • Comme dans les exemples donnés dans l’article, un torn read sur un fat pointer via confusion de type, ou une écriture hors limites (out-of-bounds write) due à un torn read sur un slice, sont bel et bien réalisables
    Je doute qu’on puisse encore qualifier cela de memory-safe
  • Le fait que les termes évoluent et changent de sens est fréquent aussi bien en mathématiques qu’en physique
    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
  • Si l’on suit la définition de l’auteur, j’aimerais comprendre sur quoi repose l’idée que Java ne serait pas memory-safe
    Je demande un exemple concret
  • Go lui-même reste officiellement flou sur la définition de la memory safety
    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
  • La définition de la memory safety évolue elle aussi légèrement avec le temps
    À 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