Comment un bug a été découvert dans le compilateur ARM64 de Go
(blog.cloudflare.com)- 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) etp(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
incgode la structuremdu 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.Receivede 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 :
- une preemption asynchrone se produit entre les deux instructions
ADD - une routine de stack unwinding est déclenchée par le GC ou une autre cause
- une position inhabituelle du pointeur de pile est explorée et une mauvaise adresse de fonction est interprétée
- le runtime plante
- une preemption asynchrone se produit entre les deux instructions
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
Commentaire Hacker News
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 LDRRSP. Si le correctif n’a été appliqué que côté compilateur, le même problème pourrait subsister ailleurs dans l’assembleur aarch64.SPatomiquement avec un seuladd. 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.signals).sscanfdans 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.