Como vencer o PoW do kernelCTF do Google com AVX512
(anemato.de)- A equipe Crusaders of Rust queria enviar um exploit para o CVE-2025-38001 no escalonador de pacotes do Linux, mas, por causa da estrutura de ordem de chegada do kernelCTF, o fator decisivo acabou sendo reduzir o PoW mais do que o próprio bug
- O processo de envio seguia 12:00 UTC conexão, cerca de 4 s de PoW, cerca de 2,5 s para boot da VM, execução do exploit e envio no Google Form, e o recorde anterior de 4,5 s expôs esse gargalo
- O PoW alvo era o VDF “sloth”, um cálculo serial que repetia quadrados modulares em
2^1279 - 1sobre inteiros de 1280 bits, então era difícil reduzi-lo apenas aumentando a quantidade de núcleos de CPU/GPU - Após cair para 1,9 s com GMP/C++ e redução modular de Mersenne, e para cerca de 1,4 s com linkagem estática e
-march=native, a equipe usou o fused multiply-add inteiro de 52 bits do AVX512IFMA para reduzir o tempo para cerca de 0,21 s em um Ryzen 9950X - Em 16 de maio de 2025, a equipe enviou a flag em 3,6 s usando um servidor Google Cloud Zen 5 e um caminho otimizado de envio por POST, e em 28 de maio o kernelCTF anunciou a remoção do PoW
Corrida contra o tempo: o problema era enviar mais rápido que o bug
- Em maio de 2025, William Liu e Savy Dicanosa, da equipe Crusaders of Rust, descobriram o bug use-after-free CVE-2025-38001 no escalonador de pacotes do Linux e desenvolveram um exploit
- William encontrou o bug enquanto fazia fuzzing no Linux para sua dissertação de mestrado
- Savy reduziu o tempo de execução do exploit para cerca de 0,55 s
- O kernelCTF do Google abria uma janela de envio a cada duas semanas ao meio-dia UTC, e apenas a primeira equipe a explorar o servidor e enviar a flag no Google Form recebia a recompensa
- O envio sempre seguia a mesma sequência
- conectar ao servidor do kernelCTF às 12:00:00 UTC
- resolver o proof of work, cerca de 4 s
- esperar o boot da instância, cerca de 2,5 s
- enviar e executar o exploit
- enviar a flag no Google Form
- A recompensa esperada era de US$ 51.000
- recompensa base de US$ 21.337
- bônus de estabilidade de execução de US$ 10.000
- bônus de bug 0-day de US$ 20.000
O recorde anterior revelou o gargalo do PoW
- Na janela de envio de 2 de maio de 2025, o primeiro envio aconteceu 4,5 s depois do meio-dia
- Só o PoW de cerca de 4 s somado ao boot da VM de cerca de 2,5 s já exigiria 6,5 s, então o registro de 4,5 s não batia com a conta simples
- Por causa de características de arredondamento no código do servidor do kernelCTF, a instância de VM na prática iniciava às 11:59:59, eliminando a contradição temporal
- Ainda assim, o timestamp de geração da flag mostrava que a equipe vencedora resolveu o PoW em menos de 1 s
- Se uma equipe concorrente tivesse usado FPGA, um PoW abaixo de 1 s poderia ser possível
- FPGA é hardware customizado capaz de executar tarefas específicas muito rapidamente
- não é adequado para tarefas de uso geral, e tem custo alto e programação difícil
sloth VDF: um PoW que quase não paraleliza
- O PoW do kernelCTF era um verifiable delay function (VDF) chamado “sloth”
- VDF é um primitivo criptográfico que prova a passagem do tempo por meio de um cálculo serial longo, enquanto a verificação da prova é relativamente rápida
- Como o cálculo em si é serial, é difícil reduzir o tempo apenas adicionando mais núcleos de CPU ou GPU
- O loop central a ser otimizado tinha a seguinte estrutura
difficulty=7337- em cada repetição de difficulty, executar 1277 vezes
x = (x * x) % (2 ** 1279 - 1) - depois inverter o bit menos significativo de
x
- A implementação de referência do Google usava gmpy em Python, que é um binding Python para GMP
- GMP é uma biblioteca de inteiros multiprecisão com kernels de soma e multiplicação implementados em assembly para cada plataforma
Primeira otimização com base em GMP
- A primeira otimização explorou o fato de
2^1279 - 1ser um número de Mersenne para fazer a redução modular- o produto intermediário de 2560 bits era dividido entre os 1279 bits baixos e os bits altos, que então eram somados
- se o resultado fosse maior ou igual ao módulo, bastava subtrair uma vez, substituindo a operação
%
- Para reduzir o overhead de FFI do Python, a solução foi portada para C++, e essa versão executava em 1,9 s em um MacBook Pro M1
- William compilou a libgmp localmente com
-march=nativee fez linkagem estática, chegando a cerca de 1,4 s em um notebook Intel Ice Lake - Um solver com otimização semelhante escrito em Rust usava a mesma técnica de Mersenne, mas levava cerca de 2,4 s
- Depois, também testaram FLINT, mas o desempenho ficou quase igual ao do GMP
Reescrevendo o quadrado de inteiros grandes com AVX512IFMA
- AVX512 é uma extensão da ISA x86 da Intel que aumenta a quantidade e a largura dos registradores vetoriais e adiciona predição por máscara e várias novas instruções
- a Intel desativou o suporte a AVX512 em CPUs cliente a partir de Alder Lake
- no segmento de servidores o suporte continuou, e a AMD implementou AVX512 tanto em CPUs de consumo quanto de servidor nas arquiteturas Zen 4 e Zen 5
- O ponto central foi o AVX512IFMA
vpmadd52luq: soma a metade baixa de uma multiplicação de 52 bits em um acumulador de 64 bitsvpmadd52huq: soma a metade alta de uma multiplicação de 52 bits em um acumulador de 64 bits
- Essas instruções calculam as partes baixa e alta de uma multiplicação 52×52→104 bits e acumulam o resultado em registradores vetoriais
- O Zen 5 tinha caminho de dados de 512 bits, então conseguia despachar duas dessas instruções por ciclo de clock
- A base natural era
2^52, e o inteiro de 1280 bits era representado por 25 limbs de 52 bits- um registrador zmm de 512 bits pode conter 8 limbs
- o valor completo cabe em 4 registradores zmm
Organização da multiplicação e redução de Mersenne
- O quadrado de 1280 bits foi implementado elevando ao quadrado 25 limbs de 52 bits para gerar um resultado intermediário de 50 limbs
- A simetria do quadrado foi usada para quase reduzir pela metade o número de multiplicações necessárias
- componentes diagonais
ai^2 - termos cruzados
2 * ai * ajparai < j
- componentes diagonais
- No cálculo dos termos cruzados, uma janela deslizante de 8 limbs consecutivos era multiplicada por um único limb multiplicador para reduzir operações de shuffle
- Com merge masking do AVX512, multiplicações que não fariam parte da soma final não eram acumuladas
- A redução modular era feita somando os 1279 bits altos aos 1279 bits baixos
- como os elementos do acumulador podiam passar de
2^52 - 1, a propagação de carry era adiada para depois da soma - decidir se o resultado era maior ou igual a
2^1279 - 1equivalia a verificar se o 1280º bit era 1 - subtrair
2^1279 - 1era equivalente a limpar o 1280º bit e somar 1 ao limb menos significativo
- como os elementos do acumulador podiam passar de
- No estágio final ainda restava uma possibilidade muito pequena de overflow
- se o último limb fosse exatamente
2^52 - 1, seria necessária propagação de carry - para PoWs aleatórios, a probabilidade foi estimada em cerca de 2 em 2 bilhões por execução e acabou sendo ignorada
- se o último limb fosse exatamente
Micro-otimizações: de 0,45 s para 0,21 s
- A primeira versão com AVX512IFMA processava o PoW em cerca de 0,45 s em um Ryzen 9950X alugado
- Como as instruções multiply-add tinham latência de 4 ciclos e podiam iniciar duas por ciclo, eram necessários pelo menos 8 acumuladores para saturar a unidade de multiplicação
- antes havia apenas 7 acumuladores
- a solução foi usar 7 acumuladores para a metade baixa e 7 para a metade alta, 14 no total, e combinar tudo no fim
- essa mudança reduziu o tempo para cerca de 0,32 s
- GCC e clang, ao desenrolar o loop, geravam
vbroadcastsd zmm, m64, e a alocação de registradores ficava sem registradores vetoriais, causando spill e reload na stack- com assembly inline, forçaram
vpmadd52luq/vpmadd52huqa usar memory broadcast operand - em vez de colocar o limb multiplicador em um registrador vetorial separado, ele era lido da memória e replicado em todos os elementos do vetor
- esse broadcast load era tratado pela unidade de load, sem consumir recursos da ALU vetorial
- nessa etapa, o tempo caiu para cerca de 0,23 s
- com assembly inline, forçaram
- Armazenar inteiros alinhados na memória e depois formar a janela com loads desalinhados causava store-forwarding stall
- com
valignq, eles passaram a emular loads desalinhados dentro dos registradores zmm, reduzindo acessos à memória - o tempo final do PoW ficou em cerca de 0,21 s
- com
Resultado do envio em 16 de maio de 2025
- A equipe preparou o envio final às 4:30 da manhã PST de 16 de maio de 2025
- Para reduzir a latência, usou um servidor Google Cloud Zen 5 na Holanda, geograficamente próximo do servidor que recebia os envios do Google Form
- Minutos antes do envio, interceptaram e registraram uma requisição POST do Google Form usando uma flag fictícia
- Bryce Casaje e Larry Yuan projetaram e otimizaram o programa de envio do formulário
- Max Cai também ajudou no desenvolvimento e no envio
- Às 5:00, o servidor conectou ao kernelCTF, resolveu o PoW, executou o exploit otimizado de Savy e inseriu a flag na requisição POST para enviá-la
- O resultado foi um envio em 3,6 s, o mais rápido da história do kernelCTF até então
- Os operadores do kernelCTF confirmaram a elegibilidade para a recompensa no mesmo dia
Remoção do PoW e publicação do solver final
- Em 28 de maio de 2025, o operador do kernelCTF koczkatamas anunciou a remoção do PoW
- Com o fim do PoW, a disputa por slot passou a depender principalmente do tempo de execução do exploit e da latência de rede
- Essa mudança permitiu competir em igualdade com equipes especializadas mesmo sem conhecimento de FPGA ou de otimizações com assembly inline
- O código do solver final foi resultado de cerca de 12 horas de trabalho entre 14 e 15 de maio de 2025 e foi publicado sob GNU AGPL 3.0
- Um exemplo de build era
gcc main.c -O3 -march=znver5 -masm=intel -lgmp
1 comentários
Comentários do Hacker News
Conteúdo excelente. Essa abordagem é muito parecida com implementações de RSA otimizado para AVX-512, porque RSA também precisa fazer exponenciação com números muito grandes
Este artigo[1] trata de como o RSA faz windowing e também inclui uma fórmula que mostra que o tamanho da janela pode ser arbitrário. Implementações de RSA com AVX-512 ainda armazenam em uma tabela os resultados de multiplicação no intervalo [0..2^{window-size}) e, para cada janela, pegam esse resultado da tabela[2] e fazem apenas shifts/rearranjos
https://dpitt.me/files/sime.pdf (hospedado no meu domínio porque foi retirado de um periódico)
https://github.com/aws/aws-lc/blob/9c8bd6d7b8adccdd8af4242e0...
Além disso, ele move registradores de máscara para registradores de uso geral por causa das operações aritméticas, o que não é ideal no Zen 4/5. Separadamente, também fico curioso se realmente é preciso propagar o carry de uma vez só. No meu código, assumi que o carry ocorre apenas uma vez e, se necessário, volto em um loop, reduzindo a latência no caso comum. Mas, com branches, pode haver problemas de ataque de timing
dpitt.me/files/sime.pdftambém pode ser colocado no archive.org: https://archive.org/download/sime_20250531/sime.pdfA parte “apesar de [AVX512] ter sido suportado em CPUs de consumidor por várias gerações” soa um pouco estranha
Antes do Rocket Lake (11ª geração), AVX-512 existia apenas em CPUs entusiastas de alto nível, CPUs Xeon e alguns processadores móveis, e é meio discutível chamar processadores móveis de CPUs de consumidor. Na 12ª geração, por causa da arquitetura com núcleos de desempenho/eficiência, ele foi desativado nesses núcleos alguns meses depois e não apareceu de novo. Ainda assim, se a AMD tiver algum sucesso com AVX-512, acho provável que a Intel o reintroduza. Para constar, ainda uso um Intel i9-11900
É por aí mesmo. O white paper atualizado de AVX10[1] da Intel, de alguns meses atrás, também parece confirmar isso. Ele diz explicitamente que AVX de 512 bits se tornará padrão tanto nos P-cores quanto nos E-cores, e que eles sairão das configurações somente de 256 bits
Isso parece um forte sinal de que AVX-512 voltará de verdade não só em servidores, mas também em futuras CPUs de consumidor com E-cores. Provavelmente para alcançar a adoção mais ampla de AVX-512 pela AMD
[1] - https://cdrdv2.intel.com/v1/dl/getContent/784343 (PDF)
CPUs de 12ª geração com núcleos de desempenho nem sequer anunciavam suporte a AVX512 e ele não vinha ativado por padrão
Como os núcleos de eficiência não incluíam AVX512 por questões de área, a CPU inteira era considerada sem suporte a AVX512. Só era possível usar um comportamento peculiar de algumas opções de BIOS para desligar os núcleos de eficiência e ativar AVX512 no restante da CPU, ao custo de abrir mão dos E-cores
O recorde vencedor foi 3,6 segundos, mas o segundo lugar fez 3,73 segundos, ou 3,74 segundos se arredondado para o mesmo número de casas do recorde. Então será que o segundo lugar também otimizou a prova de trabalho ou usou FPGA?
O autor disse que submissões anteriores, mesmo descritas como baseadas em FPGAs caros, passavam de 4 segundos. Então é possível que o segundo lugar daquela semana tenha sido a segunda submissão mais rápida de todos os tempos; acho que teria havido algum comentário sobre isso
Impressionante, mas parece uma otimização para o alvo errado. CTF não deveria virar uma disputa de operação de submissão
Acho que seria melhor para todos se todas as equipes que enviassem a flag dentro da janela de submissão dividissem o prêmio
Então, na prática, ela pode incentivar ativamente o comportamento “errado”
Se entendi direito, há uma prova de trabalho de 4 segundos, e o prêmio é pago uma vez por mês
Existem mesmo tantos exploits assim para as pessoas competirem todo mês?
CTF público é difícil. No fim, algumas equipes acabam se comportando de forma parecida com DDoS enquanto correm até a linha de chegada. Depois, o Google removeu a etapa de prova de trabalho
Conteúdo incrível, mas, olhando os obstáculos que é preciso superar para vencer este desafio, parece uma comédia. É uma verdadeira máquina de Rube Goldberg
Se quiser saber mais sobre a representação em base 52 mencionada neste texto, vale ver outro post que está hoje na front page: https://news.ycombinator.com/item?id=44132673
Uma observação pequena: linkagem estática não faz inlining; ela só remove o overhead da PLT. Quem aumenta as oportunidades de inlining é o LTO
Não entendo por que fazem isso como uma competição. Por que não simplesmente recompensar cada exploit único?
E o Linux tem tantos bugs que, se começarem a pagar por todo 0-day, a coisa sai do controle. O Google chegou a fazer uma promoção por tempo limitado, sem competição, para as pessoas descarregarem bugs acumulados; quando aceitaram todos os 0-days, as submissões explodiram. Ao mesmo tempo, eles não querem irritar a comunidade, então a estrutura ficou assim
É meio deprimente que, depois de todo esse tempo, especialistas ainda consigam tomar uma máquina Linux em 3 segundos