Como foi descoberto um bug no compilador ARM64 do Go
(blog.cloudflare.com)- 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)ep (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 estruturamdo 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.Receiveda 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:
- A preempção assíncrona ocorre entre as duas instruções
ADD - O GC ou outro motivo dispara a rotina de stack unwinding
- Uma posição incomum do ponteiro de pilha é explorada e um endereço de função incorreto é interpretado
- O runtime sofre crash
- A preempção assíncrona ocorre entre as duas instruções
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
Comentários do Hacker News
pushoupopdo 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õesLDR 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 LDRadd; 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 voltasignals)funny), mas “satisfatório” (satisfying); eu também já precisei correr contra o prazo para pegar um bug nosscanfdo 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