Força Bruta e Credential Stuffing: Por Que o Django Nunca Diz 'Tentativas Demais' — e Como Fazê-lo Dizer
Django Security Series — Post 11 | Série III: Autenticação e Sessão
OWASP A07:2021 — Identification & Authentication Failures | Tempo de leitura: ~20 min
🧪 Rode você mesmo. Este ataque vem como um laboratório executável em django-security-lab: dois endpoints de login e uma vítima cuja senha deliberadamente fraca guarda uma flag. A view vulnerável faz rate limit no cabeçalho
X-Forwarded-For, controlado pelo cliente, então rotacionar um único valor forjado a partir de uma só máquina percorre a wordlist inteira sem throttle e a flag cai; a view segura conta falhas por conta e bloqueia independentemente da origem. Reproduza os dois pela linha de comando comcurl.
A Série II tratava de usuários que já estavam dentro. O IDOR lia objetos que não lhes pertenciam, a escalação de privilégios subia até staff, o CSRF tomava emprestado um navegador que já estava logado e o mass assignment gravava colunas que o formulário nunca renderizou. Todos esses ataques pressupunham a existência de uma sessão válida. A Série III dá um passo atrás, até o momento anterior a isso: provar quem você é.
E ela abre com o ataque menos sofisticado de toda a série. Não há payload aqui. Nenhuma sintaxe injetada, nenhuma sequência de travessia, nenhum corpo JSON forjado. O atacante envia um nome de usuário e uma senha, exatamente como o formulário pretende, e depois faz isso de novo. A única coisa que transforma isso em um ataque é o número.
O que eu não esperava, quando comecei a ler para este post, era o quanto o Django é explícito ao dizer que não vai ajudar. Eu havia presumido que existia alguma proteção embutida modesta, um pequeno atraso, um contador leve, alguma coisa. Não existe, e a documentação não faz rodeios: "O Django não limita a taxa das requisições para autenticar usuários. Para se proteger contra ataques de força bruta ao sistema de autenticação, você pode considerar implantar um plugin do Django ou um módulo do servidor web para limitar essas requisições." É essa a posição inteira. O framework que escapa seus templates por padrão, parametriza suas queries por padrão e rotaciona a chave de sessão no login vai responder a um número ilimitado de tentativas de senha sem reclamar.
Pior do que um controle ausente é um que parece proteção. Um rate limiter chaveado em um valor que o cliente controla passa em um teste ingênuo — cinco tentativas de um endereço, a sexta bloqueada — e ainda assim pode ser desligado a partir de um único notebook adicionando um cabeçalho HTTP a cada requisição. É o tipo de bug que sobrevive à revisão, porque o revisor que procura uma falha encontra um limiter e segue em frente. Rodar um site de petições de verdade me ensinou a desconfiar exatamente desse tipo de "proteção"; o laboratório que acompanha o post reproduz isso de ponta a ponta, e a maior parte deste post é sobre por que a chave que ele escolheu é a errada.
O Ataque: O Que É e Como Funciona
Força bruta é adivinhação. Contra um endpoint de login, significa tentar senhas contra uma conta até que uma funcione, e sua economia é definida inteiramente por dois números: quantas tentativas por segundo você consegue fazer e qual o tamanho do espaço de senhas plausíveis. Esse segundo número é muito menor do que as pessoas imaginam, e é por isso que o ataque sobrevive. Ninguém enumera todas as 96^12 senhas possíveis. Tentam 123456, depois senha123, depois o nome da empresa com um 1! no final, e contra uma base de usuários grande o suficiente alguma delas acerta.
Credential stuffing é a mutação moderna e muito mais perigosa, e ela inverte a aritmética. Em vez de muitas senhas contra uma conta, o atacante pega pares usuário/senha vazados do vazamento de outra pessoa e os reproduz contra o seu login. Eles não estão adivinhando. Estão testando uma hipótese, e a hipótese é a reutilização de senhas. Os corpora públicos de vazamentos chegam à casa dos bilhões de pares de credenciais, então o trabalho do atacante se reduz a uma consulta e um laço. As taxas de sucesso por tentativa são baixas, frequentemente citadas em torno de uma fração de um por cento, mas uma fração de um por cento de dez milhões de tentativas ainda são dezenas de milhares de contas, e cada uma delas é um login real com uma senha real. Nada foi quebrado. Seu site apenas confirmou que uma senha já pública em outro lugar também funciona aqui.
A consequência para quem defende é a parte que levei um tempo para internalizar. Um ataque de força bruta é barulhento em uma conta a partir de um lugar. Credential stuffing é silencioso em dez milhões de contas a partir de dez milhões de lugares, uma tentativa cada. Essas duas formas exigem controles genuinamente diferentes, e um controle calibrado para a primeira costuma ser completamente cego para a segunda. Essa assimetria é a tese deste post, e é onde meu próprio projeto falhou.
A razão pela qual o endpoint de login é um alvo tão bom é que ele foi projetado para aceitar entrada anônima e responder honestamente. Todos os outros ataques desta série precisavam contrabandear algo por uma verificação. Este usa a porta da frente exatamente como ela foi construída.
O ferramental é pouco glamouroso e amplamente disponível. Hydra, Patator, Burp Intruder ou quarenta linhas de Python com requests dão conta, e os kits modernos de credential stuffing (o Sentry MBA e seus descendentes) são construídos em torno de arquivos de configuração que descrevem o formulário de login de um alvo, suas assinaturas de sucesso e falha e sua rotação de proxies. Esse último item é o importante, e é o que derrota a maioria das defesas ingênuas: o atacante distribui seu tráfego por um pool de proxies residenciais ou uma botnet, de modo que nenhum endereço IP isolado seja jamais interessante.
Antes de a adivinhação começar, normalmente existe uma etapa de reconhecimento que não custa nada ao atacante: descobrir quais nomes de usuário existem. Se o seu login diz "usuário não encontrado" para uma entrada e "senha incorreta" para outra, você entregou um oráculo, e o atacante pode enumerar contas válidas antes de gastar uma única tentativa em uma senha. O mesmo vazamento aparece no cadastro ("esse e-mail já está em uso"), na recuperação de senha ("não encontramos esse endereço") e, de forma mais sutil, no tempo de resposta, ao qual vou voltar porque o Django tem uma defesa específica e um pouco estranha para isso.
Incidentes Reais
Dunkin' Brands — Termo de Ajustamento do Procurador-Geral de Nova York (ataques a partir de 2015; acordo em 15 de setembro de 2020)
Escolhi este caso em vez de vazamentos mais chamativos porque é a rara situação em que um regulador escreveu, em um documento exigível, o que uma empresa deveria ter feito a respeito de credential stuffing. A partir do início de 2015, atacantes executaram ataques de credential stuffing contra o DD Perks, o programa de fidelidade da Dunkin', cujas contas guardam valor armazenado que pode ser gasto ou transferido. Vale a pena parar aqui: os atacantes não estavam atrás dos sistemas da Dunkin'. Estavam atrás de dinheiro que os clientes já haviam carregado nos cartões, o que tornava cada conta comprometida diretamente monetizável, e é por isso que um programa de fidelidade de donuts valia a industrialização de um ataque.
O MITRE ATT&CK mapeia essa família como T1110 — Brute Force, e o caso Dunkin' é exatamente a subtécnica T1110.004 — Credential Stuffing: os atacantes não estavam adivinhando senhas, e sim reproduzindo pares vazados de outras brechas. O truque inverso, T1110.003 — Password Spraying, tenta uma senha muito comum contra todas as contas justamente para ficar abaixo dos limites por conta. Seja qual for a variante que acerte, o desdobramento é T1078 — Valid Accounts: a partir do primeiro sucesso o atacante não está mais atacando nada; ele está simplesmente logado, e todo controle da Série II é o que separa ele dos dados.
O desenvolvedor terceirizado do aplicativo da Dunkin' alertou a empresa repetidamente sobre as tentativas em curso e entregou uma lista de quase 20.000 contas comprometidas em uma única amostra de cinco dias. Segundo o Procurador-Geral, a Dunkin' então deixou de investigar, deixou de notificar os clientes afetados, deixou de redefinir suas senhas e deixou de implementar salvaguardas contra novos ataques; a própria investigação do órgão identificou depois milhares de contas adicionais comprometidas entre 2015 e 2018. O termo resultante, anunciado em 15 de setembro de 2020, exigiu US$ 650.000 em multas e custas, além de notificação aos clientes, redefinição de senhas, ressarcimento pelo uso fraudulento dos cartões e o compromisso de manter salvaguardas contra credential stuffing e de seguir procedimentos de resposta a incidentes dali em diante. As alegações foram formuladas com base na lei de notificação de vazamentos de Nova York (GBL § 899-aa) e em suas disposições de proteção ao consumidor (Executive Law § 63(12), GBL §§ 349–350), estas últimas porque a Dunkin' havia dito aos clientes que adotava medidas razoáveis para proteger suas informações enquanto, segundo o Procurador-Geral, não fazia nada disso.
A lição jurídica é aquela à qual eu sempre volto, como alguém que se formou em Direito antes de escrever Django: a ciência do fato é o que converte um problema técnico em responsabilidade. A Dunkin' não foi penalizada por ter sido atacada, e credential stuffing não é uma vulnerabilidade em nenhum código que a Dunkin' escreveu. Ela foi penalizada por saber e não agir. Sob a LGPD, a exposição análoga é o dever de comunicação de incidentes do art. 48 lido em conjunto com o art. 44, que considera irregular o tratamento que não fornece a segurança que o titular dele pode esperar, e a própria resolução da ANPD estabelece o prazo de comunicação. Então, quando seus logs registram user_login_failed dez mil vezes contra uma conta durante a noite e ninguém olha, você não apenas deixou passar um ataque. Você criou um registro documentado e datado de que sabia. Esse registro é passível de produção probatória.
Proteções Padrão do Django
Não há throttle. Não há bloqueio. Não há contador. O LoginView vai processar seu décimo-milionésimo POST malsucedido com a mesma serenidade do primeiro, e o authenticate() não guarda memória alguma da tentativa anterior. Para um framework cuja postura de segurança inteira é "padrões seguros, e aqui estão as saídas de emergência", este é o único lugar em que o padrão simplesmente não existe, e a documentação diz isso na linguagem mais direta que ela usa em qualquer lugar.
O que o Django oferece é uma camada de armazenamento de senhas que torna cada tentativa cara de verificar e um conjunto de primitivas que facilitam acoplar os controles que faltam. O hasher padrão é o PBKDF2 com uma contagem de iterações deliberadamente alta, o que é uma defesa real, ainda que indireta, contra força bruta: ele define um piso para a velocidade com que o seu servidor consegue verificar uma senha candidata, o que limita a taxa de tentativas do atacante como efeito colateral do custo de CPU, e é o que torna um ataque offline contra um dump de hashes caro em vez de trivial. (O Post 18 é onde o hashing recebe tratamento próprio; aqui ele importa apenas como a coisa que faz cada tentativa custar algo.)
Existe um padrão genuíno e deliberado que vale conhecer, e ele está em ModelBackend.authenticate:
try:
user = UserModel._default_manager.get_by_natural_key(username)
except UserModel.DoesNotExist:
# Run the default password hasher once to reduce the timing
# difference between an existing and a nonexistent user (#20760).
UserModel().set_password(password)
else:
if user.check_password(password) and self.user_can_authenticate(user):
return user
Repare no que esse ramo except faz. Quando o nome de usuário não existe, o Django gera o hash da senha enviada mesmo assim e joga o resultado fora. Ele está desperdiçando CPU de propósito, sem nenhuma razão funcional, para que um usuário inexistente leve aproximadamente o mesmo tempo para ser rejeitado que um usuário real com a senha errada. Sem isso, "usuário não encontrado" retornaria em microssegundos enquanto uma conta real queimaria iterações de PBKDF2, e só o tempo de resposta enumeraria sua tabela de usuários inteira.
Quero destacar isso como o que genuinamente me surpreendeu ao escrever este post, porque contraria meu modelo mental do framework. O Django não vai limitar uma única tentativa de login para você, mas vai queimar um ciclo completo de PBKDF2 para esconder um sinal de temporização que você provavelmente nunca perceberia. É um par de prioridades estranho até você ver a lógica: o oráculo de temporização é invisível e impossível de corrigir a partir do código da aplicação, então o framework o fecha; o throttle é visível, e é política, então o framework se recusa a escolher sua política por você. Eu ainda acho que um contador padrão no estilo do axes teria evitado mais comprometimentos reais do que a correção de temporização jamais evitou. Mas agora entendo o raciocínio, e antes não entendia.
O que o Django protege automaticamente:
- Verificação de senha cara (PBKDF2 por padrão), que limita a taxa de tentativas online e torna dumps de hash caros de quebrar offline.
- Uma resposta de tempo quase constante para nomes de usuário inexistentes, via o hash-e-descarta deliberado acima.
- Rotação da chave de sessão no
login(), que é assunto do Post 12, não deste.
O que o Django NÃO protege automaticamente:
- Qualquer limite sobre o número de tentativas de autenticação, por IP, por conta ou global.
- Bloqueio de conta após falhas repetidas.
- Detecção de bots ou automação no formulário de login.
- Endpoints de token e JWT do DRF, que são endpoints de login com outra roupa e vêm sem throttle algum.
- Enumeração através das suas mensagens: suas views customizadas de cadastro e de recuperação podem desfazer a defesa de temporização em uma única string de erro prestativa.
Padrão Vulnerável: O Que NÃO Fazer
Padrão 1 — A view de login pelada
Isto não é tanto um erro quanto uma omissão, e é o que você obtém seguindo o tutorial e parando:
# INSEGURO — correto, idiomático e completamente sem throttle
from django.contrib.auth.views import LoginView
from django.urls import path
urlpatterns = [
path('login/', LoginView.as_view(), name='login'),
]
Nada aqui está errado. Não há código ruim para apontar, e é exatamente por isso que sobrevive à revisão: o revisor procura uma falha e não encontra nenhuma, porque a falha é um controle que nunca foi escrito. A mesma forma vale para o DRF, onde o obtain_auth_token e o TokenObtainPairView do simplejwt são endpoints de login que por acaso devolvem um token em vez de um cookie, e estão igualmente abertos. Um projeto DRF que configura cuidadosamente DEFAULT_THROTTLE_CLASSES para seus endpoints de dados e deixa o endpoint de token sem throttle limitou tudo, menos a porta.
Padrão 2 — Um rate limiter chaveado em um cabeçalho que o cliente controla
Este é pior que o Padrão 1, porque produz a sensação de proteção. É também, quase literalmente, o que encontrei no meu próprio projeto:
# INSEGURO — o "IP do cliente" é o que o cliente disser que é
def get_client_ip(request):
x_forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR')
if x_forwarded_for:
return x_forwarded_for.split(',')[0].strip() # ← controlado pelo atacante
return request.META.get('REMOTE_ADDR', '')
@rate_limit(max_requests=5, window=300) # 5 tentativas por 5 min "por IP"
def login_view(request):
...
O X-Forwarded-For é uma trilha de migalhas salto a salto, e cada proxy acrescenta a ela. Quando uma requisição chega a um dyno do Heroku, o roteador já acrescentou o endereço real do cliente ao que o cliente enviou, então o cabeçalho fica <o que o atacante digitou>, <IP real do cliente>. Pegar split(',')[0] lê a entrada do atacante. Rotacionar esse cabeçalho a cada requisição entrega ao atacante um balde de rate limit novinho toda vez, a partir de uma máquina, sem pool de proxies nenhum. A entrada correta a confiar é aquela que o seu proxy acrescentou, que em um deploy com um único proxy é a última, não a primeira, e o número de saltos em que você pode confiar é conhecimento de infraestrutura que precisa ser configurado, não adivinhado.
Padrão 3 — Limites por IP como único controle
# INSEGURO — irrelevante contra credential stuffing distribuído
@ratelimit(key='ip', rate='5/m', block=True)
def login_view(request):
...
O que dá errado: nada, no eixo que ele mede. Isso impede corretamente que um IP faça seis tentativas por minuto. Não tem opinião alguma sobre cem mil IPs fazendo uma tentativa cada contra cem mil contas diferentes, que é exatamente a forma do ataque que levou o dinheiro dos clientes da Dunkin'. Um limite por IP responde "esta origem está barulhenta?". A pergunta que você também precisa ver respondida é "esta conta está sob ataque?", e nenhuma calibragem da primeira pergunta jamais vai produzir a segunda.
Implementação Segura: O Jeito Django
Regra 1 — Bloqueie a conta, não só o endereço (django-axes)
O django-axes é a resposta canônica, e a razão de ser a primeira regra é que é o único controle aqui que contabiliza falhas por conta. Instale, adicione o middleware e coloque o backend dele em primeiro lugar:
# settings.py
INSTALLED_APPS = [..., 'axes']
MIDDLEWARE = [..., 'axes.middleware.AxesMiddleware'] # por último
AUTHENTICATION_BACKENDS = [
'axes.backends.AxesStandaloneBackend', # precisa vir primeiro
'django.contrib.auth.backends.ModelBackend',
]
AXES_FAILURE_LIMIT = 5
AXES_COOLOFF_TIME = 1 # horas; None significa bloqueio até reset manual
AXES_RESET_ON_SUCCESS = True
AXES_LOCKOUT_PARAMETERS = [["username"], ["ip_address"]]
Essa última configuração é a que eu errei, e errei no projeto em que você está lendo isto. O AXES_LOCKOUT_PARAMETERS tem como padrão ["ip_address"]. Eu havia instalado o axes neste blog, definido um limite de falhas e um cool-off, visto o template de bloqueio renderizar durante os testes e concluído que tinha bloqueio de conta. Não tinha. Tinha um bloqueio por IP com um template mais bonito que o do meu outro projeto, e ele era cego para stuffing distribuído exatamente da mesma forma. O aninhamento é significativo e fácil de ler errado: a lista externa é OU, as listas internas são E. [["username"], ["ip_address"]] bloqueia quando ou o nome de usuário ou o IP cruza o limite. [["username", "ip_address"]] (uma única lista interna) só bloqueia um par usuário-vindo-de-um-IP-específico, o que soa mais rígido e é, de fato, o mais fraco dos três, porque rotacionar qualquer uma das metades zera o contador.
Aqui está a parte sobre a qual eu genuinamente não me decidi. Bloquear por nome de usuário significa que qualquer pessoa que saiba seu e-mail pode te trancar para fora da sua própria conta falhando cinco logins, o que converte um controle de autenticação em uma ferramenta de negação de serviço apontada para seus usuários. O axes oferece AXES_LOCK_OUT_BY_USER_OR_IP e ajuste de cool-off para suavizar isso, e o conselho comum é que um cool-off curto (minutos, não horas) somado a alertas é o equilíbrio certo. Optei pela configuração OU e um cool-off de uma hora neste blog porque é um site pessoal onde me trancar para fora é um aborrecimento, não um evento de negócio. Não teria a mesma confiança em uma plataforma cívica pública, onde trancar um cidadão para fora da assinatura durante o prazo de uma petição tem consequências que eu preferiria não ter de explicar a um regulador. É um trade-off real sem resposta limpa, e quem disser o contrário está te vendendo alguma coisa.
Regra 2 — Limite na chave certa, a partir de uma fonte confiável
Rate limiting e bloqueio são controles diferentes, e vale ser preciso sobre a diferença: o limiter reduz o volume vindo de uma origem, o bloqueio protege uma conta de falhas acumuladas. Use os dois, e chaveie o limiter tanto no nome de usuário quanto no endereço:
from django_ratelimit.decorators import ratelimit
from django.utils.decorators import method_decorator
@method_decorator(ratelimit(key='ip', rate='10/m', method='POST', block=True), name='post')
@method_decorator(ratelimit(key='post:username', rate='5/5m', method='POST', block=True), name='post')
class ThrottledLoginView(LoginView):
pass
Dois detalhes carregam a maior parte do valor. O method='POST' faz com que visualizações de página não consumam o orçamento, o que parece uma nota de rodapé e não é; decore o dispatch em vez do post e um usuário que recarrega a página de login seis vezes leva um 429, enquanto um atacante que faz POST direto passa incólume. E o key='post:username' te dá a visão por conta que o key='ip' estruturalmente não consegue dar.
Para que isso signifique alguma coisa, o Django precisa saber qual endereço é realmente o seu. Atrás de exatamente um proxy confiável, pegue a última entrada em vez da primeira, e só confie no cabeçalho quando você souber que há um proxy na sua frente:
# settings.py — só se você estiver atrás de um proxy que você controla
SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https')
def get_client_ip(request):
"""Confie apenas no salto que o nosso próprio proxy acrescentou."""
xff = request.META.get('HTTP_X_FORWARDED_FOR')
if xff and settings.TRUST_PROXY_HEADERS:
return xff.split(',')[-1].strip() # ← a entrada do proxy, não a do cliente
return request.META.get('REMOTE_ADDR', '')
Se você não está atrás de um proxy, ignore o X-Forwarded-For por completo e use o REMOTE_ADDR, que o cliente não consegue forjar.
Regra 3 — Torne a automação cara
Bloqueios e limites contam tentativas. Uma verificação de bot ataca o modelo de custo do atacante, que é o único controle desta lista que escala contra uma botnet. O Cloudflare Turnstile é o que meus dois projetos usam, e validar o token no servidor dentro do clean() do formulário é toda a integração: se o token estiver ausente ou inválido, a autenticação nunca roda. Duas propriedades importam mais que o widget. Os tokens são de uso único, então o Cloudflare devolve timeout-or-duplicate em uma repetição e um token capturado não pode ser espalhado por uma lista de credenciais. E o validador precisa falhar fechado: um erro de rede ao falar com o Cloudflare, ou uma chave secreta ausente, tem que lançar exceção, não retornar True. Uma verificação de bot que falha aberta sob carga é uma verificação de bot que desaparece exatamente quando está sendo atacada.
Regra 4 — Não entregue a lista de usuários
A defesa de temporização do Django só ajuda se o seu próprio código não a desfizer. Um cadastro que diz "esse e-mail já está registrado", um fluxo de recuperação que diz "nenhuma conta com esse endereço" ou um login que distingue "usuário desconhecido" de "senha incorreta" reconstroem o oráculo que o Django gastou um ciclo de PBKDF2 para esconder. Diga a mesma coisa sempre ("Se o e-mail estiver cadastrado, você receberá instruções") e aceite que a UX marginalmente pior é o preço. Limite também o endpoint de recuperação por endereço de e-mail, já que é um caminho adjacente ao login que revela a existência da conta e dispara e-mail sob demanda.
Checklist de Prevenção de Força Bruta
| Controle | O que cobre |
|---|---|
| Hashing de senha PBKDF2 (padrão do Django) | Limita a taxa de tentativas online via custo de CPU; torna um dump de hashes caro de quebrar offline |
Hash-e-descarta do ModelBackend para usuários inexistentes (padrão do Django) |
Elimina o oráculo de temporização que enumeraria nomes de usuário válidos |
django-axes com AXES_LOCKOUT_PARAMETERS = [["username"], ["ip_address"]] |
Contagem de falhas por conta — o único controle aqui que enxerga stuffing distribuído |
django-ratelimit chaveado em post:username e ip, com method='POST' |
Reduz volume por origem e por conta sem penalizar carregamentos de página |
IP do cliente ciente de proxy confiável (último salto do XFF, ou REMOTE_ADDR) |
Impede que um X-Forwarded-For forjado crie um balde de limite novo a cada requisição |
| Cloudflare Turnstile (ou equivalente) validado no servidor, falhando fechado | Aumenta o custo por tentativa contra botnets, onde a contagem por IP é cega |
ScopedRateThrottle do DRF nos endpoints de obtenção de token/JWT |
Fecha o endpoint de login que não se chama LoginView |
| Respostas uniformes no login, no cadastro e na recuperação | Nega a etapa de enumeração que precede a adivinhação |
Alertas sobre volume de user_login_failed, com um identificador utilizável |
Converte logs em resposta; um IP com hash que você não pode bloquear é registro, não controle |
| MFA (Post 15) | A camada que sobrevive a uma senha correta |
A Visão do Analista
Força bruta é onde o vocabulário de controles de um analista CySA+ prova seu valor, porque nenhum controle isolado daquele checklist cobre o ataque inteiro — o empilhamento é a defesa. O hash caro de PBKDF2 é um controle preventivo que limita a taxa de tentativas online; o bloqueio por conta (django-axes) é um controle preventivo contra a adivinhação sustentada em uma conta; o rate limiting é um estrangulamento detectivo-e-responsivo sobre o volume de uma origem; e o Turnstile e o MFA são controles compensatórios que continuam de pé quando a própria senha é fraca ou já é conhecida. Leia essa lista contra as duas formas do ataque e os pontos cegos se encaixam de propósito: um limite por IP enxerga uma origem barulhenta mas é cego para stuffing distribuído, enquanto um bloqueio por conta enxerga a conta visada mas pode ser transformado em negação de serviço contra os seus próprios usuários. Defesa em profundidade aqui não é slogan — é a admissão específica de que cada controle tem uma lacuna que outro está ali para cobrir.
O hábito que o ataque recompensa é uma única pergunta feita a todo contador que você implanta: em que isto está chaveado, e quem controla esse valor? Um limite chaveado em X-Forwarded-For[0] deixa o atacante escrever a resposta; um limite chaveado no IP de origem responde a uma pergunta que o stuffing distribuído parou de fazer anos atrás; só uma chave por conta responde "esta conta está sob ataque?". A mesma disciplina segue da prevenção para a detecção e a resposta: um pico de eventos user_login_failed só é um controle se o identificador que você registra for um sobre o qual você pode agir — um endereço com hash que você não consegue colocar em uma regra de firewall é um registro do ataque, não uma resposta a ele.
Detectando Automaticamente
Testando Sua Defesa
O incômodo de testar esse controle é que a asserção é sobre a sexta requisição, então o teste precisa fazer cinco reais antes, e precisa ser honesto sobre qual chave está exercitando.
# tests/test_brute_force.py
from django.contrib.auth.models import User
from django.core.cache import cache
from django.test import TestCase, override_settings
from django.urls import reverse
@override_settings(AXES_FAILURE_LIMIT=5, AXES_COOLOFF_TIME=1,
AXES_LOCKOUT_PARAMETERS=[["username"], ["ip_address"]])
class BruteForceTests(TestCase):
def setUp(self):
cache.clear()
self.url = reverse('login')
self.user = User.objects.create_user('vitima', password='corr3ct-h0rse!')
def tearDown(self):
cache.clear()
def test_account_locks_after_five_failures(self):
"""A 6ª senha errada precisa ser recusada, não avaliada."""
for _ in range(5):
self.client.post(self.url, {'username': 'vitima', 'password': 'errada'})
response = self.client.post(self.url, {'username': 'vitima', 'password': 'errada'})
self.assertEqual(response.status_code, 403) # bloqueio do axes
def test_lockout_survives_ip_rotation(self):
"""A regressão que importa: uma conta, muitos endereços de origem."""
for i in range(5):
self.client.post(self.url, {'username': 'vitima', 'password': 'errada'},
REMOTE_ADDR=f'203.0.113.{i}')
response = self.client.post(self.url, {'username': 'vitima', 'password': 'errada'},
REMOTE_ADDR='203.0.113.99')
self.assertEqual(response.status_code, 403) # falha se chaveado só por IP
def test_correct_password_is_refused_while_locked(self):
"""Um bloqueio que deixa a senha certa passar não é um bloqueio."""
for _ in range(5):
self.client.post(self.url, {'username': 'vitima', 'password': 'errada'})
response = self.client.post(self.url, {'username': 'vitima',
'password': 'corr3ct-h0rse!'})
self.assertNotIn('_auth_user_id', self.client.session)
def test_page_views_do_not_consume_the_budget(self):
"""GET não pode sofrer throttle; só POST é uma tentativa de autenticação."""
codes = [self.client.get(self.url).status_code for _ in range(10)]
self.assertEqual(set(codes), {200})
O test_lockout_survives_ip_rotation é o primeiro a escrever. É o único teste desse arquivo que falha em uma configuração chaveada por IP — a configuração errada de que trata este post inteiro.
Um teste unitário prova o bloqueio por conta; o bypass em si é mais fácil de sentir pela linha de comando. Rotacione um X-Forwarded-For forjado de uma única máquina e observe se o limite chega a disparar:
# Um cabeçalho forjado cria um balde novo? Rotacione o XFF de uma máquina só.
for i in $(seq 1 20); do
curl -s -o /dev/null -w "%{http_code} " \
-X POST https://staging.example.com/login/ \
-H "X-Forwarded-For: 10.0.0.$i" \
-d "username=vitima&password=errada$i"
done; echo
# Esperado: 200 200 200 200 200 429 429 ... Ruim: vinte 200s.
# Controle: mesmo laço, sem o cabeçalho forjado. Se este bloquear e o de cima não,
# seu limiter está chaveado em entrada controlada pelo cliente.
Fazendo a Varredura
Este é o post da série em que os scanners voltam de mãos vazias — e esse resultado vale tanto quanto qualquer achado. O Bandit percorre a AST do Python atrás de construções arriscadas; rodado contra o laboratório, ele reporta apenas um B105 na senha deliberadamente fraca do seed, nada em nenhuma das duas views de login. Os pacotes comunitários do Semgrep (p/django, p/python, p/owasp-top-ten) — os rulesets que um analista roda por padrão — reportam zero. E como o post de CSRF (Post 8) nos ensinou que uma regra pode se esconder no tier de auditoria do Semgrep, a passada de detecção checou isso também (r/python.django, r/python); ela só aparece com ruídos não relacionados, nada sobre o throttle.
bandit -r labs/post_11_brute_force/ # só B105 na senha do seed
semgrep scan --config p/django --config p/python --config p/owasp-top-ten labs/post_11_brute_force/ # 0 achados
semgrep scan --config r/python.django --config r/python labs/post_11_brute_force/ # tier de auditoria: só ruídos não relacionados
Nenhum tier encontra — e, ao contrário dos outros casos de "miss" desta série, nenhuma regra customizada encontraria também. As duas falhas derrotam a análise estática por motivos diferentes. A view vulnerável confia na chave errada: se dá para confiar em X-Forwarded-For[0] é um juízo sobre quem controla aquele valor, não uma chamada perigosa que uma regra consiga casar. E a lacuna mais profunda — nenhum contador por conta sequer existe — é a ausência de código, que nenhum padrão consegue apontar. Uma regra estática consegue sinalizar uma chamada perigosa; não consegue sinalizar uma que falta. É essa a linha entre este laboratório e o IDOR ou o mass assignment (Post 6, Post 7): as ferramentas padrão também erraram aqueles, mas uma forma sintática sobrevivia — uma consulta sem escopo de dono, um fields='__all__' — então uma regra customizada ainda podia disparar. Aqui não sobra nada contra o que escrever uma regra, e uma regra seria encenação.
O sinal aqui é dinâmico — mas deliberadamente não é um scanner DAST, e vale ser preciso sobre o porquê, porque o reflexo de um analista CySA+ é recorrer a um. Ferramentas de DAST como o sqlmap (Post 1) funcionam disparando um payload forjado e lendo uma assinatura na resposta; força bruta não deixa assinatura alguma para ler. Seu único sintoma é volume ao longo de muitas requisições, então "detectar" isso é na verdade um teste de carga — e a única forma de um scanner ter certeza de que falta um limite é martelar o endpoint até ele bloquear ou cair, o que é negação de serviço, não varredura. É por isso que o OWASP ZAP não traz regra ativa para "falta de rate limit", e por isso não há um equivalente push-button do sqlmap aqui.
Pior: uma ferramenta genérica erraria este laboratório ao contrário. Aponte o Hydra ou um fuzzer do ZAP para a view vulnerável sem rotacionar o cabeçalho e ele é bloqueado após cinco tentativas — então reporta o endpoint como protegido e segue em frente, um falso negativo limpo. O bypass só aparece para uma sonda que já sabe forjar e rotacionar o X-Forwarded-For, e esse conhecimento vem de ler o modelo de ameaças, não do conjunto de regras de nenhum scanner. Então a sonda que mantemos é exatamente essa, tornada determinística: aponte o curl (ou o test client do Django, em tests.py) para o laboratório no ar, rotacione o cabeçalho forjado e observe o limite deixar de disparar — a mesma verificação manual acima, promovida ao que de fato prova o bug. Uma execução scriptada de credential stuffing (Hydra, Patator, um laço de fuzzing do ZAP) mostraria o mesmo efeito, mas mede tempo e só se versionaria como um transcript não determinístico, então não há artefato de DAST capturado. A lição CySA+ honesta do post inteiro está aqui: alguns achados vêm de ler o modelo de ameaças e rodar uma sonda, nunca de um scanner. As execuções capturadas e a análise completa estão em scans/.
Força bruta é o ataque sem nada de inteligente dentro, e é por isso que a defesa precisa ser deliberada: o Django decidiu, explicitamente e por escrito, que limitar seu login é tarefa sua. Conte falhas por conta, não só por endereço; confie apenas no salto que o seu próprio proxy realmente escreveu; torne a automação cara; e garanta que o identificador que você registra às três da manhã seja um sobre o qual você poderia agir. O contador que você pode apontar em uma revisão nem sempre é o contador que detém o ataque — e distinguir os dois é o trabalho inteiro.
O Post 12 continua a Série III com Sequestro e Fixação de Sessão, onde o atacante pula tudo isto. Em vez de adivinhar a senha, ele pega a prova do login depois do fato, e vamos ver por que o Django já rotaciona sua chave de sessão no login() e com que facilidade um fluxo de autenticação feito à mão desfaz isso.
Leitura Complementar
- django-security-lab — o laboratório executável deste post (
labs/post_11_brute_force/), o bypass por XFF forjado vs. bloqueio por conta - Django Docs — Security in Django (sobre throttling de requisições de autenticação)
- Django Docs — Customizing Authentication: Authentication Backends
- django-axes — Configuration Reference (AXES_LOCKOUT_PARAMETERS)
- django-ratelimit — Rate Limit Keys
- DRF Docs — Throttling
- OWASP A07:2021 — Identification and Authentication Failures
- OWASP Cheat Sheet — Credential Stuffing Prevention
- MITRE ATT&CK — T1110.004: Credential Stuffing
- NIST SP 800-63B — Digital Identity Guidelines: Authentication
- NY Attorney General — Dunkin' Credential Stuffing Consent Order (2020)
- Web Security for Developers: Real Threats, Practical Defense (Malcolm McDonald) — Capítulo 9: Autenticação