Cómo se descubrió un bug en el compilador ARM64 de Go
(blog.cloudflare.com)- Cloudflare detectó un raro bug de condición de carrera (race condition) en el compilador de Go que funciona sobre la plataforma arm64 mientras monitoreaba tráfico a gran escala
- Este bug se manifestaba haciendo que el servicio entrara inesperadamente en pánico o produciendo errores de acceso a memoria durante el proceso de stack unwinding
- Durante el rastreo de la causa, confirmaron que el problema ocurría entre la preemption asíncrona del runtime de Go y dos instrucciones de ajuste del stack pointer generadas por el compilador
- Con un código mínimo de reproducción demostraron que el bug era un problema del propio runtime de Go, y revelaron la existencia de una condición de carrera de una sola instrucción en la que el stack pointer quedaba modificado de forma incompleta
- El issue fue corregido en las versiones go1.23.12, go1.24.6, go1.25.0, y el nuevo enfoque evita manipulaciones del stack pointer que no puedan cambiarse de inmediato, bloqueando de raíz la race condition
Análisis del bug del compilador Go ARM64 encontrado por Cloudflare
Los centros de datos de Cloudflare procesan 84 millones de solicitudes HTTP por segundo en más de 330 ciudades del mundo, y un entorno de tráfico a esa escala tiene la particularidad de exponer con frecuencia incluso bugs muy raros. Este artículo analiza en detalle, con un caso real, un problema de condición de carrera presente en código generado por el compilador de Go para la plataforma arm64.
Investigación de un fenómeno de pánico extraño
- Dentro de la red de Cloudflare se ejecutan servicios que configuran en el kernel el procesamiento de tráfico de productos como Magic Transit y Magic WAN
- En máquinas arm64, el sistema de monitoreo detectó de forma poco frecuente pero repetida mensajes de fatal panic
- El análisis inicial mostró que durante el stack unwinding se detectaba una violación de integridad; en código antiguo que usaba el patrón panic/recover, los pánicos ocurrían con frecuencia
- Se eliminó temporalmente la estructura panic/recover para reducir la frecuencia de los pánicos, pero luego empezaron a aparecer con más frecuencia fatal panic sospechosos
- A partir de eso se concluyó que hacía falta un análisis más profundo de la causa, más allá de solo rastrear patrones simples
Resumen de las estructuras de datos del runtime y scheduler de Go
- Go adopta una estructura de scheduling M:N con un scheduler ligero en espacio de usuario, mapeando múltiples goroutines sobre un pequeño número de hilos del kernel
- Las estructuras centrales del scheduler giran en torno a
g(goroutine),m(máquina/hilo del kernel) yp(processor) - Los fallos de stack unwinding o los errores de acceso a memoria pueden ocurrir cuando el stack pointer o la dirección de retorno cambian de manera anómala
Causa estructural del error durante el stack unwinding
- El análisis de varios backtraces mostró que todos los casos ocurrían durante el proceso de stack unwinding en la función
(*unwinder).next - En un caso, la return address era null, por lo que se interpretó como un stack anómalo y terminó en error fatal; en otro, se produjo un segmentation fault al intentar acceder al campo
incgode la estructuramdel scheduler de Go dentro del stack frame - El crash ocurría bastante lejos del punto real donde se originaba el bug, lo que volvía difícil rastrear la causa
Patrones observados y relación con la librería Go Netlink
- Al revisar los stack traces, confirmaron que los crashes se concentraban en el momento en que ocurría la preemption dentro de la función
NetlinkSocket.Receivede la librería Go Netlink - A partir de eso plantearon dos hipótesis
- Que fuera un bug originado por el uso de unsafe.Pointer en Go Netlink
- Que fuera un bug en la propia preemption asíncrona y el stack unwinding del runtime de Go
- Se realizó una auditoría del código, pero no se encontraron patrones directos de corrupción de memoria, por lo que se estimó que el núcleo del problema estaba en el runtime y en la estrategia de manejo del stack
Preemption asíncrona y condición de carrera
- La función de preemption asíncrona, introducida desde Go 1.14, envía una señal (SIGURG) al hilo del sistema operativo para crear por la fuerza un punto de scheduling en goroutines que llevan mucho tiempo ejecutándose
- Si esa preemption ocurre entre dos instrucciones de ensamblador que ajustan el puntero del stack, el stack pointer queda en un estado intermedio
- Cuando se hace stack unwinding para recolección de basura, manejo de pánicos o generación de stack traces, se termina leyendo una ubicación incorrecta y se interpretan mal direcciones de funciones o datos
Creación de un código mínimo de reproducción
- Al ajustar el tamaño de la asignación del stack frame y escribir una función que ajusta explícitamente el stack (
big_stack) junto con código que invoca el garbage collector de forma constante, lograron reproducir la condición de carrera - Efectivamente, en el código ensamblador el stack pointer se ajustaba con dos instrucciones
ADD, y si la preemption asíncrona ocurría entre ambas, se producía un crash durante el stack unwinding - El defecto pudo reproducirse usando únicamente código de la librería estándar, lo que demostró que se trataba de una vulnerabilidad a nivel de una sola instrucción inherente al código generado por el compilador de Go
Causa de la ventana de carrera a nivel compilador en ARM64
- Debido a la longitud fija de las instrucciones y a las limitaciones de valores inmediatos en la arquitectura ARM64, el ajuste del stack pointer puede requerir dos o más instrucciones
- La representación intermedia interna (IR) de Go no tiene en cuenta la longitud de esos inmediatos, y las instrucciones divididas solo se insertan al convertir al código máquina real
- Por eso, para devolver el stack frame (
ADD RSP, RSP) se usaban dos instrucciones, creando una ventana vulnerable de una sola instrucción ante la preemption - El unwinder necesita de forma absoluta que el stack pointer sea correcto, y si la ejecución se detiene a mitad de la instrucción compuesta, eso provoca interpretaciones incorrectas de valores y fallos fatales
- El flujo real del crash se compone así:
- Ocurre una preemption asíncrona entre las dos instrucciones
ADD - El routine de stack unwinding se ejecuta por GC u otra causa
- Se explora una posición extraña del stack pointer y se interpreta mal una dirección de función
- El runtime colapsa
- Ocurre una preemption asíncrona entre las dos instrucciones
Corrección del bug y mejora de fondo
- El equipo de Cloudflare reportó el problema al repositorio oficial de Go basándose en el código mínimo de reproducción y el análisis detallado, y el issue fue corregido y liberado rápidamente
- En las versiones posteriores a go1.23.12, go1.24.6, go1.25.0, primero se calcula el offset completo en un registro temporal y luego se cambia el stack pointer con una sola instrucción, eliminando la vulnerabilidad a la preemption
- Ahora se garantiza que el stack pointer siempre permanezca en un estado válido, por lo que la condición de carrera queda bloqueada estructuralmente
LDP -8(RSP), (R29, R30)
MOVD $32, R27
MOVK $(1<<16), R27
ADD R27, RSP, RSP
RET
Conclusión e implicaciones
- Este bug es un caso donde la generación de código del compilador para una arquitectura específica y la gestión de concurrencia (preemption asíncrona) chocaron de una manera inesperada
- Es un caso especialmente interesante por cómo se logró rastrear, con datos reales y razonamiento científico, una condición de carrera a nivel de instrucción extremadamente rara que solo aparece en entornos a gran escala
- Si operas servicios basados en la arquitectura ARM64 y en entornos recientes de Go, es importante actualizar a las versiones de Go relacionadas
1 comentarios
Comentarios en Hacker News
pushopopdel tamaño de la pila para reducir líneas de instrucciones, pero como no tenía claro exactamente qué valida el GC, no estaba seguro; le gustaría escuchar otras opiniones.LDR Rd, =expr; cuando se trata de una constante que no puede construirse directamente, se coloca la constante en una ubicación relativa al PC y luego se carga al registro con base en el PC. Con esto, el proceso de “sumar una constante aSP” puede cambiarse a 2 instrucciones ejecutables, y se necesitan 8 bytes de código y 4 bytes de datos (para una constante de 17 bits), en total 12 bytes. Documentación relacionada: explicación de la pseudoinstrucción LDRRSP; si el parche solo se aplicó del lado del compilador, el mismo problema podría seguir existiendo en otras partes del ensambladoraarch64.SPatómicamente con un soloadd; claro, eso agrega una instrucción, pero garantiza atomicidad. Otra opción sería operar con un registro temporal y luego volver a moverlo.gcc), pero muchas veces son casos raros que solo salen a la luz a gran escala, como en el artículo; la mayoría nunca llega a operar a esa escala.signals).funny), sino “satisfactorio” (satisfying). A mí también me tocó perseguir con una fecha límite encima un bug desscanfen el toolchain ARM de GCC para Ubuntu; no fue divertido en ese momento, pero después de aislar el problema con precisión y escribir una prueba de regresión, sí fue realmente satisfactorio.