Sequestro e Fixação de Sessão: Por Que o Django Rotaciona Sua Chave de Sessão no Login — e Como um Fluxo de Autenticação Feito à Mão Desfaz Isso
Django Security Series — Post 12 | Série III: Autenticação e Sessão
OWASP A07:2021 — Identification & Authentication Failures | Tempo de leitura: ~19 min
🧪 Rode você mesmo. Este ataque vem como um laboratório executável no django-security-lab: duas views de login e uma sessão que o atacante fixa de antemão. A view feita à mão autentica o usuário escrevendo direto em
request.session— então o id de sessão pré-login nunca é rotacionado, o cookie conhecido do atacante vira a sessão autenticada da vítima, e uma flag que só a vítima deveria ver cai nas mãos do atacante. A view comauth.login()rotaciona a chave no login, e o atacante fica segurando uma sessão vazia e anônima. Reproduza os dois pela linha de comando comcurl.
O Post 11 tratava do atacante à porta, adivinhando. Este trata do atacante que nunca adivinha nada. A autenticação prova quem você é exatamente uma vez, no login; a partir daí, toda requisição que você faz é confiada porque carrega um pequeno valor opaco — o cookie de sessão — que faz as vezes dessa prova. Roube-o, ou combine de antemão para conhecê-lo, e você é o usuário. Nenhuma senha, nenhum segundo fator, nada para forçar. A credencial que importa depois do login não é a senha. É o id de sessão, e a maioria dos desenvolvedores nunca pensa nele porque o Django o gerencia por eles.
Essa é toda a tensão deste post: o Django gerencia o id de sessão bem — rotaciona-o no momento exato, marca o cookie como HttpOnly por padrão, dá a você as flags para prendê-lo ao HTTPS — e a vulnerabilidade quase sempre é algo que um desenvolvedor fez para sair desse gerenciamento. Esta é a parte contraintuitiva que quero fixar cedo: na maior parte desta série a falha é um controle que o Django não fornece (ele não vai limitar seu login, não vai sanitizar seu HTML). Aqui a falha é um controle que o Django já fornece, silenciosamente, que um desenvolvedor consegue desligar sem perceber.
Aprendi a respeitar o id de sessão construindo o fluxo de recuperação de senha do Petição Brasil. Redefinir uma senha é o único momento em que você tem de assumir que a conta já pode estar comprometida — é por isso que o usuário está redefinindo — então a nova senha não basta. Eu tinha de decidir o que acontece com toda sessão que já estava aberta, e escolhi percorrer todo o armazenamento de sessões, apagar cada sessão vinculada àquele usuário, encerrar a sessão atual e recusar auto-login em seguida. Só depois de escrever isso o modelo se encaixou: a redefinição não "muda a senha", ela revoga as sessões, porque em uma aplicação baseada em sessão as sessões vivas são a credencial e a senha é apenas como você emite novas. Este post é sobre as duas formas pelas quais um atacante obtém uma dessas sessões — roubando uma que está viva (sequestro) ou plantando uma conhecida antes do login (fixação) — e por que a defesa é, em grande parte, uma questão de não desfazer o que o Django faz por você.
O Ataque: O Que É e Como Funciona
Há duas formas para este ataque, e elas diferem em quando o atacante obtém o id de sessão.
O sequestro de sessão toma uma sessão que já está viva. A vítima faz login, o Django entrega ao navegador dela um cookie sessionid, e o atacante obtém uma cópia desse valor depois do fato. A rota clássica é a rede: sobre HTTP não criptografado, o cookie viaja em texto puro em cada requisição, e qualquer um que compartilhe o meio — o mesmo Wi‑Fi da cafeteria, um roteador comprometido, uma escuta no nível do provedor — o lê direto dos pacotes. A outra rota é o cross-site scripting (Post 2): um script rodando na sua origem pode ler document.cookie, e se o cookie de sessão for alcançável pelo JavaScript, o script o exfiltra para um servidor controlado pelo atacante. De qualquer forma, o atacante reenvia o valor roubado como se fosse o seu próprio cookie, e o servidor, que não tem como distinguir um navegador de outro, entrega a ele a conta da vítima. O id de sessão foi emitido honestamente; foi copiado desonestamente.
A fixação de sessão inverte a linha do tempo: o atacante escolhe o id de sessão antes de a vítima se autenticar. Como uma aplicação web cria alegremente uma sessão para um visitante anônimo — para guardar um carrinho, um idioma, um segredo CSRF —, um atacante pode obter um id de sessão válido e vazio, dele próprio, e então enganar o navegador da vítima para adotar esse mesmo id (um link forjado que define o cookie, uma escrita XSS em document.cookie, um Set-Cookie em um subdomínio compartilhado). A vítima então faz login normalmente. Se a aplicação autentica a sessão que recebeu — promovendo esse mesmo id de anônimo para autenticado —, o id pré-escolhido do atacante agora é uma sessão logada da vítima, e o atacante, que conheceu o valor o tempo todo, simplesmente o usa. A vítima digitou a própria senha no próprio navegador e ainda assim entregou a conta a outra pessoa.
O ponto que derrota a fixação é trivial de enunciar e fácil de errar: rotacione o id de sessão na fronteira de privilégio. No momento em que uma sessão anônima se torna autenticada, jogue fora o id antigo e emita um novo, aleatório. Qualquer id que o atacante tenha fixado de antemão agora está obsoleto — ele nunca foi promovido, então continua anônimo — e o valor que o navegador da vítima agora carrega é um que o atacante nunca viu. Sequestro e fixação convergem para a mesma ideia defensiva a partir de direções opostas: o id de sessão precisa ser imprevisível para o atacante e desconhecido para ele ao longo do evento de login, e nunca deve trafegar ou repousar em lugar algum onde ele possa lê-lo.
O que torna esta classe silenciosamente perigosa é que nada disso envolve entrada malformada. Não há payload, nenhuma sintaxe injetada, nenhuma fronteira a escapar. Toda requisição que o atacante envia é uma requisição perfeitamente bem-formada carregando um cookie de sessão perfeitamente válido. O servidor se comporta exatamente como projetado. O defeito não está no que a requisição contém; está em se o id daquele cookie ainda deveria ser confiado — uma pergunta que a aplicação responde implicitamente, no código que ela escreveu em torno do login, e que normalmente nunca revisita.
Incidentes Reais
Firesheep (2010)
Em outubro de 2010, o desenvolvedor Eric Butler lançou o Firesheep, uma extensão do Firefox que ele demonstrou na conferência de segurança ToorCon, e transformou um aviso abstrato em um truque de festa. Grandes sites da época — Facebook, Twitter, Flickr e muitos outros — autenticavam o login sobre HTTPS mas então serviam o resto da sessão sobre HTTP puro, de modo que o cookie de sessão era transmitido em texto puro em cada requisição subsequente. O Firesheep ficava em uma rede Wi‑Fi aberta, observava esses cookies e apresentava as contas sequestráveis de todos por perto em uma barra lateral de um clique: um duplo-clique num nome e você estava logado na conta daquele estranho. Não exigia habilidade nem código de exploit — os cookies estavam simplesmente ali, à disposição, no meio compartilhado.
A contribuição do Firesheep não foi uma técnica nova; o sidejacking (sequestro lateral) de sessão já era bem compreendido. Foi que ele tornou o risco inegável e o colocou nas mãos de qualquer um, que é exatamente o que finalmente moveu a indústria. Nos dois anos que se seguiram, as grandes plataformas migraram para HTTPS na sessão inteira, e não apenas no formulário de login, e a flag de cookie Secure — que diz ao navegador para nunca enviar o cookie sobre HTTP puro — passou de opção obscura a linha de base. Em termos de MITRE ATT&CK, a técnica é T1539 — Steal Web Session Cookie: o adversário captura um cookie de sessão e o reutiliza para se autenticar como a vítima, contornando a credencial e qualquer prompt de múltiplos fatores por completo, porque o cookie de sessão é emitido depois de essas verificações já terem passado.
O peso regulatório de uma sessão sequestrada é fácil de subestimar porque nada é "violado" no sentido de banco de dados — nenhum dump, nenhuma tabela exfiltrada. Mas um atacante montado em uma sessão viva lê e age sobre quaisquer dados pessoais que aquela conta alcança, e sob a LGPD (Lei Geral de Proteção de Dados) brasileira, Artigo 48, o controlador de uma base que sofre um incidente de segurança com risco relevante aos titulares deve comunicar a autoridade nacional (ANPD) e os usuários afetados. Um incidente de sequestro de sessão que expõe dados pessoais é exatamente esse tipo de evento — e, revelador, os controles que o teriam evitado (a flag Secure, HTTPS em tudo, HttpOnly para conter o roubo de cookie via XSS) são todos configurações de uma linha que o desenvolvedor definiu ou não. A falha de conformidade e a flag de cookie ausente são a mesma omissão vista de dois lados.
Fonte: Eric Butler — Firesheep (2010)
Proteções Padrão do Django
O framework de sessões do Django é um dos padrões mais fortes do framework, e a defesa contra fixação em particular é algo de que a maioria dos desenvolvedores se beneficia sem nunca saber que existe.
Rotação da chave de sessão no login. Quando você chama django.contrib.auth.login(request, user), o Django rotaciona a chave de sessão. Internamente, o login() inspeciona a sessão atual: se ela já pertence a um usuário autenticado diferente, ele a encerra por completo; caso contrário — o caso comum, uma sessão anônima se tornando autenticada — ele chama request.session.cycle_key(), que gera uma chave de sessão nova e aleatória enquanto carrega os dados de sessão existentes. De um jeito ou de outro, o id antigo é descartado. Este é exatamente o pivô anti-fixação descrito acima — e acontece automaticamente, na única linha que todo tutorial de Django manda você escrever. Um atacante que fixou um id de sessão antes do login descobre que, no instante em que a vítima se autentica, o id que ele plantou fica órfão: nunca foi promovido, e o navegador da vítima agora carrega um id novo que o atacante não consegue prever. O login() também chama rotate_token() para rotacionar o segredo CSRF na mesma fronteira, pela mesma razão.
Flags de cookie que mantêm o id fora de alcance. O cookie de sessão do Django é HttpOnly por padrão (SESSION_COOKIE_HTTPONLY = True), então a rota de roubo por cross-site scripting — um script lendo document.cookie — fica fechada de fábrica; o cookie de sessão simplesmente não é visível ao JavaScript. O SESSION_COOKIE_SAMESITE tem padrão 'Lax', o que mantém o cookie fora da maioria das requisições cross-site. E o SESSION_COOKIE_SECURE, embora não esteja ligado por padrão, é a única flag que prende o cookie ao HTTPS de modo que uma captura em texto puro no estilo Firesheep se torne impossível.
Sessões no servidor que você pode revogar. Com o backend de banco de dados padrão (ou o backend de cache), os dados de sessão vivem no servidor e o cookie carrega apenas uma chave opaca. Essa indireção é o que torna uma sessão revogável: apagar o registro no servidor — como faz a recuperação de senha do Petição Brasil para toda sessão vinculada a um usuário — invalida o cookie instantaneamente, porque a chave agora aponta para o nada. É também o que mantém o conteúdo da sessão inteiramente fora do cliente.
O que o Django não faz por você:
SESSION_COOKIE_SECUREéFalsepor padrão. O Django não vai forçar seu cookie de sessão para HTTPS, e não pode defini-lo comoTruecom segurança: o servidor de desenvolvimento roda sobre HTTP puro, onde um cookieSecurenunca é enviado — então um padrão seguro-em-produção quebraria silenciosamente o login no instante em que você rodasse orunserver. O Django entrega o valor permissivo e deixa o endurecimento para você, condicionado aDEBUG=Falsecomo este blog faz; esquecer é o eco moderno da brecha do Firesheep. (SESSION_COOKIE_HTTPONLYeSESSION_COOKIE_SAMESITEpodem ter padrões seguros justamente porque continuam funcionando sobre HTTP puro — não quebram nada em desenvolvimento.)- Ele não consegue rotacionar uma chave que você contorna. A proteção do
cycle_key()vive dentro dologin(). Um fluxo de autenticação feito à mão que marca uma sessão como autenticada escrevendo direto emrequest.session, ou um recurso de "personificar usuário" / "trocar de conta" que muda a quem a sessão pertence sem chamarlogin(), nunca dispara a rotação — e reabre a fixação exatamente onde o Django a havia fechado. - O backend de cookie assinado coloca a sessão nas mãos do cliente. Definir
SESSION_ENGINE = 'django.contrib.sessions.backends.signed_cookies'move todo o payload da sessão para o próprio cookie. Ele é assinado (à prova de adulteração) mas não criptografado (legível por qualquer um que segure o cookie) e — porque não há registro no servidor — não pode ser invalidado: um logout ou uma redefinição de senha não conseguem revogar uma sessão de cookie assinado que um atacante já tenha copiado. Ele abre mão das duas propriedades que mais importam a este post.
Padrão Vulnerável: O Que NÃO Fazer
Padrão 1 — Um login feito à mão que nunca rotaciona a sessão
O footgun canônico é autenticar um usuário sem passar pelo login(). Ele costuma aparecer quando alguém quer "mais controle" sobre a resposta de login, ou está portando um fluxo de autenticação, e reconstrói à mão o que o login() faz — mas deixa de fora a parte que não conseguia ver, a rotação da chave:
# INSEGURO — autentica escrevendo direto na sessão, então o id de sessão
# pré-login nunca é rotacionado (fixação de sessão).
from django.contrib.auth import authenticate
from django.http import HttpResponse
def login_view(request):
user = authenticate(
username=request.POST.get('username'),
password=request.POST.get('password'),
)
if user is None:
return HttpResponse('credenciais inválidas', status=401)
# PERIGO: estas são as escritas que o login() faz — mas NÃO sua rotação
# cycle_key(). O id de sessão com que o navegador chegou é promovido no lugar.
request.session['_auth_user_id'] = str(user.pk)
request.session['_auth_user_backend'] = 'django.contrib.auth.backends.ModelBackend'
request.session['_auth_user_hash'] = user.get_session_auth_hash()
return HttpResponse(f'logado como {user.username}')
O código "funciona" — o usuário está logado, request.user resolve na requisição seguinte, todo teste que só verifica se o login teve sucesso passa. O que ele silenciosamente descarta é a rotação. Um atacante que fixou o id de sessão antes desta chamada agora segura uma sessão autenticada, porque o id com que a vítima chegou foi promovido no lugar em vez de ser substituído. Nada aqui parece uma vulnerabilidade; parece uma view de login. O bug é a linha que não está lá.
Padrão 2 — Um cookie de sessão que não está preso ao HTTPS
# INSEGURO (settings.py) — o cookie de sessão é permitido sobre HTTP puro
# em um site HTTPS, então um atacante de rede pode capturá-lo (a brecha do Firesheep).
SESSION_COOKIE_SECURE = False # ← cookie enviado sobre http:// também
# SESSION_COOKIE_HTTPONLY tem padrão True, SESSION_COOKIE_SAMESITE tem padrão 'Lax' —
# mas SECURE é a que fica desligada a menos que você a ligue.
Isto não é um bug de código; é um padrão deixado no lugar. Em um site servido sobre HTTPS, deixar SESSION_COOKIE_SECURE = False significa que o navegador ainda anexará o cookie de sessão a qualquer requisição http:// acidental — um esquema digitado errado, um recurso de conteúdo misto, um atacante que remove o TLS em uma rede aberta — e essa requisição carrega o id em texto puro. É exatamente a superfície que o Firesheep colheu, uma década depois e a uma configuração de distância.
Padrão 3 — Um recurso de "personificar" ou "trocar de usuário" que reutiliza a sessão
# INSEGURO — personificação por staff que muda a quem a sessão pertence
# sem rotacionar a chave.
@user_passes_test(lambda u: u.is_staff)
def impersonate(request, user_id):
target = get_object_or_404(User, pk=user_id)
# PERIGO: a fronteira de privilégio é cruzada (staff → usuário-alvo) mas o
# id de sessão é reutilizado, então um id fixado ou compartilhado sobrevive à transição.
request.session['_auth_user_id'] = str(target.pk)
return redirect('dashboard')
Personificação é uma fronteira de privilégio assim como o login, e merece a mesma rotação. Reatribuir _auth_user_id no lugar significa que a sessão que era "a staff Alice" vira "o usuário Bob" sob o mesmo id — então qualquer fixação anterior daquele id, ou qualquer cópia obsoleta dele, agora pega carona na conta do Bob. A versão correta encaminha a troca pelo login() (que rotaciona) em vez de mutar a sessão à mão.
Implementação Segura: O Jeito Django
Regra 1 — Autentique pelo login(), sempre
A regra mais importante é também a mais fácil: nunca marque uma sessão como autenticada à mão. Chame django.contrib.auth.login(), e deixe-o rotacionar a chave por você:
# SEGURO — login() rotaciona a chave de sessão na mudança de privilégio, então um id
# de sessão pré-login fixado não consegue sobreviver na sessão autenticada.
from django.contrib.auth import authenticate, login
from django.http import HttpResponse
def login_view(request):
user = authenticate(
username=request.POST.get('username'),
password=request.POST.get('password'),
)
if user is None:
return HttpResponse('credenciais inválidas', status=401)
login(request, user) # cycle_key(): novo id de sessão aleatório, dados preservados
return HttpResponse(f'logado como {user.username}')
Toda a defesa contra fixação é a única chamada. O login() regenera a chave de sessão, migra os dados de sessão para o novo id, define as chaves _auth_* corretamente (incluindo o hash de autenticação de sessão que amarra a sessão à senha, de modo que uma troca de senha possa invalidá-la) e rotaciona o token CSRF. Toda transição adjacente à autenticação que cruza uma fronteira de privilégio — login, início e fim de personificação, autenticação reforçada (step-up) — deveria passar por ele em vez de contorná-lo.
Regra 2 — Prenda o cookie ao HTTPS e mantenha-o fora do JavaScript
Defina as flags do cookie de sessão explicitamente em produção. HttpOnly e SameSite já têm padrões seguros, mas tornar a intenção visível (e ligar o Secure) é a diferença entre "confiamos num padrão" e "decidimos":
# SEGURO (settings.py) — endurecimento do cookie de sessão em produção
SESSION_COOKIE_SECURE = True # nunca enviado sobre HTTP puro (fecha o Firesheep)
SESSION_COOKIE_HTTPONLY = True # ilegível a partir de document.cookie (conter roubo por XSS)
SESSION_COOKIE_SAMESITE = 'Lax' # não anexado à maioria das requisições cross-site
SESSION_COOKIE_SECURE = True é o descendente direto da lição do Firesheep: com ele definido, um atacante de rede nunca vê o cookie porque o navegador se recusa a transmiti-lo em texto puro. SESSION_COOKIE_HTTPONLY = True (já o padrão) significa que, mesmo que um bug de XSS passe (Post 2), o script não consegue ler o id de sessão de document.cookie. Estas são defesa em profundidade para o mesmo ativo contra duas ameaças diferentes — a rede e o script injetado.
Uma ressalva completa a história do HTTPS para deployments reais. Atrás de um proxy ou CDN que termina o TLS (Heroku, Cloudflare, um balanceador de carga da AWS), a requisição que chega ao Django chega sobre HTTP puro — o proxy já cuidou do TLS —, então request.is_secure() retorna False e a maquinaria consciente de HTTPS (SECURE_SSL_REDIRECT, a verificação de referer do CSRF) falha mesmo com o usuário em HTTPS. Diga ao Django para confiar no esquema que o proxy encaminhou com SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https'), mas só quando o proxy é um que você controla e que remove ou sobrescreve esse cabeçalho — do contrário, um cliente que alcance o Django diretamente poderia forjá-lo e alegar que sua requisição em HTTP puro era segura. O SESSION_COOKIE_SECURE = True em si estampa o atributo Secure incondicionalmente, então a flag do cookie funciona atrás de um proxy de qualquer forma; o cabeçalho é o que a lógica de redirect-e-detecção ao redor precisa para concordar que a conexão é de fato HTTPS.
Regra 3 — Limite o tempo de vida da sessão
Uma sessão que nunca expira é um cookie roubado que funciona para sempre. Defina uma idade que combine com a sensibilidade da aplicação, e decida se as sessões devem sobreviver ao fechamento do navegador:
# SEGURO (settings.py) — limite por quanto tempo uma sessão (e um cookie roubado) fica válida
SESSION_COOKIE_AGE = 60 * 60 * 24 # 24 horas, não o padrão de 2 semanas
SESSION_EXPIRE_AT_BROWSER_CLOSE = False # defina True para contextos de máquina compartilhada
O SESSION_COOKIE_AGE padrão do Django é de duas semanas, o que é generoso para qualquer coisa que guarde dados pessoais. Encurtá-lo limita a janela em que uma sessão sequestrada ou fixada permanece útil. Para uma ação genuinamente sensível, a reautenticação (pedir a senha de novo na fronteira) é o controle mais forte, mas uma idade limitada é a linha de base barata que toda aplicação deveria definir.
Regra 4 — Mantenha as sessões no servidor e revogáveis
Deixe o SESSION_ENGINE em um backend do lado do servidor (o backend de banco de dados padrão, ou cache/cached_db), de modo que o cookie carregue apenas uma chave opaca e os dados de sessão — e o poder de revogá-los — fiquem no seu servidor. Então use esse poder nos momentos que importam: em troca ou redefinição de senha, invalide as outras sessões do usuário, exatamente como faz o fluxo de recuperação do Petição Brasil. O Django amarra a sessão ao hash da senha por meio de um hash de autenticação de sessão e o verifica em toda requisição (em auth.get_user()), então após uma troca de senha as outras sessões do usuário são deslogadas automaticamente na requisição seguinte, enquanto o update_session_auth_hash() mantém a atual viva — mas para uma revogação imediata e total (o botão "sair de todos os lugares", ou uma redefinição que assume comprometimento) apagar os registros de sessão no servidor é a jogada direta e auditável. Nada disso é possível com o backend de cookie assinado, que é a razão concreta para evitá-lo em sessões autenticadas.
A Visão do Analista
Para um analista CySA+, sequestro e fixação de sessão são o caso mais limpo da série de um único ativo — o identificador de sessão — que precisa ser protegido em três planos diferentes ao mesmo tempo, e a defesa é uma pilha de controles da qual nenhum isolado é suficiente. A rotação no login (cycle_key()) é um controle preventivo contra a fixação: remove o conhecimento prévio do id pelo atacante. A flag Secure mais TLS é um controle preventivo contra a captura na rede: remove o id do fio. O HttpOnly é um controle compensatório posto em camada contra uma classe de vulnerabilidade diferente — ele não corrige o XSS (Post 2), apenas nega ao XSS seu alvo de maior valor, o cookie de sessão. Lido assim, o cookie de sessão é um exemplo prático de defesa em profundidade: o mesmo segredo guardado contra ser previsto, farejado e extraído por script, por três mecanismos independentes, porque qualquer um deles pode falhar.
O hábito que o ataque recompensa é tratar o id de sessão como uma credencial com um ciclo de vida, não como um valor que apenas existe. Uma credencial é emitida (rotacione-a em toda fronteira de privilégio para que o atacante nunca a conheça), transmitida (prenda-a a um canal que ele não consegue ler), armazenada (no servidor, para que você possa revogá-la) e encerrada (idade limitada, e morta na troca de senha). Todo controle da Implementação Segura se encaixa em um desses quatro verbos, e os padrões vulneráveis são cada um um lugar onde um verbo foi pulado — um login que nunca reemitiu, um cookie transmitido em claro, uma sessão de cookie assinado que não podia ser encerrada. Quando você revisa um fluxo de autenticação, percorra os quatro verbos contra ele; o que faltar é o achado.
Detectando Automaticamente
Testando Sua Defesa
A defesa contra fixação tem uma propriedade que a torna incomumente testável: é um único fato observável — a chave de sessão precisa mudar ao longo do login(). Um teste captura a chave antes da autenticação e afirma que ela é diferente depois. Essa única asserção falha contra todo login feito à mão do Padrão 1 e passa apenas quando o fluxo passa pelo login():
# tests/test_session_fixation.py
from django.contrib.auth.models import User
from django.test import TestCase, Client
class SessionFixationTests(TestCase):
def setUp(self):
self.user = User.objects.create_user('victim', password='correct-horse-battery')
def test_session_key_rotates_on_login(self):
"""A defesa contra fixação: o id de sessão precisa mudar ao longo do login()."""
client = Client()
client.get('/accounts/login/') # estabelece uma sessão pré-login
before = client.session.session_key
self.assertIsNotNone(before)
client.post('/accounts/login/', {'username': 'victim',
'password': 'correct-horse-battery'})
after = client.session.session_key
self.assertNotEqual(before, after) # falha em um login feito à mão
def test_fixed_session_id_does_not_survive_login(self):
"""Um id pré-escolhido deve ficar órfão assim que a vítima se autentica."""
attacker = Client()
attacker.get('/accounts/login/')
fixed = attacker.session.session_key # o id que o atacante conhece
victim = Client()
victim.cookies['sessionid'] = fixed # a vítima adota o id fixado
victim.post('/accounts/login/', {'username': 'victim',
'password': 'correct-horse-battery'})
# O id conhecido do atacante nunca foi promovido — ainda é anônimo.
attacker.cookies['sessionid'] = fixed
response = attacker.get('/accounts/whoami/')
self.assertNotContains(response, 'victim', status_code=200)
O lado das flags de cookie é igualmente verificável de forma direta — afirme que o cabeçalho Set-Cookie em uma resposta autenticada carrega Secure, HttpOnly e SameSite:
def test_session_cookie_is_hardened(self):
client = Client()
response = client.post('/accounts/login/', {'username': 'victim',
'password': 'correct-horse-battery'})
cookie = response.cookies['sessionid']
self.assertTrue(cookie['secure'])
self.assertTrue(cookie['httponly'])
self.assertEqual(cookie['samesite'], 'Lax')
Fazendo a Varredura
As ferramentas de SAST voltam de mãos vazias aqui, e por uma razão mais aguda do que nos outros posts em que elas erram: são duas falhas e nenhuma é um padrão que uma regra consiga casar. O Bandit reporta zero no laboratório — nem mesmo o ruído da senha de teste hardcoded que outros labs mostram, porque simplesmente não há chamada perigosa alguma em um login que pula o login(). Os pacotes da comunidade do Semgrep (p/django, p/python, p/owasp-top-ten) também reportam zero, e o tier de registry/auditoria só traz nits sem relação (direct-use-of-httpresponse nas views de HttpResponse puro do lab) — nada sobre rotação de sessão. E, ao contrário de IDOR ou mass assignment, nenhuma regra customizada resolveria: a falha de fixação é a ausência de uma chamada cycle_key(), e uma regra consegue sinalizar uma chamada perigosa, mas nunca uma que falta. Então, como no post de força bruta, este lab não traz regra customizada — uma aqui seria encenação.
A detecção que funciona é uma que a série ainda não havia buscado: o próprio verificador de deploy do Django. O python manage.py check --deploy roda os system checks de segurança do framework contra suas configurações, e sinaliza a metade do cookie com todas as letras:
python manage.py check --deploy
# ?: (security.W012) SESSION_COOKIE_SECURE is not set to True. Using a secure-only
# session cookie makes it more difficult for network traffic sniffers to hijack
# user sessions.
O W012 é a lição do Firesheep nas próprias palavras do Django, e vem no framework — sem ferramenta extra, sem regra customizada. Ele pega o que o Bandit e o Semgrep estruturalmente não conseguem porque a vulnerabilidade é um valor (SESSION_COOKIE_SECURE = False), não uma chamada, e o check --deploy é um linter de configuração, não um scanner de código. Rode-o no CI e ele vira um gate sobre todo o checklist de segurança de deploy — cookies seguros, HSTS, o redirect de SSL, DEBUG — pelo custo de um comando. (Ele tem um ponto cego que vale nomear: lê configurações, então não enxerga um cookie que você define à mão com response.set_cookie('t', v) sem secure= — essa superfície é a do Post 19.)
A metade da rotação não tem scanner algum, e essa é a lição honesta de CySA+ do post: alguns controles são verificados por um teste, não por uma ferramenta. A asserção de que a session_key precisa mudar ao longo do login() no tests.py do lab é determinística e falha no instante em que um login feito à mão volta a se infiltrar; a outra metade, dinâmica, é a sonda curl — fixe um id, monte nele até a flag na view vulnerável, e o veja morrer (um 302) na segura. As execuções capturadas de check --deploy, Bandit e Semgrep estão versionadas em scans/; o tests.py é a prova executável.
A lição da segurança de sessão é a silenciosa que encerra grande parte do tema "porta da frente" da Série III: o Django já emite, rotaciona e endurece o id de sessão corretamente, então a vulnerabilidade é quase sempre um desenvolvedor saindo dessa maquinaria — um login que pula o login(), uma flag de cookie deixada em seu padrão inseguro, um backend que abre mão da revogabilidade. Encaminhe toda fronteira de privilégio pelo login(), prenda o cookie ao HTTPS, mantenha a sessão no servidor, e a joia da coroa continua sua.
O Post 13 continua a Série III com Senhas Fracas e Validadores, onde a falha volta para a própria credencial — a aplicação que aceita 123456, ou o próprio e-mail do usuário, como senha — e os AUTH_PASSWORD_VALIDATORS do Django a pegam apenas quando todo caminho de cadastro e de troca de fato os chama.
Leitura Complementar
- django-security-lab — o laboratório executável deste post (
labs/post_12_session_fixation/), o exploit de fixar-a-sessão vs. rotação no login - Django Docs — How to use sessions (engines de sessão, configurações de cookie, segurança)
- Django Docs — Authentication: how to log a user in (
login()e rotação de sessão) - Django Docs — Session settings reference (
SESSION_COOKIE_*,SESSION_ENGINE) - OWASP A07:2021 — Identification and Authentication Failures
- OWASP Cheat Sheet — Session Management
- PortSwigger Web Security Academy — Session fixation and related auth mechanisms
- MITRE ATT&CK — T1539: Steal Web Session Cookie
- Eric Butler — Firesheep (2010)
- Web Security for Developers: Real Threats, Practical Defense (Malcolm McDonald) — Capítulo 10: Session Hijacking