Artigos aprofundados sobre a tecnologia que molda o que vem a seguir.

Um Asterisco, Dez Anos: o Bug Silencioso do SSH no Alpine

Um bug de SSH no Alpine Linux deixava entrar em contas bloqueadas com qualquer senha longa por dez anos. Veja como o crypt() do musl causou isso.

Uma porta de cofre com abertura em formato de asterisco brilhando em uma sala de servidores escura
Um único caractere em um arquivo de sistema fazia a diferença entre uma conta bloqueada e uma porta aberta.

Aqui está um número que deveria te incomodar: 257. Por cerca de dez anos, digitar qualquer senha com 257 caracteres ou mais em um prompt de SSH, no tipo errado de sistema Linux, não te rejeitava. Te deixava entrar. Qualquer senha. Um monte de caracteres aleatórios, um dump em base64 das suas fotos de férias, metade de uma mensagem do Slack colada por engano: desde que fosse longa o suficiente, a porta se abria. O bug de SSH do Alpine Linux, divulgado como “sshole”, é uma daquelas vulnerabilidades que parecem brincadeira até você conferir as condições, e aí passam a parecer um relatório de incidente.

Resumindo: no OpenSSH compilado com a musl libc (o que inclui Alpine, OpenWrt, Buildroot, Yocto e uma fatia impressionante das imagens base do Docker na internet), contas que deveriam ser matematicamente impossíveis de acessar aceitavam qualquer senha entre 257 e 1.024 caracteres. Root. Seu usuário de deploy que só aceita chave. O usuário padrão alpine nas imagens oficiais da AWS e da Azure, que vem com doas sem senha. A correção saiu no OpenSSH 10.6p2. Mas a história de como isso aconteceu é uma cadeia de pequenas decisões sensatas que remontam a 1980, e cada elo parecia correto na época.

Como o Bug de SSH do Alpine Linux Funciona de Fato

Para entender a colisão, você precisa de três peças, cada uma mantida por uma base de código diferente e de uma década diferente. Primeiro: como o Linux armazena senhas. Quando você define uma senha, o hash vai para /etc/shadow, uma linha por conta. A linha de uma conta normal se parece com $6$salt$hash, onde $6$ significa sha512crypt. Mas contas que nunca deveriam aceitar senha (daemons, usuários só com chave, root bloqueado) recebem um marcador no lugar:

alice:$6$yJ3kXw9q$k2P9vLm4...:19723:0:99999:7:::
deploy:*:19723:0:99999:7:::
root:*:19723:0:99999:7:::

Quando você faz login, o sshd pega o que você digitou, gera o hash com o salt armazenado e compara as strings com o campo guardado. Mesma string, você entra. O asterisco é uma sentinela: não é o hash de nada, então nenhuma senha jamais vai bater com ele. Esse tem sido o mecanismo de bloqueio para contas somente com chave desde os primórdios do Unix. Ele funciona porque a comparação nunca pode dar certo, a menos que a própria função de hash consiga produzir um asterisco. O que nos leva à peça dois.

A peça dois é o tratamento de erros da musl. Tradicionalmente, crypt() retorna NULL quando não consegue fazer seu trabalho, por exemplo quando o salt pede um algoritmo que o sistema não tem. O problema é que, por décadas, softwares não verificaram NULL e simplesmente o passavam para strcmp, causando um segfault. Então, em 2012, a musl tomou uma decisão de design deliberada: seu crypt() nunca retornaria NULL. Em caso de falha, retornaria uma string garantidamente diferente de qualquer hash de senha real, para que chamadores quebrados vissem uma senha errada em vez de travar. A string de falha escolhida foi *. Três semanas depois, a musl adicionou o sha512crypt com um limite de entrada de 256 bytes, já que o custo do hash escala com o tamanho da senha e senhas gigantes são um vetor de negação de serviço. Rejeite qualquer coisa maior, o que é uma falha, que retorna *. O comentário que documenta isso ficou no código-fonte o tempo todo.

A peça três é o sshd sendo educado. Em 2016, o CVE-2016-6210 mostrou que era possível enumerar nomes de usuário válidos medindo o tempo das respostas: usuários inexistentes eram recusados instantaneamente, porque não havia hash a calcular. A correção foi elegante: para contas sem hash utilizável, o sshd pega o salt de outra conta e gera o hash da senha informada mesmo assim, só para gastar o mesmo tempo. Um não mais lento. O detalhe: esse caminho não é acionado só para usuários inexistentes. Também é acionado para usuários reais cujo campo no shadow é *, porque um asterisco não é um hash utilizável. Então agora o sshd calcula crypt(sua_senha, salt_emprestado) e compara o resultado com *.

/* what sshd effectively does in this configuration */
const char *stored = shadow_field(user);        /* "*" for locked accounts */
const char *salt   = pick_salt(user);           /* borrowed: "*" isn't a valid hash */
char *ret = crypt(typed_password, salt);        /* musl: "*" if password > 256 bytes */
if (ret && strcmp(ret, stored) == 0)
allow_login();                              /* "*" == "*"  →  welcome in */

Todos os elementos precisam se alinhar: o campo armazenado é *, o crypt falha e o valor de falha também é *. No glibc, um crypt com falha retorna NULL, o que faz a comparação falhar com segurança. Nos sistemas com libxcrypt, a string de falha é *0, um caractere de diferença, e esse caractere é o jogo inteiro, porque *0 nunca é igual a *. Na musl, o valor de erro e o valor de bloqueio são o mesmo caractere, e o strcmp não tem como distinguir um erro de uma correspondência. Dois significados, um único canal, e quem recebe não consegue diferenciar.

Uma Década de Decisões Perfeitamente Razoáveis

O que torna esse bug digno de estudo não é o mecanismo. É que ninguém envolvido fez algo obviamente estúpido. Comece em 1980: na época, os hashes de senha ficavam em /etc/passwd, legível por todos. Bill Joy criou uma conta chamada daemon, que ninguém deveria jamais acessar, e colocou um * onde ficaria o hash de senha. Uma sentinela limpa, décadas à frente do seu tempo. (Dizem que o campo de nome real dizia “o próprio diabo”.) O Unix ainda bloqueia contas exatamente desse jeito.

A decisão da musl em 2012 também parece defensável por todos os ângulos. Chamadores que travam são um problema real, e uma biblioteca C que transforma NULL em arma contra décadas de código de aplicação descuidado faz muitos inimigos. Retornar uma string que nunca vai bater é uma degradação graciosa. Limitar o tamanho da senha para impedir negação de serviço baseada em hash também faz sentido. A correção de temporização do sshd também: preencher as respostas para usuários sem hash é prática padrão. Até o que a Alpine fez em seguida é compreensível. Em 2019, o CVE-2019-5021 revelou que as imagens Docker da Alpine vinham com o campo de senha do root vazio, em algumas configurações sem senha nenhuma. A Alpine bloqueou o root com !. Mas, sem PAM, o sshd trata o ! como bloqueio total, o que também mata o login por chave SSH, e imagens de nuvem vivem e morrem de login por chave. Então, em 2022, a Alpine 3.16 mudou o root para *: continua bloqueado, as chaves funcionam, todo mundo feliz. Só que * era exatamente a string que o crypt da musl podia ser induzido a retornar.

Cada passo foi uma resposta defensável a um problema real. É isso que torna tudo desconfortável: você poderia ter estado na sala em cada decisão e acenado com a cabeça junto.

Os quase-acidentes são quase piores. Um desenvolvedor do Debian certa vez pediu que o glibc adotasse exatamente o comportamento de * da musl; seis anos depois, o Debian migrou para o libxcrypt, cujo *0 fechou o buraco por acaso. Uma thread do BusyBox reclamou que o passwd engasgava com uma senha de 257 caracteres: mesmo asterisco, mesmo limite, e ninguém na thread disse “sshd”. Um revisor de segurança remendou a função pick_salt() do sshd com o bug real a quatro linhas dali, quatro dias antes de alguém finalmente encontrá-lo. Isso foi ignorado repetidamente por pessoas que olhavam quase diretamente para ele.

Dominós de materiais variados caindo em cadeia sobre uma mesa escura
Quatro décadas de pequenas decisões sensatas, cada uma derrubando a seguinte.

Sua Infraestrutura Está Afetada pelo Bug do OpenSSH na musl?

O ponto positivo do sshole é que ele precisa de um conjunto completo de condições para funcionar. Se faltar qualquer uma, você está seguro. Você está exposto se todas estas forem verdadeiras:

  • Seu sshd foi compilado com a musl: Alpine, OpenWrt, Buildroot, Yocto ou uma imagem Docker derivada de alpine com o openssh-server instalado.
  • O sshd não passa pelo PAM. O openssh-server do Alpine não usa por padrão, ou você definiu UsePAM no. O caminho de código do PAM nunca chama o crypt para uma conta *.
  • A autenticação por senha está habilitada. Por padrão, está.
  • Pelo menos uma conta tem um * puro em /etc/shadow: usuários somente com chave, contas via LDAP, root no Alpine.
  • Pelo menos outra conta tem um hash de senha real, então existe um salt para tomar emprestado.

Se as cinco forem verdadeiras, toda conta * da máquina aceita qualquer senha de 257 a 1.024 caracteres. A configuração de pesadelo são as imagens oficiais da Alpine na AWS e na Azure: o usuário padrão alpine só aceita chave (logo, *) e tem doas sem senha. Um login com senha longa ali não é escalonamento de privilégio, é uma porta direta para o root. Uma mitigação merece crédito por ter mantido isso em silêncio por tanto tempo: o PermitRootLogin do OpenSSH recusa logins por senha para root desde 2015, então o alvo mais óbvio estava parcialmente protegido por padrão. Nada protegia os usuários de deploy e as contas de serviço.

Se você usa Debian, Ubuntu, Fedora ou qualquer outra distro baseada em glibc, está tranquilo. Quem passa pelo PAM: tranquilo. Dropbear: tranquilo. OpenBSD: tranquilo. Um alerta para os céticos: as primeiras tentativas da comunidade de reproduzir o problema foram inconsistentes, porque as cinco condições são fáceis de deixar passar uma a uma. Alguém testa a imagem oficial da Alpine, não percebe que o campo do root é ! e não *, não consegue reproduzir e desiste. Trate “não consegui reproduzir no meu notebook” como nenhuma evidência sobre produção. Verifique as condições, não a sensação.

A Causa Raiz Real: Sinalização In-Band e Strings Mágicas

É tentador classificar o sshole como “esquisitice de libc” e seguir em frente. O modo de falha é mais antigo e mais geral que isso: sinalização in-band. O caractere * foi escalado para dois trabalhos. No /etc/shadow, significa “não existe senha para esta conta”; no crypt da musl, significa “o hash falhou”. Duas mensagens compartilhando um canal, sem nenhuma forma de quem recebe distingui-las. Valores sentinela embutidos em dados comuns são a forma como sistemas acabam explorando a si mesmos. NULL era um sinal de erro aceitável (apesar dos chamadores propensos a travar). Uma exceção é melhor. Um Result<T, E> que obrigue o chamador a tratar a falha explicitamente é o melhor. A regra prática, aquela que eu tatuaria em todo programador de sistemas: nada de strings mágicas. Se você realmente precisar de uma, escolha algo que não possa colidir com nenhum valor do domínio do problema. O *0 do libxcrypt supera o * da musl por exatamente um byte, e esse byte é a diferença entre uma esquisitice e um CVE.

Há uma segunda lição aqui sobre shims de compatibilidade silenciosos. A decisão de * gracioso da musl existe para impedir que software quebrado trave, o que significa que impede que software quebrado seja percebido como quebrado. Um segfault é feio, mas também é um sinal: alguém abre um bug, alguém corrige o chamador. Engola o erro e o equívoco se acumula em silêncio por uma década. Estou cada vez mais convencido de que esta é a divisão mais profunda do design de sistemas: engenharia que resiste a catástrofes tende a falhar de forma alta e cedo, enquanto a engenharia que otimiza para a suavidade acumula dívida oculta até a conta chegar de uma vez.

E isso deveria aposentar um mito confortável: o de que código antigo e amplamente implantado é mais confiável por já ter sido testado em batalha. A idade não distribui o escrutínio de forma igual. Ela concentra a atenção nos caminhos quentes e deixa os frios se fossilizarem. Todo mundo audita a criptografia. Quase ninguém audita a cola sem graça onde o sshd conversa com a libc sobre senhas, porque esse código “funciona há uma eternidade”. A interseção de duas configurações minoritárias (sistemas com musl e, dentro deles, os que não usam PAM) é exatamente o tipo de lugar onde um bug pode viver sem ser incomodado por dez anos.

Como Corrigir o Bug sshole e Verificar Seus Logs

O patch é a parte fácil: o OpenSSH 10.6p2 fecha o buraco. No Alpine, é um comando só. Se você não puder atualizar hoje, a mitigação é igualmente direta e totalmente eficaz: desligue a autenticação por senha. Sem checagem de senha, sem bug. Sinceramente, faça as duas coisas, porque a autenticação só por chave já era a resposta certa antes deste bug existir e continuará sendo depois que todo mundo aplicar o patch. Isso ecoa uma lição vista em outros debates entre patch e mitigação: quando a mitigação é barata e completa, não seja esperto demais.

# patch: openssh 10.6p2 or later
apk upgrade openssh
# can't patch yet? passwords off, keys only
echo "PasswordAuthentication no" > /etc/ssh/sshd_config.d/00-no-passwords.conf
rc-service sshd restart
Uma lanterna iluminando impressões de log em cascata na escuridão, com uma linha brilhando em vermelho
Em algum lugar dentro de dez anos de logs de autenticação, uma senha muito longa pode ter aberto uma conta bloqueada.

Então vem a pergunta mais difícil: alguém chegou lá primeiro? Dez anos é um tempo de permanência longo. O que você procura são autenticações por senha bem-sucedidas em contas que nunca tiveram senha: usuários de deploy somente com chave, contas de serviço, identidades LDAP, root em máquinas Alpine. Logins bem-sucedidos nessas contas pelo método password deveriam ser raríssimos; qualquer ocorrência é um incêndio de cinco alarmes. A verdade incômoda é que o sshd registra o sucesso, não o tamanho da senha, então boa parte da evidência direta desaparece na hora de gravar. E, como já falamos antes, o log de autenticação na nuvem tem pontos cegos mesmo em dias bons: os logs do sshd em nível de instância, em VMs de vida curta, são exatamente onde eles moram. Pesquise no que você tem. Presuma que está incompleto.

O Que Dez Anos Silenciosos Deveriam Nos Ensinar

A descoberta em si merece tanta atenção quanto o bug. O pesquisador que encontrou o sshole não ficou olhando para a criptografia. Olhou para a fronteira sem graça onde o OpenSSH conversa com a libc, compilou o sshd em todas as configurações que conseguiu imaginar (glibc, musl, PAM, sem PAM, shadow, sem shadow) e jogou cerca de 17.600 logins malformados contra essa matriz. Hashes esquisitos, contas quebradas, senhas longas demais. Todos os builds concordaram em todos os logins, exceto um: musl sem PAM. Esse aceitou coisas que não deveria. Isso não é um achado de sorte; é cobertura. Testes diferenciais em matrizes de configuração são como você encontra os bugs que se escondem nas interseções, e é exatamente o tipo de trabalho exaustivo e incansável em que enxames de agentes se mostram assustadoramente bons. O próximo sshole não terá dez anos de silêncio.

A questão em aberto que deveria tirar o sono dos operadores é se esses dez anos realmente passaram sem que ninguém percebesse, ou se as pessoas erradas perceberam. Pense só no caso acidental: cole o conteúdo errado da área de transferência em um prompt de senha (metade de uma thread do Slack, uma chave privada SSH inteira, qualquer coisa acima de 257 caracteres) e, em uma máquina vulnerável, uma conta bloqueada simplesmente abre. Se você nunca errou um paste em um campo de senha, é mais cuidadoso do que qualquer pessoa com quem já trabalhei. Alguém, em algum lugar, já entrou em uma máquina onde não queria entrar e fechou o terminal em silêncio. Talvez muitos alguéns.

Então sim, aplique o patch. Desligue as senhas, com certeza. Mas depois reserve uma hora para procurar, nos seus próprios sistemas, os caracteres que fazem dois trabalhos: os valores sentinela, os fallbacks silenciosos, os ramos do tipo “isso nunca pode acontecer”, os shims de compatibilidade que ninguém lembra de ter escrito. Toda base de código madura tem esses elementos. Eles são invisíveis justamente porque nunca causaram um problema que alguém pudesse ver. Em algum lugar da sua stack, agora mesmo, existe um asterisco significando duas coisas ao mesmo tempo. Encontre-o antes que alguém com uma senha muito longa encontre.