← All posts

Nosso bloqueio após cinco senhas erradas nunca bloqueou ninguém

Authagonal·August 5, 2026

O bloqueio de conta é o mínimo dos mínimos. Cinco senhas erradas e a conta fica bloqueada por dez minutos, o que transforma a adivinhação de senhas on-line, de uma ameaça real, num beco sem saída: um atacante consegue cinco tentativas por conta a cada dez minutos, ou seja, nada. Nós tínhamos isso. O limite era cinco, a janela era dez minutos, o código que verificava estava correto, e funcionava perfeitamente toda vez que testávamos, porque toda vez que testávamos digitávamos a senha errada cinco vezes seguidas, uma após a outra, como uma pessoa.

Um atacante não é uma pessoa digitando uma após a outra. Um atacante envia quinhentas de uma vez. E quando fizemos isso, o contador que deveria parar em cinco mal se moveu, e a conta nunca foi bloqueada. Você podia lançar uma quantidade ilimitada de tentativas contra uma única conta, e o único controle projetado para deter exatamente isso ficava ali sem fazer nada, não porque a lógica estivesse errada, mas porque presumia que só lhe seria feita uma pergunta de cada vez.

Um contador que perde a conta

O código de login malsucedido fazia o óbvio. Diante de uma senha errada, ele carregava o usuário, somava um à sua contagem de tentativas falhas, verificava se o novo valor havia chegado a cinco, e salvava o usuário de volta. Ler, modificar, escrever. Escrito como uma pessoa pensaria a respeito, está correto.

Escrito como duas requisições que chegam no mesmo instante, não está. As duas leem a contagem, e as duas leem o mesmo valor, digamos três. As duas somam um e obtêm quatro. As duas escrevem quatro de volta. Aconteceram duas tentativas falhas, e a contagem foi de três para quatro. Um dos incrementos simplesmente desapareceu, sobrescrito pelo outro, porque ambas partiam do mesmo ponto de partida desatualizado e a segunda escrita pousou sobre a primeira como se a primeira nunca tivesse existido.

Agora dispare quinhentas senhas erradas de uma vez. Elas se empilham umas sobre as outras exatamente desta forma, cada uma lendo uma contagem baixa e escrevendo uma contagem pouco menos baixa, com os incrementos evaporando às centenas. O contador sobe um pouco à deriva e para bem longe de cinco. O limite de bloqueio nunca é cruzado, não porque a verificação esteja errada, ela é verificada fielmente todas as vezes, mas porque o número que ela verifica é uma mentira montada por uma multidão de requisições que se sobrescrevem todas umas às outras. A proteção contra força bruta é derrotada não por uma falha de lógica mas pela concorrência, que é o tipo de falha que passa em todo teste sequencial que você jamais escreverá e falha no instante em que encontra um atacante de verdade, a única parte com um motivo para enviar requisições em paralelo.

Onde esse bug morava importa. Ele não estava enterrado em alguma camada de armazenamento. Estava bem ali, no manipulador de login, à vista de todos, um ++ e um salvamento, o código mais natural do mundo. Nada em sua leitura sugere perigo. Você precisa estar pensando em dois deles rodando ao mesmo tempo para que o bug apareça, e ler código uma linha de cada vez é exatamente o estado mental em que você não está.

Tornar um contador atômico sobre um armazenamento que não sabe contar

A correção precisa tornar indivisível todo o ler-modificar-escrever: nenhuma outra tentativa pode se enfiar entre a nossa leitura e a nossa escrita. O interessante é que estamos sobre um armazenamento chave-valor sem operação de incremento. Você não pode dizer a ele "some um a este campo", que é a primitiva que um banco de dados com contadores atômicos lhe entregaria. Você só pode ler uma linha e escrever uma linha.

O que o armazenamento de fato lhe dá é uma escrita condicional. Cada linha carrega uma etiqueta de versão que muda a cada escrita, e você pode dizer: reescreva esta linha, mas só se a versão dela ainda for a que eu li. Se outra pessoa escreveu nesse meio-tempo, a versão mudou, e a sua escrita é rejeitada em vez de esmagar a dela em silêncio.

Isso transforma a corrida por incrementos perdidos numa colisão detectada. Leia a linha e a versão dela. Some um. Tente reescrever condicionado a essa versão. Se der certo, o seu incremento com certeza pousou sobre o valor que você leu, sem perder nada. Se for rejeitado, significa que outra tentativa chegou antes de você, então você relê a contagem agora mais alta e tenta de novo. Repita a cada rejeição, um punhado limitado de vezes, e cada incremento é forçado a se serializar atrás do último em vez de sobrescrevê-lo. A contagem volta a ser verdadeira, e o ataque de quinhentas de uma vez agora leva o contador direto até cinco e aciona o bloqueio, que é todo o propósito.

Levamos a mesma forma para o nosso outro backend de armazenamento, que também não tem incremento mas também oferece uma escrita condicional, desta vez condicionada ao próprio valor do contador em vez de a uma etiqueta de versão: escreva a nova contagem só se a contagem armazenada ainda for a que eu li. A mesma garantia, expressa no vocabulário daquele armazenamento. A lição vai além de qualquer banco de dados específico: quando lhe falta um incremento atômico, uma escrita condicional mais um laço de repetição reconstrói um, porque a condição é o que recusa a sobrescrita desatualizada que estava perdendo a sua contagem.

Vale notar uma assimetria deliberada. Tornamos atômico o caminho de falha e deixamos o caminho de sucesso, que zera o contador em um login correto, como uma simples escrita em que o último a escrever vence. A concorrência não ameaça uma redefinição: vários logins bem-sucedidos disputando querem todos escrever zero, e não importa qual vence. Só o incremento é adversário, porque só o incremento é aquilo que um atacante tenta manter baixo. Você gasta o custo das escritas condicionais e das repetições onde um adversário empurra, e não onde ele não empurra.

Parta do princípio de que alguém está contando

O verdadeiro valor desse bug foi o estado de espírito que ele impôs, porque no mesmo dia em que o corrigimos saímos procurando tudo o mais que presumia em silêncio uma-requisição-por-vez ou presumia que ninguém estava medindo, e encontramos um pequeno cacho de irmãos.

Nossas respostas de login diziam aos atacantes quais contas existiam. Uma conta inexistente, uma desativada, uma bloqueada e uma senha errada produziam cada uma uma resposta distinguível, e o caminho do usuário inexistente até retornava mais rápido porque pulava por completo o hash da senha, de modo que um atacante podia descobrir quem tem uma conta observando ou o status ou o relógio. Unificamos as respostas: um usuário desconhecido e uma senha errada agora retornam a resposta idêntica, e um usuário desconhecido continua sendo verificado contra um hash fictício fixo para que o tempo seja o mesmo, exista a conta ou não. A conta existe ou não, e você não consegue saber de fora.

O cadastro tinha o mesmo vazamento numa forma mais difícil, porque você não pode simplesmente dizer sempre sucesso quando alguém tenta cadastrar um e-mail já ocupado, já que isso pareceria entregar a conta a essa pessoa. Então um cadastro para um endereço já registrado agora retorna exatamente o mesmo sucesso neutro que um cadastro novo retorna, gasta o mesmo tempo de hash de senha, não cria nada, e em vez disso envia ao verdadeiro dono um e-mail dizendo que alguém tentou cadastrar o endereço dele. O atacante não descobre nada. O verdadeiro dono é avisado em silêncio.

E o formulário de redefinição de senha era um canhão de e-mails: sem limite por endereço, então você podia nos fazer disparar e-mails de redefinição para qualquer endereço repetidamente, um ataque voltado contra um terceiro e contra a nossa própria reputação de envio. Agora ele limita a taxa por endereço de destino e, acima do teto, pula o envio enquanto retorna a mesma resposta de sucesso de sempre, para que a própria limitação não vire uma nova forma de sondar quais endereços existem.

Cada um desses casos é a lição do bug de bloqueio vestida com outra roupa. Projete como se um atacante estivesse enviando requisições em paralelo, cronometrando suas respostas e contando. Porque a única coisa da qual você pode ter certeza é de que a pessoa atacando o seu login não está fazendo isso uma educada requisição de cada vez.

Por que um contador de segurança tem de ser testado sob concorrência

Um bug de concorrência em um controle de segurança é pior que um bug de concorrência em qualquer outro lugar, por um motivo específico: a parte com o motivo mais forte para dispará-lo é o atacante, e as condições que o disparam, muitas requisições simultâneas, são exatamente as condições que um ataque cria e que um teste quase nunca cria. Então um bloqueio, uma limitação de taxa, uma cota, qualquer controle construído sobre um contador, não pode ser validado incrementando-o em um laço e assistindo-o disparar. Ele tem de ser atingido sob concorrência, porque a concorrência é o ambiente nativo do atacante, e a versão sequencial do teste prova apenas que o seu controle funciona contra um adversário que concordou em ser educado.

E quando você endurece o contador, a ferramenta em um armazenamento sem incrementos atômicos é a escrita condicional: leia a versão, escreva só se ela não tiver mudado, tente de novo se tiver. Ela reconstrói o incremento atômico que o banco de dados não lhe deu, a partir da única garantia que ele lhe deu.

Se você prefere rodar sobre um provedor de identidade que já parte do princípio de que o atacante está contando em paralelo, a Authagonal construiu esses controles para serem atingidos todos de uma vez, porque essa era, de qualquer forma, a única maneira pela qual eles algum dia seriam atingidos de verdade.