Controle de Acesso Quebrado e IDOR: Quando Estar Logado Não É o Mesmo que Ter Permissão

Controle de Acesso Quebrado e IDOR: Quando Estar Logado Não É o Mesmo que Ter Permissão

Django Security Series — Post 6 | Série II: Broken Access Control
OWASP A01:2025 — Broken Access Control | Tempo de leitura: ~17 min

🧪 Rode você mesmo. Este ataque vem como um laboratório executável no django-security-lab: uma view de nota sob @login_required que busca um objeto pela chave primária sem escopo de propriedade, e uma versão segura que escopa a busca ao request.user. Tudo é exercitado pela linha de comando — faça login como alice, leia a nota do bob pelo id em /idor/vulnerable/<pk>/ e veja a flag vazar, depois receba um 404 indistinguível em /idor/secure/<pk>/. Clone e reproduza cada passo.

A Série I foi sobre injeção: dados cruzando para código em uma fronteira de interpretador. Ao longo de cinco posts o interpretador mudou (o motor SQL, o navegador, o motor de templates, o shell do SO, o parser XML) mas a causa raiz nunca mudou: entrada não confiável era analisada como sintaxe em vez de tratada como um valor. A Série II abre um modo de falha completamente diferente. O código está sintaticamente correto, as queries são parametrizadas, os templates estão escapados, o shell nunca é invocado. Mas a aplicação ainda entrega a um usuário os dados de outro, porque verificou quem você é sem nunca verificar o que você tem permissão de tocar.

O cenário ao qual eu sempre voltava enquanto aprendia esta classe é uma plataforma de petições cívicas — petições pertencem a seus criadores, assinaturas às pessoas que as assinaram. Um cidadão logado abre a sua própria petição em /petition/41/, muda o 41 para 42, e a view — guardada por nada além de @login_required — lhe entrega a de outra pessoa. Nenhum exploit, nenhum payload, um dígito trocado. Troque esse registro vazado por uma assinatura — um nome completo, um CPF, um e-mail — e o cenário muda completamente: divulgar dados de outra pessoa sem autorização não é apenas um bug de lógica, mas um bug jurídico, um incidente de segurança que a LGPD torna notificável quando pode acarretar risco ou dano relevante (os Incidentes Reais abaixo mostram o que isso custa na prática). É essa a lacuna de que trata este post — a distância entre autenticado e autorizado tem a largura de um filtro de queryset esquecido, e na tabela errada ela tem peso jurídico.

Este post cobre como o IDOR funciona, por que a autenticação do Django não o cobre e como fechá-lo escopando cada busca ao usuário autenticado, em views de função e no Django REST Framework (DRF).


O Ataque: O Que É e Como Funciona

Uma view Django recebe uma requisição para /invoices/42/. Ela chama get_object_or_404(Invoice, pk=42) e retorna o resultado. A view é decorada com @login_required, então visitantes anônimos são redirecionados para a página de login. Parece seguro, certo? Só que o Usuário A pode mudar a URL de /invoices/41/ para /invoices/42/ e ler a fatura do Usuário B sem nenhuma resistência. O ORM retornou a linha porque pk=42 existe. O decorator confirmou que alguém está logado. Ninguém perguntou se esse alguém é dono da fatura 42. A chave primária é uma referência direta: mapeia um-para-um a uma linha do banco de dados, e o usuário a controla pela URL.

Essa pergunta ausente é a lacuna entre autenticação (quem está fazendo esta requisição?) e autorização (essa identidade tem permissão para realizar esta ação neste recurso?). O Broken Access Control (Controle de Acesso Quebrado), a categoria de risco #1 da OWASP, é qualquer falha na segunda etapa, e a forma que este post cobre é o IDOR (Insecure Direct Object Reference, Referência Direta Insegura a Objeto): uma referência direta a um objeto interno, geralmente uma chave primária do banco de dados, é exposta na URL, e a aplicação nunca verifica se o usuário autenticado tem qualquer relação com o objeto para o qual ela aponta. O desenvolvedor confundiu "autenticado" (você tem uma sessão válida) com "autorizado" (você pode acessar este objeto específico). Essa confusão é toda a vulnerabilidade, e ela é invisível em todos os estágios do desenvolvimento normal: o código é limpo, os testes passam porque o desenvolvedor que escreveu a view a testou como um único usuário, a revisão vê @login_required e segue em frente, e demonstrações nunca envolvem um segundo usuário tentando os IDs do primeiro.

A forma mais comum é o IDOR horizontal: o Usuário A lê faturas, prontuários médicos ou mensagens do Usuário B, no mesmo nível de privilégio. O atacante é um usuário normal, autenticado, que fez login legitimamente, tem uma sessão válida e simplesmente muda o ID na URL, no parâmetro de query ou no corpo da requisição. Nenhum toolkit de exploits, nenhum payload de injeção, nenhum bypass. Apenas um número diferente.

O ataque não exige nada mais do que um navegador e curiosidade básica. Um atacante que pode ver sua própria fatura em /invoices/41/ tenta /invoices/40/, /invoices/42/ e assim por diante; se a view retorna dados para IDs que ele não possui, a aplicação é vulnerável — e na prática tudo isso é automatizado em segundos. O atacante faz login legitimamente, anota a URL do seu próprio recurso (digamos /api/documents/1053/) e aponta um script simples — ou o Intruder do Burp Suite — para uma faixa de IDs, enviando uma requisição GET autenticada para cada um com seu cookie de sessão anexado automaticamente. Um 200 sozinho não prova nada, já que os registros do próprio atacante também retornam um, e numa view vulnerável um 404 pode significar apenas que nenhum registro tem aquele ID: o vazamento é um 200 trazendo um registro de outra conta, e a proteção é um 404 ou 403 num ID que se sabe pertencer a outra conta. Em uma aplicação vulnerável, todo ID que existe retorna 200, seja de quem for.

O ataque escala trivialmente porque chaves primárias são inteiros sequenciais por padrão. O atacante não precisa adivinhar — basta contar. Mesmo quando uma aplicação usa UUIDs, um único UUID vazado (em um link de e-mail, uma URL compartilhada, um cabeçalho referrer ou uma resposta de API que lista recursos sem escopo) dá ao atacante uma referência válida para tentar, e a ausência de uma verificação de propriedade significa que funciona.

A mesma falta de escopo vaza de outras formas. Um endpoint de lista que retorna todas as linhas entrega a tabela inteira numa única requisição, em vez de um ID por vez. Uma view de edição que busca o objeto sem escopo deixa o atacante sobrescrever o registro de outro usuário em vez de lê-lo. E o IDOR vertical alcança um recurso acima do nível de privilégio do atacante, como um registro exclusivo de staff, o que fica perto da escalação de privilégios, o assunto do próximo post.


Incidentes Reais

First American Financial Corporation — Exposição de Dados por IDOR (2019)

Em maio de 2019 o jornalista de segurança Brian Krebs reportou que a First American Financial Corporation, uma das maiores empresas de seguros de títulos dos Estados Unidos, havia exposto aproximadamente 885 milhões de arquivos datando desde 2003 por meio de uma vulnerabilidade IDOR direta. A empresa enviava aos clientes links para os seus documentos, e cada link levava um número de documento de nove dígitos que parecia ser emitido em sequência. Mudar o número retornava o documento de outro cliente. Nenhuma autenticação era necessária: qualquer pessoa com o padrão da URL podia recuperar qualquer documento iterando o ID. Os registros expostos incluíam números de Seguro Social, imagens de carteiras de motorista, números e extratos de contas bancárias, registros hipotecários e fiscais e comprovantes de transferências eletrônicas — tudo necessário para roubo de identidade em escala industrial. A própria equipe de segurança da informação da empresa havia identificado a vulnerabilidade meses antes, sem corrigi-la. A Comissão de Valores Mobiliários dos EUA (SEC) acusou a First American de falhas em controles de divulgação em 2021 (uma multa de US$ 487.616), porque suas declarações públicas sobre o incidente foram feitas sem que seus executivos sêniores soubessem que a vulnerabilidade já havia sido sinalizada internamente, e em 2023 o Departamento de Serviços Financeiros de Nova York encerrou suas acusações de cibersegurança contra a subsidiária de seguros de títulos com uma multa de US$ 1 milhão.

A lição para desenvolvedores Django é que o IDOR não é um ataque exótico — é a vulnerabilidade mais mundana que existe. Não houve SQL Injection, nem zero-day, nem cadeia de exploits sofisticada. Toda a violação foi uma verificação de autorização ausente em uma view que servia documentos por ID sequencial; o equivalente Django da correção é owner=request.user numa busca atrás de @login_required. Em termos de ATT&CK, explorar a falha mapearia para MITRE ATT&CK T1190 (Exploit Public-Facing Application) e coletar os documentos para T1213 (Data from Information Repositories): um atacante só precisaria ler o que a aplicação servia a qualquer um que pedisse. Os relatos comprovam a exposição, não que alguém tenha coletado os documentos.

Fontes: KrebsOnSecurity — First American Financial Corp. Leaked Hundreds of Millions of Title Insurance Records; SEC — SEC Charges Issuer With Cybersecurity Disclosure Controls Failures; NYDFS — $1 million settlement with First American Title Insurance Company


Proteções Padrão do Django

O framework de autenticação do Django é excelente, e esse é o problema, porque sua excelência cria uma falsa sensação de completude. Eis o que o Django de fato te dá:

  • @login_required / LoginRequiredMixin — redireciona usuários não autenticados para a página de login. Isso é autenticação: estabelece identidade. Não diz nada sobre o que o usuário autenticado pode acessar.
  • request.user.is_authenticated — o booleano que você verifica em um template ou view. Mesmo escopo: identidade, não permissão.
  • O sistema de permissões auth (has_perm, PermissionRequiredMixin, @permission_required) — verifica se um usuário possui uma permissão no nível do modelo como blog.change_post. Isso é autorização de granularidade grossa: "este usuário pode alterar qualquer post?" Não responde "este usuário pode alterar este post específico?"
  • Rotação de chave de sessão no login (login() chama cycle_key()) — previne fixação de sessão, que ganha um post próprio nesta série, mas é irrelevante para autorização.

Eis o que o Django não te dá:

  • Autorização em nível de objeto. O ORM retorna qualquer linha que você pedir. Invoice.objects.get(pk=42) retorna a fatura 42 independentemente de quem está pedindo. Não há filtro embutido "retornar apenas objetos que este usuário possui" — o desenvolvedor deve adicioná-lo a cada queryset em cada view. A documentação do Django diz isso: o framework de permissões "has a foundation for object permissions" (tem uma base para permissões de objeto) — has_perm() aceita um argumento obj opcional — mas nenhuma implementação no core, então verificações de permissão de objeto "will always return False or an empty list" (sempre retornarão False ou uma lista vazia) até que um backend de autenticação customizado forneça a lógica. A única exceção é um superusuário ativo, para quem has_perm() sempre retorna True, com objeto ou sem. Esse backend é o que pacotes de terceiros como o django-guardian fornecem.
  • Escopo automático de queryset. Diferente de alguns frameworks que aplicam filtros de tenant ou usuário no nível do model manager, o manager padrão do Django retorna all(). Toda view começa da tabela inteira a menos que o desenvolvedor a restrinja.
  • Verificações de objeto no DRF que rodam sozinhas. O DRF só roda permissões em nível de objeto quando uma view chama self.get_object(), e só as que você escreve: o IsAuthenticated, o permission_classes de costume, é de novo autenticação, e a verificação de objeto dele permite tudo. Um ModelViewSet cujo queryset é Model.objects.all() serve, portanto, GET /api/documents/42/ a qualquer usuário logado que peça o 42.

O Django trata a autenticação de fábrica e deixa a autorização em nível de objeto inteiramente para o desenvolvedor. Todo IDOR em uma aplicação Django vive nessa lacuna.


O Padrão Vulnerável e a Correção

Padrão 1 — Uma View de Detalhe que Verifica Autenticação mas Não Propriedade

A view de nota do laboratório é o caso clássico, e a correção é um argumento nomeado:

 @login_required
 def note_detail(request, pk):
-    note = get_object_or_404(Note, pk=pk)
+    note = get_object_or_404(Note, pk=pk, owner=request.user)
     return HttpResponse(

Na view vulnerável, alice pede /idor/vulnerable/<pk do bob>/ e recebe a nota do Bob, flag incluída. Na view segura, o get_object_or_404 consulta Note.objects.filter(pk=pk, owner=request.user): a nota do Bob não está nesse resultado, então o Django lança um 404 — a mesma resposta de um id que de fato não existe, o que não diz ao atacante nada sobre a existência da nota 42. As duas views estão no laboratório: views_vulnerable.py e views_secure.py.

Padrão 2 — Um ModelViewSet do DRF com um Queryset sem Escopo

O desenho baseado em classes do DRF parece que deveria tratar permissões automaticamente. O desenvolvedor define permission_classes = [permissions.IsAuthenticated], talvez acrescente um has_object_permission customizado, e presume que o viewset está trancado. Mas queryset = Document.objects.all() entrega ao DRF cada linha da tabela, então GET /api/documents/42/ retorna o documento 42 a qualquer usuário logado que troque o ID. A correção escopa o queryset e mantém uma verificação em nível de objeto por trás dele:

+class IsOwner(permissions.BasePermission):
+    def has_object_permission(self, request, view, obj):
+        return obj.owner == request.user
+
 class DocumentViewSet(viewsets.ModelViewSet):
-    queryset = Document.objects.all()
     serializer_class = DocumentSerializer
-    permission_classes = [permissions.IsAuthenticated]
+    permission_classes = [permissions.IsAuthenticated, IsOwner]
+
+    def get_queryset(self):
+        return Document.objects.filter(owner=self.request.user)

Agora GET /api/documents/42/ retorna um 404 se o documento 42 pertence a outra pessoa, porque ele nem está no queryset. O get_queryset() escopado é o controle principal; o IsOwner é a retaguarda nos caminhos que chamam get_object(), caso uma refatoração futura amplie o queryset. Um detalhe decorre de remover o queryset no nível da classe: o router não consegue mais derivar um nome dele, então registre o viewset com um basename explícito.

Três regras valem para os dois padrões, e não para um só. Primeiro, mantenha o escopo na query também em views baseadas em classe: sobrescreva get_queryset() com o filtro de propriedade em vez de carregar o objeto dentro de UserPassesTestMixin.test_func(), porque um teste que carrega o objeto primeiro responde 403 para um registro que existe e 404 para um que não existe — o vazamento de existência que o 404 escopado evita. Quando a propriedade é mais que uma chave estrangeira — um documento compartilhado com uma equipe, um registro que um papel pode ler —, expresse-a como um filtro Q nesse mesmo get_queryset(). Segundo, retorne 404 em vez de 403 para objetos fora do escopo do usuário: um 403 num ID enumerável confirma que o registro existe, então uma sondagem consegue mapear quais registros são reais. O único preço é um usuário legítimo que digita errado a própria URL recebendo um 404 seco, e isso é um problema de redação para a página 404. Terceiro, trate UUIDs como obscuridade, não como controle de acesso: eles impedem a enumeração casual, o que vale a pena ter, mas vazam por URLs, cabeçalhos Referer, links de e-mail, respostas de API e logs, e uma vez que um seja conhecido, uma busca sem escopo o serve tão prontamente quanto um inteiro sequencial.

Muitas vezes o ID nem precisa estar na URL. Uma view que mostra ao usuário o próprio perfil já sabe quem ele é, então get_object_or_404(Profile, user=request.user) atrás de uma URL sem pk não deixa nada para enumerar.


A Visão do Analista

O IDOR é o exemplo mais claro desta série da distinção que o CySA+ faz entre autenticação e autorização — e de por que o controle de acesso é um controle preventivo que você projeta desde o início em vez de acoplar depois. O Django te dá autenticação quase de graça; a autorização é sua para impor em cada acesso a objeto, e a imposição pertence à query (owner=request.user), não a um if posterior que uma refatoração futura pode silenciosamente derrubar. Escopar o queryset é o controle preventivo; retornar um 404 indistinguível é uma pequena camada extra por cima (nega ao atacante até a confirmação de que um objeto existe); e uma verificação has_object_permission() do DRF é um segundo portão, de defesa em profundidade, nos caminhos que de fato chamam get_object().

O que torna o IDOR um problema distintamente de analista é o que acontece depois que você encontra um na sua própria aplicação: não há assinatura para casar nem patch de fornecedor para aplicar. O achado é "este endpoint retorna objetos que não deveria", e confirmá-lo significa raciocinar sobre quem deveria alcançar o quê e então testar a fronteira com uma segunda conta. É por isso que os scanners da próxima seção conseguem apontar a busca suspeita mas não conseguem dar o veredito — o modelo de propriedade vive na sua cabeça e nos seus dados, não na sintaxe do código. O que você pode automatizar é "uma busca de objeto sem escopo de dono"; o julgamento, "e esse objeto é de outra pessoa", continua humano.


Detectando Automaticamente

Testando Sua Defesa

A prova precisa de dois usuários — um que é dono do objeto e outro que não é — e verifica que o estranho recebe um 404 sem a flag do dono. Este é o teste do laboratório, reduzido às partes que importam:

def setUp(self):
    self.alice = User.objects.create_user("alice", password="labpass")
    self.bob = User.objects.create_user("bob", password="labpass")
    self.bob_note = Note.objects.create(
        owner=self.bob, title="Bob's private note", body=f"secret: {FLAG}"
    )
    self.client.force_login(self.alice)

def test_exploit_is_blocked_on_secure_view(self):
    resp = self.client.get(f"/idor/secure/{self.bob_note.pk}/")
    self.assertEqual(resp.status_code, 404)
    self.assertNotIn(FLAG, resp.content.decode())

Aponte-o para /idor/vulnerable/ e ele falha: a Alice recebe um 200 com a flag do Bob no corpo. Um teste escrito com um único usuário não consegue falhar assim, e é por isso que o bug sobrevive a suítes de teste de usuário único. O tests.py do laboratório cobre a outra metade: a Alice ainda lê a própria nota na view segura. Numa API DRF, os mesmos dois usuários e a mesma verificação servem para a rota de detalhe: autentique como Alice, faça GET /api/documents/<id do Bob>/ e espere um 404.

Fazendo a Varredura

Rode os scanners padrão em que esta série se apoia desde o Post 1 contra as duas views do laboratório e algo instrutivo acontece: nenhum deles encontra o bug. Como o SSTI e o XXE antes dele, o IDOR é uma classe que as ferramentas de prateleira deixam passar por completo — e aqui não porque falta uma regra, mas porque não há uma chamada perigosa para sinalizar.

O Bandit — que percorre a AST do Python procurando construções arriscadas por nome — não reporta nada em nenhuma das views. Suas verificações miram construções perigosas (eval, subprocess … shell=True, mark_safe, yaml.load, senhas fixas), e get_object_or_404(Note, pk=pk) é uma chamada comum. A única coisa que o Bandit sinaliza em todo o módulo é uma senha fixa no arquivo de teste — o labpass dos usuários da fixture —, ruído sem relação com a classe, e um lembrete útil de que um relatório limpo numa view vulnerável é uma falha de detecção, não uma aprovação.

Os pacotes da comunidade do Semgrep (p/django, p/python, p/owasp-top-ten) também não reportam nada — 156 regras (151 Python, 5 multilang), zero achados. A própria documentação do Semgrep explica o porquê, e ela serve de licença para escrevermos nossa própria regra: o IDOR "is closer to a logic flaw: it's the absence of an authorization check at the right point in the request flow" (está mais perto de uma falha de lógica: é a ausência de uma verificação de autorização no ponto certo do fluxo da requisição), então o conselho do Semgrep é "write custom rules for your application that describe the access control logic you're expecting" (escrever regras customizadas para a sua aplicação que descrevam a lógica de controle de acesso que você espera). A view vulnerável e a corrigida diferem por um único predicado específico da aplicação — owner=request.user — e nenhuma regra genérica pode saber que uma Note tem um dono que deveria coincidir com o solicitante.

Então o repositório companheiro traz uma regra Semgrep customizada para este post, rules/idor.yaml. Ela é deliberadamente sintática: sinaliza uma busca get_object_or_404 / get_list_or_404 que não traz nenhum argumento owner= ou user=, então dispara na view vulnerável e fica em silêncio na correção escopada pelo dono, a separação que as ferramentas padrão não conseguiam fazer. A CI verifica os dois resultados e roda a regra contra a sua fixture.

semgrep scan --config rules/idor.yaml labs/post_06_idor/views_vulnerable.py   # 1 finding (line 23)
semgrep scan --config rules/idor.yaml labs/post_06_idor/views_secure.py       # 0 findings

Por ser sintática, ela erra para os dois lados, e cada caso abaixo é uma linha da sua fixture, conferida com o Semgrep 1.170.0. Ela sinaliza buscas corretamente escopadas de outra forma — por um campo que não conhece (account=request.user), por um related manager (get_object_or_404(request.user.notes, pk=pk)) ou por um queryset pré-filtrado — e buscas de objetos que não têm dono, como um post de blog publicado buscado pelo slug. Ela silencia diante de qualquer argumento owner= ou user=, seja qual for o valor, então user=request.GET["uid"] passa, e nunca enxerga o mesmo bug escrito como Model.objects.get(pk=pk) ou como um queryset do DRF sem escopo — o queryset = Document.objects.all() do Padrão 2 passa por ela em silêncio. Trate um achado como "verifique quem deveria alcançar este objeto", não como um veredito.

Deliberadamente, não há DAST de apertar um botão aqui. O IDOR é uma classe de runtime, então um scanner caixa-preta consegue alcançá-lo — mas um crawler percorrendo /idor/vulnerable/1/, /2/, /3/ vê três 200 e nenhum erro, sem nenhuma forma de saber que a nota 2 pertence ao Bob e deveria estar fora do alcance da Alice. Os testes de controle de acesso do OWASP ZAP e do Burp podem ajudar — conduzir duas sessões autenticadas e comparar o que cada uma alcança —, mas um humano ainda fornece a intenção de propriedade. As execuções capturadas do Bandit, do Semgrep e da regra customizada estão em scans/, para você ler o que cada uma reportou; o tests.py continua sendo a prova executável do vazamento em si.


O hábito que este post me deixou: todo get_object_or_404 e todo Model.objects.get(pk=...) sobre um objeto que tem dono precisa responder a uma pergunta — este queryset está escopado ao usuário solicitante? Se não estiver, e nenhuma verificação depois da busca recusar a requisição, a view é um IDOR.

O Post 7 leva a mesma falha na vertical: Privilege Escalation (Escalação de Privilégios), em que o atacante não apenas lê dados de outro usuário mas se promove a um papel superior, alterando is_staff ou is_superuser através de um formulário ou serializer que nunca deveria ter exposto esses campos.

Leitura Complementar

Next in this series → Post 7: Escalação de Privilégios: Como fields = '__all__' Entrega ao Atacante as Chaves do Seu Django Admin

← Voltar para a série