2 pontos por GN⁺ 2025-10-09 | 1 comentários | Compartilhar no WhatsApp
  • A Cloudflare encontrou, durante o monitoramento de tráfego em larga escala, um raro bug de condição de corrida (race condition) no compilador Go em execução na plataforma arm64
  • Esse bug se manifestava com o serviço entrando inesperadamente em pânico durante o processo de stack unwinding ou com erros de acesso à memória
  • Ao rastrear a causa, foi confirmado que o problema ocorria entre a preempção assíncrona (asynchronous preemption) do runtime do Go e duas instruções de ajuste do ponteiro de pilha geradas pelo compilador
  • Com um código mínimo de reprodução, foi demonstrado que esse bug era um problema do próprio runtime do Go, revelando a existência de uma condição de corrida de uma instrução na qual o ponteiro de pilha ficava parcialmente alterado
  • O problema foi corrigido nas versões go1.23.12, go1.24.6 e go1.25.0, e a nova abordagem evita manipulações do ponteiro de pilha que não podem ser alteradas imediatamente, bloqueando a race condition pela raiz

Análise do bug no compilador Go ARM64 encontrado pela Cloudflare

Os datacenters da Cloudflare processam 84 milhões de requisições HTTP por segundo em mais de 330 cidades no mundo, e esse tipo de ambiente de tráfego em larga escala tem a característica de expor até mesmo bugs raros com frequência. Este texto analisa em detalhes, com casos reais, um problema de condição de corrida no código gerado pelo compilador Go para a plataforma arm64.

Investigação de um comportamento estranho de panic

  • Na rede da Cloudflare, há serviços que aplicam no kernel o processamento de tráfego de produtos como Magic Transit e Magic WAN
  • Em máquinas arm64, mensagens de fatal panic eram detectadas de forma rara, mas recorrente, pelo sistema de monitoramento
  • A análise inicial mostrou que, durante o processo de stack unwinding, era detectada uma violação de integridade (panics aconteciam com frequência em código antigo que usava o padrão panic/recover)
  • A estrutura de panic/recover foi removida temporariamente para reduzir a frequência dos panics, mas depois panics fatais suspeitos passaram a ocorrer com mais frequência
  • Com isso, concluiu-se que era necessária uma análise mais profunda da causa, além de apenas rastrear padrões simples

Visão geral do runtime do Go e das estruturas do escalonador

  • O Go adota uma estrutura de escalonamento M:N com um scheduler leve em espaço de usuário (mapeando várias goroutines para um pequeno número de threads do kernel)
  • As estruturas centrais do scheduler são g (goroutine), m (machine/thread do kernel) e p (processor)
  • Falhas no stack unwinding ou erros de acesso à memória podem acontecer quando o ponteiro de pilha ou o endereço de retorno mudam de forma anormal

Causa estrutural do erro durante o stack unwinding

  • A análise de vários backtraces mostrou que todos os casos aconteciam durante o processo de stack unwinding na função (*unwinder).next
  • Em um caso, o endereço de retorno era null, então a pilha era tratada como inválida e a execução era encerrada com erro fatal; em outro, ocorreu uma falha de segmentação ao acessar um campo (incgo) da estrutura m do scheduler do Go dentro de um stack frame
  • O crash acontecia muito longe do ponto real onde o bug era disparado, o que dificultava rastrear a causa

Padrão observado e relação com a biblioteca Go Netlink

  • Ao revisar os stack traces, foi confirmado que todos os crashes se concentravam em momentos em que a preempção acontecia dentro da função NetlinkSocket.Receive da biblioteca Go Netlink
  • A partir disso, foram levantadas duas hipóteses
    • Possibilidade de bug causado pelo uso de unsafe.Pointer no Go Netlink
    • Possibilidade de bug no próprio runtime do Go, na preempção assíncrona e no stack unwinding
  • Foi feita uma auditoria no código, mas não foram encontrados padrões diretos de corrupção de memória, então estimou-se que o núcleo do problema estava no runtime e na estratégia de manipulação da pilha

Preempção assíncrona e condição de corrida

  • O recurso de preempção assíncrona, introduzido no Go 1.14, força a criação de um ponto de escalonamento em goroutines de longa execução ao enviar um sinal (SIGURG) para a thread do sistema operacional
  • Se essa preempção acontecer entre duas instruções de assembly que ajustam o ponteiro do stack frame, o ponteiro de pilha pode ficar em um estado intermediário
  • Quando a pilha é desempilhada para coleta de lixo, tratamento de panic ou geração de stack trace, isso leva à leitura de posições erradas e à interpretação incorreta de endereços de função ou dados

Criação de um código mínimo de reprodução

  • Ao ajustar o tamanho da alocação do stack frame e escrever código com uma função que faz ajuste explícito da pilha (big_stack) junto com chamadas constantes ao garbage collector, a condição de corrida pôde ser reproduzida
  • De fato, no código de assembly, o ponteiro de pilha era ajustado com duas instruções ADD, e se a preempção assíncrona ocorresse entre elas, acontecia um crash durante o stack unwinding
  • Foi possível reproduzir essa falha usando apenas código da biblioteca padrão, comprovando que se tratava de uma vulnerabilidade de uma instrução, inerente ao código gerado pelo compilador Go

Origem da janela de corrida no nível do compilador ARM64

  • Por causa do comprimento fixo das instruções e das limitações de valores imediatos na arquitetura ARM64, o ajuste do ponteiro de pilha pode exigir duas ou mais instruções
  • Na representação intermediária interna (IR) do Go, esse comprimento de imediato não é considerado, e as instruções divididas só são inseridas na conversão para código de máquina real
  • Por isso, o retorno do stack frame (ADD RSP, RSP) acabava usando duas instruções, criando uma janela vulnerável de uma única instrução para preempção
  • Como o unwinder depende absolutamente da exatidão do ponteiro de pilha, uma interrupção no meio dessas instruções leva à interpretação errada de valores e a falhas fatais
  • O fluxo real do crash se organiza assim:
    1. A preempção assíncrona ocorre entre as duas instruções ADD
    2. O GC ou outro motivo dispara a rotina de stack unwinding
    3. Uma posição incomum do ponteiro de pilha é explorada e um endereço de função incorreto é interpretado
    4. O runtime sofre crash

Correção do bug e melhoria estrutural

  • A equipe da Cloudflare reportou o problema ao repositório oficial do Go com base no código mínimo de reprodução e na análise detalhada, e o issue foi corrigido e lançado rapidamente
  • Nas versões go1.23.12, go1.24.6 e go1.25.0 em diante, o deslocamento completo passa a ser calculado primeiro em um registrador temporário e depois o ponteiro de pilha é alterado com uma única instrução, eliminando a vulnerabilidade à preempção
  • Agora, o ponteiro de pilha é sempre garantido como válido, de modo que a condição de corrida fica estruturalmente bloqueada
LDP -8(RSP), (R29, R30)
MOVD $32, R27
MOVK $(1<<16), R27
ADD R27, RSP, RSP
RET

Conclusão e implicações

  • Esse bug é um caso em que a geração de código do compilador para uma arquitetura específica e o gerenciamento de concorrência (preempção assíncrona) colidiram de uma forma inesperada
  • É um caso interessante por mostrar como uma condição de corrida em nível de instrução, extremamente rara e visível apenas em ambientes de grande escala, foi rastreada com dados de produção e inferência rigorosa
  • Se você opera serviços baseados em Go recente e arquitetura ARM64, é importante atualizar para uma das versões relacionadas

1 comentários

 
GN⁺ 2025-10-09
Comentários do Hacker News
  • Fiquei realmente impressionado com a descoberta e, no momento em que vi o código assembly, já comecei a seguir o caminho da depuração; na verdade, essa abordagem não é algo que só possa ser feito em assembly, talvez também fosse possível na etapa de IR, mas por vários motivos não é assim; o fato de conseguir ler assembly ARM é uma grande vantagem; também considerei a ideia de fazer push ou pop do tamanho da pilha para reduzir a contagem de instruções, mas como não sei exatamente o que o GC verifica, não tenho certeza; gostaria de ouvir outras opiniões
    • Em geral, usa-se a pseudo-instrução ARM LDR Rd, =expr; no caso de constantes que não podem ser criadas diretamente, a constante é colocada em uma posição relativa ao PC e carregada para um registrador com base no PC; com isso, o processo de “somar uma constante ao SP” pode ser transformado em 2 instruções executáveis, exigindo no total 12 bytes: 8 bytes de código e 4 bytes de área de dados (para uma constante de 17 bits); documentação relacionada: explicação da pseudo-instrução LDR
    • É surpreendente que esse bug não tenha recebido tratamento especial no montador, sendo um caso peculiar de adicionar um valor imediato ao RSP; se o patch foi aplicado apenas no compilador, o mesmo problema pode continuar existindo em outros pontos de assembly aarch64
    • Essa sintaxe estranha de assembly ARM com cifrão não é assembly padrão de AArch64, e acho que o texto também deveria ter mencionado a regra de que “a pilha deve ser movida apenas uma vez”
    • Em runtimes como Java ou .NET, os safepoints são definidos de forma explícita para evitar que o contexto mude no meio de um conjunto de instruções
    • Parece que a solução correta é o compilador carregar a constante no registrador em duas etapas e depois ajustar o SP atomicamente com um único add; claro, isso acrescenta uma instrução, mas garante a atomicidade; outra opção seria fazer a operação em um registrador temporário e depois mover de volta
  • Para quem está com pressa, segue o link do commit da correção: link do commit em golang/go
    • Ao olhar a issue, fiquei curioso se a equipe do Go usa um bot de linguagem natural, ou se apenas verifica a palavra-chave “backport” nos comentários; comentário relacionado: comentário na issue do GitHub
  • É um blog tecnicamente excelente, com explicações tão claras que ficou fácil entender e até dá a sensação de ter ficado mais inteligente por isso; fazia muito tempo que eu não via assembly desde x86, e mesmo assim foi fácil acompanhar; além disso, uma equipe assim transmite confiança de que tem capacidade e controle de qualidade para resolver esse tipo de problema a qualquer momento; eu também tinha considerado Ampere Altra para expandir servidores, mas como havia espaço de sobra, acabei usando Epyc
  • Acho que, se existisse no Go um modo de executar todas as instruções em single-step e disparar uma interrupção de GC a cada instrução, seria bem mais fácil encontrar bugs desse tipo
  • Fico curioso sobre onde estão usando servidores ARM64; no ano passado falaram em lançar servidores Gen 12 baseados em AMD EPYC, mas não houve menção a ARM64; atualmente ARM64 já está sendo usado em produção
    • Não sou funcionário da Cloudflare, mas pelo que sei de tanto ler o blog, eles já implantavam Ampere junto com AMD há alguns anos, levando em conta coisas como secure boot; o objetivo operacional parece ser eficiência no edge, embora possa haver outros usos; mais informações podem ser vistas em texto sobre design de servidores de edge, Ampere Altra vs AWS Graviton2 e avaliação do ARM da Qualcomm
    • Lembro de ouvir que a Cloudflare hospeda parte da computação non-edge em nuvem pública, por exemplo o control plane; então pode ser isso
  • Eu achava que hoje em dia a Cloudflare usava só 100% Rust e x86 (EPYC), então é interessante ver que também usam Go e ARM
  • Como sempre, os posts do blog da Cloudflare me parecem um ótimo conteúdo que captura a essência da engenharia sem depender de magia de infraestrutura ou de ML; algum dia gostaria de me candidatar para trabalhar lá; bugs de compilador são mais comuns do que parecem (no passado eu encontrava alguns por ano no gcc), mas muitas vezes são casos raros que só aparecem em escala, como no texto; a maioria das pessoas nunca chega a operar nessa escala
    • Fico curioso por que não se candidata hoje
  • Vale reforçar que o ponteiro de pilha deve sempre ser ajustado atomicamente
    • Quem implementou a preempção provavelmente escreveu o código pensando em x86 (onde a instrução pode conter a constante e isso acontece de forma atômica), e no porte para ARM a divisão automática em nível mais alto acabou produzindo esse bug; não é culpa de ninguém, mas o resultado não foi bom
    • Foi exatamente isso que me veio à cabeça
  • Não entendo muito bem como a thread de máquina conseguiu parar entre duas instruções; fico em dúvida se isso seria possível em bare metal
    • O Go usa interrupções para notificações do GC
    • sinais (signals)
  • Sobre a frase “foi um problema muito divertido”, resolver uma falha tão fundamental certamente deve ter sido recompensador, mas enquanto ainda estava sem solução imagino que não tenha sido nada divertido; esse tipo de bug consome toda a sua energia mental; existe uma cultura em que ninguém pensa que o problema possa estar na biblioteca padrão ou no compilador, então o desenvolvedor continua suspeitando apenas do próprio código; eu mesmo já encontrei um bug em biblioteca padrão, e desconfiar que o problema estava no SDK foi a última coisa que me ocorreu; isso faz você perder tempo em lugares totalmente errados e, além disso, quando é uma race condition como neste caso, é difícil reproduzir, então você sempre acha que sumiu e depois ela reaparece
    • Esse comentário até acrescenta uma experiência pessoal parecida, mas ao mesmo tempo parece contestar sem necessidade a diversão que o autor sentiu, o que acaba reduzindo um pouco o impacto
    • Algumas pessoas realmente gostam de uma depuração muito incomum, daquelas que deixariam os outros sofrendo; o que para alguém é frustração, para outro é prazer
    • Acho que talvez o autor quisesse dizer não “divertido” (funny), mas “satisfatório” (satisfying); eu também já precisei correr contra o prazo para pegar um bug no sscanf do toolchain GCC ARM do Ubuntu, e na hora não foi divertido, mas depois de isolar o problema com precisão e escrever um teste de regressão, a sensação foi realmente muito satisfatória
    • Resolver um defeito profundo traz uma sensação enorme de alívio quando finalmente dá certo; muitas vezes senti meu maior prazer justamente ao resolver bugs relacionados a compilador ou CPU
    • Em linguagens gerenciadas, quando acontece um segfault sem usar nada do tipo Unsafe, costumo tomar isso como sinal de que provavelmente o problema não está no meu código