2 points par GN⁺ 2025-10-09 | 1 commentaires | Partager sur WhatsApp
  • Cloudflare a découvert, lors de la surveillance d’un trafic à grande échelle, un rare bug de condition de course dans le compilateur Go fonctionnant sur la plateforme arm64
  • Ce bug se manifeste par une mise en panic inattendue du service ou par des erreurs d’accès mémoire lors du stack unwinding
  • L’analyse de la cause a confirmé que le problème survenait entre la preemption asynchrone du runtime Go et deux instructions d’ajustement du pointeur de pile générées par le compilateur
  • Un code minimal de reproduction a démontré qu’il s’agissait bien d’un problème du runtime Go lui-même, révélant une condition de course d’une seule instruction où le pointeur de pile est modifié de manière incomplète
  • Le problème a été corrigé dans les versions go1.23.12, go1.24.6, go1.25.0 ; la nouvelle approche évite les manipulations du pointeur de pile qui ne peuvent pas être modifiées instantanément, bloquant ainsi fondamentalement la race condition

Analyse du bug du compilateur Go ARM64 découvert chez Cloudflare

Les datacenters de Cloudflare traitent 84 millions de requêtes HTTP par seconde dans plus de 330 villes à travers le monde, et ce type d’environnement à très grande échelle a la particularité d’exposer fréquemment même les bugs les plus rares. Cet article analyse en détail, exemples concrets à l’appui, un problème de condition de course dans le code généré par le compilateur Go sur la plateforme arm64.

Enquête sur un phénomène de panic étrange

  • Dans le réseau Cloudflare, des services configurent dans le kernel le traitement du trafic de produits comme Magic Transit et Magic WAN
  • Sur les machines arm64, des messages de fatal panic étaient détectés de manière rare mais répétée par le système de supervision
  • L’analyse initiale a montré qu’une violation d’intégrité était détectée lors du stack unwinding (dans un ancien code utilisant le motif panic/recover, les panic se produisaient fréquemment)
  • La structure panic/recover a été retirée temporairement pour réduire la fréquence des panic, mais par la suite des fatal panic suspects ont commencé à se produire plus souvent
  • Il a donc été jugé nécessaire de mener une analyse approfondie au-delà du simple suivi de motifs récurrents

Vue d’ensemble du runtime Go et des structures de données du scheduler

  • Go adopte une architecture de scheduling M:N avec un ordonnanceur léger en espace utilisateur (plusieurs goroutines mappées sur un petit nombre de threads kernel)
  • Les structures centrales du scheduler s’articulent autour de g (goroutine), m (machine/thread kernel) et p (processor)
  • Les échecs de stack unwinding ou les erreurs d’accès mémoire surviennent lorsque le pointeur de pile ou l’adresse de retour évolue de manière anormale

Cause structurelle des erreurs pendant le stack unwinding

  • L’analyse de plusieurs backtraces a montré que tous les cas survenaient durant le stack unwinding dans la fonction (*unwinder).next
  • Dans un cas, l’adresse de retour était nulle, ce qui faisait considérer la pile comme corrompue et provoquait un arrêt fatal ; dans un autre, une erreur de segmentation se produisait lors d’un accès au champ incgo de la structure m du scheduler Go à l’intérieur d’une stack frame
  • Le crash se produisait très loin du point réel où le bug survenait, ce qui compliquait fortement l’identification de la cause

Motifs observés et lien avec la bibliothèque Go Netlink

  • L’examen des stack traces a montré que les crashs se concentraient tous au moment où une preemption se produisait dans la fonction NetlinkSocket.Receive de la bibliothèque Go Netlink
  • Deux hypothèses ont alors été formulées
    • un bug lié à l’usage de unsafe.Pointer dans Go Netlink
    • un bug dans la preemption asynchrone et le stack unwinding du runtime Go lui-même
  • Un audit du code n’a révélé aucun motif direct de corruption mémoire, ce qui a conduit à soupçonner que le cœur du problème se situait dans le runtime et dans la stratégie de gestion de la pile

Preemption asynchrone et condition de course

  • Introduite à partir de Go 1.14, la preemption asynchrone envoie un signal (SIGURG) au thread OS pour créer de force un point de scheduling dans les goroutines qui s’exécutent longtemps
  • Si cette preemption se produit entre deux instructions assembleur qui ajustent le pointeur de stack frame, le pointeur de pile reste dans un état intermédiaire
  • Lors du déroulage de la pile pour le garbage collection, la gestion des panic ou la génération de stack traces, une mauvaise position peut être lue, entraînant une interprétation erronée d’adresses de fonctions ou de données

Création d’un code minimal de reproduction

  • En ajustant la taille d’allocation de la stack frame et en écrivant une fonction avec ajustement explicite de pile (big_stack) ainsi qu’un code appelant en permanence le garbage collector, la condition de course a pu être reproduite
  • Il a été confirmé qu’en assembleur, le pointeur de pile est ajusté par deux instructions ADD, et que si une preemption asynchrone se produit entre les deux, un crash survient pendant le stack unwinding
  • Le défaut était reproductible même avec du simple code de bibliothèque standard, prouvant qu’il s’agissait d’une vulnérabilité intrinsèque, à l’échelle d’une seule instruction, dans le code généré par le compilateur Go

Cause de la fenêtre de course au niveau du compilateur ARM64

  • En raison de la longueur fixe des instructions de l’architecture ARM64 et des limites sur les valeurs immédiates, l’ajustement du pointeur de pile peut nécessiter deux instructions ou plus
  • Dans la représentation intermédiaire interne (IR) de Go, cette contrainte de taille des immédiats n’est pas connue ; les instructions sont découpées uniquement lors de la génération finale du code machine
  • De ce fait, le retour de stack frame (ADD RSP, RSP) utilise deux instructions, créant une fenêtre de vulnérabilité d’une seule instruction face à la preemption
  • Or, l’unwinder dépend absolument de l’exactitude du pointeur de pile ; si l’exécution s’interrompt au milieu de l’opération, cela entraîne une mauvaise interprétation des valeurs et un échec fatal
  • Le déroulement réel du crash est le suivant :
    1. une preemption asynchrone se produit entre les deux instructions ADD
    2. une routine de stack unwinding est déclenchée par le GC ou une autre cause
    3. une position inhabituelle du pointeur de pile est explorée et une mauvaise adresse de fonction est interprétée
    4. le runtime plante

Correctif du bug et amélioration fondamentale

  • L’équipe Cloudflare a signalé le problème au dépôt officiel de Go avec un code minimal de reproduction et une analyse détaillée, et le correctif a été rapidement intégré et publié
  • À partir des versions go1.23.12, go1.24.6, go1.25.0, le runtime calcule d’abord l’offset complet dans un registre temporaire, puis modifie le pointeur de pile en une seule instruction, supprimant ainsi la vulnérabilité à la preemption
  • Le pointeur de pile est désormais toujours garanti dans un état valide, ce qui bloque structurellement la condition de course
LDP -8(RSP), (R29, R30)
MOVD $32, R27
MOVK $(1<<16), R27
ADD R27, RSP, RSP
RET

Conclusion et enseignements

  • Ce bug illustre un cas où la génération de code du compilateur sur une architecture donnée et la gestion de la concurrence (preemption asynchrone) sont entrées en collision d’une manière inattendue
  • C’est un cas particulièrement intéressant de condition de course au niveau instruction, extrêmement rare et n’apparaissant qu’à très grande échelle, mais retracée à partir de données réelles et d’un raisonnement rigoureux
  • Si vous exploitez des services basés sur les versions récentes de Go et l’architecture ARM64, il est important de mettre à niveau vers les versions concernées de Go

1 commentaires

 
GN⁺ 2025-10-09
Commentaire Hacker News
  • Une découverte vraiment impressionnante ; dès qu’on regarde le code assembleur, on se met à suivre la piste du débogage. En réalité, cette approche n’est pas forcément limitée à l’assembleur ; elle pourrait aussi fonctionner au stade IR, mais ce n’est pas le cas pour diverses raisons. Le fait de savoir lire l’assembleur ARM est un gros avantage. J’ai aussi envisagé une approche consistant à ajuster la taille de la pile avec des push ou pop pour réduire le nombre d’instructions, mais comme je ne sais pas exactement ce que le GC vérifie, je n’en suis pas certain. J’aimerais entendre d’autres avis.
    • En général, on utilise la pseudo-instruction ARM « LDR Rd, =expr ». Lorsqu’une constante ne peut pas être créée directement, on place cette constante à une position relative au PC puis on la charge dans un registre à partir du PC. Cela permet de transformer l’opération « ajouter une constante à SP » en 2 instructions exécutables, et il faut au total 12 octets : 8 octets de code et 4 octets de zone de données (pour une constante sur 17 bits). Documentation associée : explication de la pseudo-instruction LDR
    • C’est surprenant que ce bug n’ait pas été traité comme un cas particulier dans l’assembleur alors qu’il s’agit d’un cas atypique d’ajout immédiat à RSP. Si le correctif n’a été appliqué que côté compilateur, le même problème pourrait subsister ailleurs dans l’assembleur aarch64.
    • Cette étrange syntaxe avec un symbole dollar dans l’assembleur ARM n’est pas l’assembleur standard AArch64, et j’aurais aimé que l’article mentionne aussi la règle selon laquelle « la pile ne doit être déplacée qu’une seule fois ».
    • Dans des runtimes comme Java ou .NET, on définit explicitement des safepoints afin d’éviter qu’un changement de contexte se produise au milieu d’une séquence d’instructions.
    • La bonne solution semble être que le compilateur charge la constante dans un registre en deux étapes, puis ajuste SP atomiquement avec un seul add. Bien sûr, cela ajoute une instruction, mais l’atomicité est garantie. Sinon, on peut aussi faire l’opération dans un registre temporaire puis recopier le résultat.
  • Pour les personnes pressées, voici le lien vers le commit de correction : lien vers le commit golang/go
    • En regardant l’issue, je me suis demandé si l’équipe Go utilisait un bot de langage naturel, ou si elle se contentait de vérifier la présence du mot-clé « backport » dans les commentaires. Commentaire associé : github issue comment
  • Blog techniquement excellent, avec des explications si claires qu’on a presque l’impression d’être devenu plus intelligent en le lisant. C’était la première fois depuis longtemps que je replongeais dans l’assembleur depuis le x86, et pourtant c’était facile à suivre. Et avec une équipe comme ça, on a confiance dans sa capacité à résoudre ce genre de problème et à maintenir un haut niveau de qualité. J’avais aussi envisagé l’Ampere Altra pour faire évoluer des serveurs, mais comme on avait suffisamment d’espace, on a finalement choisi Epyc.
  • Je pense que s’il existait dans Go un mode qui exécute toutes les instructions en pas à pas et déclenche une interruption GC à chaque instruction, ce type de bug serait beaucoup plus facile à trouver.
  • Je me demande où les serveurs ARM64 sont utilisés. L’an dernier, il était question du lancement de serveurs Gen 12 basés sur AMD EPYC, mais il n’y avait aucune mention d’ARM64 ; aujourd’hui, ARM64 est pourtant utilisé en production.
    • Je ne travaille pas chez Cloudflare, mais à force de lire leur blog, je sais qu’en tenant compte notamment du secure boot, ils déploient déjà des machines Ampere en parallèle d’AMD depuis plusieurs années. L’objectif opérationnel semble être l’efficacité à l’edge, mais il peut aussi y avoir d’autres usages. Plus d’informations ici : article sur la conception des serveurs edge, Ampere Altra vs AWS Graviton2 et évaluation ARM de Qualcomm.
    • Il me semble me souvenir que Cloudflare héberge une partie de son calcul non-edge dans le cloud public, par exemple le control plane ; donc c’est possible.
  • Je pensais que Cloudflare utilisait aujourd’hui uniquement Rust à 100 % et du x86 (EPYC) ; c’est intéressant d’apprendre qu’ils utilisent aussi Go et ARM.
  • Comme toujours, les articles du blog Cloudflare sont de très bons contenus qui capturent l’essence de l’ingénierie sans magie d’infrastructure ni de ML. J’aimerais postuler là-bas un jour. Les bugs de compilateur sont plus fréquents qu’on ne le pense (dans le passé, j’en trouvais quelques-uns par an dans gcc), mais comme dans l’article, ce sont souvent des cas rares qui n’apparaissent qu’à très grande échelle. La plupart des gens n’opèrent tout simplement jamais à cette échelle.
    • Pourquoi ne pas postuler aujourd’hui ?
  • Il faut insister sur le fait que le pointeur de pile doit toujours être ajusté de manière atomique.
    • Les auteurs de la préemption ont probablement écrit le code en pensant au x86, où cela se fait atomiquement parce qu’une instruction peut embarquer la constante, puis, lors du portage vers ARM, la décomposition automatique à un niveau plus élevé a introduit ce bug. Ce n’est la faute de personne, mais le résultat n’est pas bon.
    • C’est exactement ce à quoi j’ai pensé immédiatement.
  • Je ne comprends pas très bien comment le thread machine a pu s’arrêter au milieu de deux instructions. Je me demande si ce genre de chose est possible en bare metal.
    • Go utilise des interruptions pour les notifications du GC.
    • Des signaux (signals).
  • À propos de la phrase « c’était un problème très amusant », il est certain que résoudre un problème aussi fondamental a dû être libérateur, mais tant qu’il n’était pas résolu, cela ne devait pas être amusant du tout. Ce genre de bug vous dévore tous vos nerfs. Comme personne n’imagine que la bibliothèque standard ou le compilateur puisse être en cause, on développe une culture où le développeur continue à soupçonner uniquement son propre code. J’ai moi-même déjà trouvé un bug dans une bibliothèque standard, et on ne commence à soupçonner le SDK qu’en tout dernier. Du coup, on perd tout son temps à regarder au mauvais endroit. Et quand, en plus, comme ici, c’est une race condition, c’est difficile à reproduire : on croit toujours qu’elle a disparu, puis elle réapparaît.
    • Ce commentaire ajoute une expérience personnelle similaire, mais en cherchant à contester le plaisir ressenti par l’auteur, il atténue un peu l’émotion du propos. Les gens ne trouvent pas tous les mêmes choses amusantes.
    • Certaines personnes prennent plaisir à un débogage très particulier que d’autres trouveraient pénible. Ce qui est frustrant pour l’un peut être réjouissant pour l’autre.
    • Je pense que ce que l’auteur voulait probablement dire, ce n’était pas « amusant » au sens de funny, mais plutôt « satisfaisant ». J’ai déjà traqué sous pression un bug de sscanf dans la toolchain Ubuntu GCC ARM ; ce n’était pas amusant sur le moment, mais une fois le problème précisément identifié et le test de régression écrit, c’était vraiment satisfaisant.
    • Corriger un défaut profond procure un immense soulagement une fois le problème résolu. J’ai moi aussi souvent ressenti le plus grand plaisir en corrigeant des bugs côté compilateur ou CPU.
    • Dans un langage managé, lorsqu’un segfault survient sans utiliser quoi que ce soit de type Unsafe, j’y vois généralement un signal que le problème ne vient probablement pas de mon code.