2 puntos por GN⁺ 2025-10-09 | 1 comentarios | Compartir por WhatsApp
  • 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) y p (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 incgo de la estructura m del 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.Receive de 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í:
    1. Ocurre una preemption asíncrona entre las dos instrucciones ADD
    2. El routine de stack unwinding se ejecuta por GC u otra causa
    3. Se explora una posición extraña del stack pointer y se interpreta mal una dirección de función
    4. El runtime colapsa

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

 
GN⁺ 2025-10-09
Comentarios en Hacker News
  • Realmente da la impresión de ser un hallazgo increíble, y en cuanto vio el código ensamblador se puso a seguir la ruta de depuración; en realidad, este enfoque no necesariamente solo es posible en ensamblador, también podría funcionar en la etapa de IR, pero por varias razones no se hace así; poder leer ensamblador ARM es una gran ventaja. También consideró probar a hacer push o pop del 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.
    • Normalmente se usa la pseudoinstrucción ARM 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 a SP” 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 LDR
    • Sorprende que este bug no se haya tratado como un caso especial en el ensamblador para el caso peculiar de sumar un valor inmediato a RSP; si el parche solo se aplicó del lado del compilador, el mismo problema podría seguir existiendo en otras partes del ensamblador aarch64.
    • La expresión extraña con signo de dólar en la sintaxis del ensamblador ARM no es ensamblador estándar de AArch64, y habría sido bueno que el artículo también mencionara la regla de que “la pila debe moverse una sola vez”.
    • En runtimes como Java o .NET, los safepoints se definen claramente para evitar que el contexto cambie a mitad de un conjunto de instrucciones.
    • Parece que la solución correcta es que el compilador cargue la constante en el registro en dos pasos y luego ajuste SP atómicamente con un solo add; claro, eso agrega una instrucción, pero garantiza atomicidad. Otra opción sería operar con un registro temporal y luego volver a moverlo.
  • Para quienes tienen prisa, comparte el enlace al commit de la corrección: enlace al commit de golang/go
    • Al revisar el issue, surgió la duda de si el equipo de Go usa un bot de lenguaje natural o si en los comentarios simplemente revisan la palabra clave “backport”. Comentario relacionado: github issue comment
  • Es un blog técnicamente excelente; la explicación es tan clara y fácil de entender que hasta da la sensación de haber salido más inteligente después de leerlo. Aunque no veía ensamblador desde los tiempos de x86, fue fácil seguirlo. Y además genera confianza pensar que un equipo así tiene la capacidad y el control de calidad para resolver este tipo de problemas cuando aparezcan. Incluso había considerado Ampere Altra para ampliar servidores, pero como había suficiente espacio al final usaron Epyc.
  • Si hubiera un modo en Go para hacer single-step de todas las instrucciones y provocar una interrupción del GC en cada una, sería más fácil encontrar bugs como este.
  • Da curiosidad saber dónde están usando servidores ARM64. El año pasado dijeron que lanzarían servidores Gen 12 basados en AMD EPYC, pero no mencionaron ARM64; actualmente ARM64 sí se está usando en producción.
    • No trabajo en Cloudflare, pero por leer mucho el blog sé que, considerando arranque seguro y otros temas, ya desde hace varios años despliegan Ampere en paralelo con AMD. Parece que el objetivo operativo es la eficiencia en el edge, aunque podría haber otros usos. Más información en artículo sobre diseño de servidores edge, Ampere Altra vs AWS Graviton2 y evaluación de ARM de Qualcomm.
    • Recuerdo haber leído que Cloudflare aloja parte del cómputo no-edge en la nube pública, por ejemplo el control plane, así que podría ser por eso.
  • Yo pensaba que Cloudflare hoy en día usaba solo Rust y x86 (EPYC) al 100%; resulta interesante saber que también usan Go y ARM.
  • Como siempre, las publicaciones del blog de Cloudflare me parecen gran contenido que captura la esencia de la ingeniería sin magia de infraestructura ni de ML. Algún día me gustaría postularme ahí. Los bugs de compilador son más comunes de lo que parece (antes encontraba algunos cada año en 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.
    • Me da curiosidad por qué no postularte hoy.
  • Enfatiza que el puntero de pila siempre debe ajustarse de forma atómica.
    • Quienes implementaron la preempción probablemente escribieron el código pensando en x86 (donde aquí la instrucción puede contener la constante y hacerse de forma atómica), y al portar a ARM en un nivel más alto eso terminó dividiéndose automáticamente, generando este bug. No es culpa de nadie, pero el resultado no fue bueno.
    • Eso mismo pensé de inmediato.
  • No termino de entender cómo un hilo de máquina pudo detenerse entre dos instrucciones; en bare metal me pregunto si algo así sería posible.
    • Go usa interrupciones para las notificaciones del GC.
    • Señales (signals).
  • Sobre la frase del artículo “fue un problema muy divertido”, aunque sin duda debió de haber sido muy satisfactorio resolver un problema tan fundamental, mientras seguía sin resolverse probablemente no tuvo nada de divertido. Este tipo de bug te consume todos los nervios. Existe una cultura donde nadie piensa que el problema podría estar en la biblioteca estándar o en el compilador, así que el desarrollador sigue dudando solo de su propio código. Una vez yo también encontré un bug en una biblioteca estándar, y que el problema estuviera del lado del SDK fue lo último que se me ocurrió sospechar. Por eso uno termina gastando tiempo en lugares equivocados; y además, si es una race condition como en este caso, es difícil reproducirla, así que siempre parece que ya desapareció y luego vuelve a aparecer.
    • Este comentario añade una experiencia personal similar, pero al mismo tiempo se sintió como que innecesariamente se opone a la idea de que el autor lo haya disfrutado, y eso le resta un poco al impacto; cada persona puede encontrar divertidas cosas distintas.
    • A algunas personas les da gusto hacer depuración muy peculiar que a otros les resultaría insoportable; lo que para alguien es frustración para otro puede ser diversión.
    • Creo que probablemente lo que el autor quería decir no era “divertido” (funny), sino “satisfactorio” (satisfying). A mí también me tocó perseguir con una fecha límite encima un bug de sscanf en 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.
    • Resolver un defecto profundo da una sensación enorme de alivio cuando por fin se soluciona; muchas veces he sentido que la mayor satisfacción me llega al arreglar bugs del compilador o de la CPU.
    • En lenguajes administrados, si aparece un segfault sin usar nada tipo Unsafe, suelo tomarlo como una señal de que probablemente el problema no está en mi código.