O Broken Access Control (BAC) é, segundo o próprio OWASP, a falha de segurança web mais comum que existe. Faz anos que ele ocupa o #1 do OWASP Top 10 (A01) e, na edição A01:2025, 100% das aplicações testadas tinham alguma forma de quebra de controle de acesso. Não há nada de exótico aí: é só deixar alguém ver o que não deveria. É a categoria que junta todo caso em que a aplicação acerta em saber quem você é, mas erra ao decidir o que você pode fazer. O servidor confirma a sua sessão, vê o pedido para acessar o pedido #1234 e entrega, sem nunca checar se aquele pedido é seu.
O IDOR (Insecure Direct Object Reference) é o nome mais conhecido dessa família. É a falha que troca um id na URL e devolve os dados de outra pessoa. Ela importa por três motivos: é trivial de explorar (não precisa de payload nem ferramenta exótica, só um navegador e um pouco de curiosidade), passa batido pelos scanners automáticos (a requisição é “válida”: autenticada e bem-formada) e tem impacto devastador, do vazamento em massa de dados à alteração de registros alheios e, no pior caso, virar administrador. Este artigo serve de referência rápida para citar no relatório e de guia de correção para quem escreve o código.
A taxonomia: BAC, IDOR, BOLA, BFLA
Esses acrônimos confundem porque descrevem o mesmo problema em camadas diferentes. Vamos cortá-los:
- Broken Access Control (BAC) — o guarda-chuva. Qualquer falha na aplicação das regras de autorização. Tudo abaixo é um subtipo de BAC. É a categoria A01:2025 do OWASP Top 10 (que, nesta edição, absorveu também o SSRF).
- IDOR (Insecure Direct Object Reference) — a aplicação expõe uma referência direta a um objeto interno (um
idde banco, um nome de arquivo) e não verifica se o usuário da sessão tem direito àquele objeto específico. É a causa raiz mais comum de BAC no nível de objeto. - BOLA (Broken Object Level Authorization) — o #1 do OWASP API Security Top 10 (API1:2023). É exatamente a mesma ideia do IDOR, só que com o vocabulário do mundo de APIs. IDOR e BOLA descrevem a mesma falha — use o termo conforme o público (IDOR para web clássica, BOLA em contexto de API).
- BFLA (Broken Function Level Authorization) — não é sobre qual objeto, e sim sobre qual função (API5:2023 no Top 10 de APIs). Aqui o problema é acessar um endpoint ou operação que deveria ser restrito a um papel mais alto (ex.: um usuário comum chamando
POST /api/admin/users). É o que normalmente leva à escalada vertical.
Mantenha esta distinção na cabeça porque ela define o eixo da escalada:
- Escalada horizontal — você acessa dados de outro usuário do mesmo nível. Trocar
userId=1001poruserId=1002. Tipicamente um IDOR/BOLA. - Escalada vertical — você ganha privilégios mais altos: vira admin, acessa o painel de gestão, executa funções administrativas. Tipicamente um BFLA, ou um IDOR combinado com mass assignment (ex.: setar
"role": "admin"no próprio perfil).
A anatomia do ataque
A vulnerabilidade nasce de um raciocínio só, e ele é furado: buscar o objeto pelo identificador que o cliente mandou e devolver sem checar de quem ele é.
Imagine um endpoint de detalhe de pedido. A usuária Alice (sessão autenticada) abre seu pedido:
GET /api/orders/1042 HTTP/1.1
Host: loja.exemplo.com
Cookie: session=<token-válido-da-alice>
No servidor, o código vulnerável tem esta forma:
@app.get("/api/orders/<int:order_id>")
@login_required # autenticação: OK, sabemos que é a Alice
def get_order(order_id):
order = db.query(Order).filter(Order.id == order_id).first()
return jsonify(order.to_dict()) # <- entrega QUALQUER pedido pelo id
O @login_required garante que existe uma sessão válida. Mas autenticação não é autorização: o código busca Order.id == 1042 e devolve, sem nunca comparar o owner_id do pedido com a identidade da sessão. Então a Alice simplesmente faz:
GET /api/orders/1043 HTTP/1.1
Host: loja.exemplo.com
Cookie: session=<token-válido-da-alice>
E recebe o pedido do Bob: endereço de entrega, itens, valor, talvez os últimos quatro dígitos do cartão. Coloque isso num laço for de 1 a 99999 e o resultado é um vazamento em massa. Esse é o IDOR de leitura.
A versão de escrita é pior. Se o mesmo padrão existe num PUT/PATCH/DELETE, o atacante não só lê: ele altera ou apaga registros que não são dele:
PATCH /api/orders/1043 HTTP/1.1
Host: loja.exemplo.com
Cookie: session=<token-válido-da-alice>
Content-Type: application/json
{"shipping_address": "Rua do Atacante, 42"}
No fundo é sempre a mesma história: a decisão de autorização foi esquecida ou ficou no lugar errado. O servidor partiu do princípio de que, se o cliente conhece o
id, então tem direito ao objeto. Trataram a obscuridade do identificador como se fosse um controle de segurança, e obscuridade não protege nada.
Variações e bypasses
O IDOR quase nunca aparece na forma do manual. Vale conhecer as variações que encontramos em campo.
IDs previsíveis (sequenciais)
O caso clássico: identificadores auto-incrementais (1, 2, 3…). Dá para enumerar só iterando. Se você vê inteiros pequenos e crescentes em URL ou body, comece por aí.
UUIDs não são proteção (vazamento + enumeração)
Trocar id=1042 por um UUID v4 (f47ac10b-58cc-...) dificulta a adivinhação, mas não corrige o IDOR. Se a aplicação não checa propriedade, qualquer UUID válido que o atacante consiga por outro caminho funciona. E UUID vaza o tempo todo: em respostas de listagem, logs, cabeçalho Location, e-mail, parâmetro de outra tela, histórico do navegador. Vale lembrar também que o UUID v1 embute timestamp e o endereço MAC do nó, o que o deixa parcialmente previsível, então nem todo formato resiste igual à adivinhação (use o v4, aleatório). De qualquer forma, o UUID muda o custo do ataque, não a existência dele.
Mass assignment (a ponte para escalada vertical)
A aplicação cria/atualiza um objeto fazendo bind automático do JSON recebido nas colunas do modelo. O atacante adiciona um campo que não deveria controlar:
PATCH /api/users/me HTTP/1.1
Content-Type: application/json
{"name": "Alice", "role": "admin", "account_id": 7}
Se o servidor faz user.update(**request.json) sem allowlist de campos, role e account_id são gravados. Um IDOR horizontal (escrever no próprio objeto) vira escalada vertical (virar admin) ou pulo de tenant.
Contexto multi-tenant
Em SaaS B2B, o objeto pertence a uma organização, não só a um usuário. A checagem precisa incluir objeto.tenant_id == sessão.tenant_id (além da propriedade do usuário, quando aplicável). Falhar aqui significa um cliente lendo dados de outro cliente — uma quebra de isolamento que costuma ser tratada como incidente crítico, inclusive contratual.
GraphQL: node IDs e o padrão node(id:)
GraphQL (no estilo Relay) concentra acesso a objetos por ID global — tipicamente o base64 de algo como Order:1043. O resolver node(id: ...) busca por qualquer tipo/ID. Se a autorização não está dentro de cada resolver de objeto, o IDOR se espalha por todo o grafo de uma vez. Decodificar o ID global (base64 -d) frequentemente revela o inteiro sequencial por baixo — ou seja, a “opacidade” do ID global é só codificação, não autorização.
IDOR via método HTTP alternativo
A leitura (GET /api/orders/1043) está protegida, mas o DELETE ou o PUT no mesmo recurso não. Desenvolvedores frequentemente aplicam a checagem em um verbo e esquecem dos outros. Sempre teste todos os métodos sobre o mesmo objeto.
IDOR via parâmetro escondido
O id não está na URL — está num campo secundário, num cabeçalho (X-Account-Id), num cookie não assinado, ou num parâmetro JSON aninhado que a UI nunca expõe. Ferramentas como o Param Miner (extensão do Burp) ajudam a descobrir parâmetros e cabeçalhos não documentados que controlam a referência ao objeto.
Como exploramos no pentest
A metodologia é direta e quase sempre dá resultado. A chave é ter duas contas.
1. Provisione duas contas do mesmo nível — chame-as de vitima e atacante. Idealmente, crie também uma terceira de nível mais alto (admin) para mapear o que cada papel acessa.
2. Mapeie os identificadores. Navegando como vitima, catalogue toda referência a objeto: IDs em URLs, bodies, headers, respostas JSON. Anote os IDs dos recursos da vitima (pedidos, faturas, mensagens, arquivos).
3. Troque os IDs entre contas. Autenticado como atacante, repita as requisições da vitima usando a sessão do atacante mas os IDs da vítima. No Burp Suite, isso é “Send to Repeater”, trocar o cookie de sessão e o id, e observar:
- Veio
200com os dados da vítima? IDOR de leitura confirmado. - O
PATCH/DELETEretornou sucesso? IDOR de escrita — valide relendo como a vítima. - Veio
403/404consistente? Provavelmente protegido (mas teste outros verbos e parâmetros antes de descartar).
4. Automatize com Burp Autorize. Esta extensão é feita exatamente para isso. Você configura os cookies/headers da conta de baixo privilégio; ao navegar com a conta de alto privilégio, o Autorize reenvia cada requisição com as credenciais rebaixadas para a conta fraca e classifica:
- Bypassed! — a requisição funcionou sem o privilégio adequado (achado).
- Enforced! — foi corretamente bloqueada.
- Is enforced??? — ambíguo; o detector não conseguiu decidir e a resposta exige análise/configuração manual (você adiciona uma string/regex de fingerprint para o Autorize aprender o padrão de bloqueio).
Isso transforma a caça a BAC numa varredura quase passiva enquanto você usa a aplicação normalmente. Ainda assim, confirme manualmente os achados marcados como Bypassed! — falsos positivos acontecem quando a resposta de erro e a de sucesso têm tamanho/forma parecidos.
5. Enumere em escala com o Turbo Intruder ou o Intruder do Burp: itere o id sobre uma faixa e meça as respostas (status e tamanho do corpo). Picos de 200 com tamanhos não-triviais delatam objetos acessíveis. Para GraphQL, automatize a iteração do inteiro embutido no ID global.
6. Mostre o impacto, não só a falha. Um bom achado de IDOR não diz apenas “troquei o ID”. Ele mostra quantos registros dá para acessar, que dados sensíveis vazam e se dá para escrever (alterar ou apagar). É o que separa um “Médio” de um “Crítico” no relatório.
Use somente os dados das suas contas de teste para provar a leitura cruzada. Não percorra a base inteira de usuários reais — colete o mínimo necessário (dois ou três IDs vizinhos) para demonstrar o padrão e respeite o escopo combinado.
Resumo para o relatório
- Impacto: Um usuário autenticado acessa, modifica ou exclui dados de outros usuários/organizações trocando um identificador de objeto na requisição (escalada horizontal). Quando combinado com mass assignment ou endpoints administrativos desprotegidos, leva a escalada vertical (comprometimento de conta administrativa) e vazamento em massa de dados pessoais.
- Severidade: Alta a Crítica. CVSS v3.1 de 6.5 para leitura (
CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:N/A:N), subindo a 8.1 quando há escrita/exclusão de dados alheios (...C:H/I:H/A:N) ou cruzamento de tenant. Escalada vertical para admin chega a Crítica (9.x). Pontue sempre conforme o caso concreto.- Pré-condições: Uma conta válida de baixo privilégio e conhecimento (ou enumerabilidade) de identificadores de objetos de outros usuários.
- Evidência sugerida: Par de requisições/respostas mostrando a conta A acessando objeto da conta B (com os cookies/sessão de A destacados e o
idde B); IDs envolvidos; captura do dado sensível retornado; para escrita, prova de releitura confirmando a alteração; saída do Burp Autorize marcando Bypassed!.
Como mitigar
Corrigir BAC não é aplicar um patch pontual. É uma decisão de arquitetura: autorização centralizada, negada por padrão e amarrada ao objeto.
1. Consulte com escopo de propriedade (a correção mais robusta)
A melhor defesa contra IDOR é nunca conseguir buscar um objeto que não é seu. Em vez de “buscar por id e depois checar”, filtre pela identidade da sessão na própria query:
# ERRADO: busca por id, confia que o id implica direito
order = db.query(Order).filter(Order.id == order_id).first()
# CERTO: o objeto só existe na query se pertencer ao usuário da sessão
order = (db.query(Order)
.filter(Order.id == order_id,
Order.owner_id == g.current_user.id) # escopo!
.first())
if order is None:
abort(404) # 404 também para "não autorizado" — não confirme a existência do objeto
Com WHERE owner_id = :session_user, um id de outra pessoa simplesmente não retorna nada. A autorização vira uma consequência da consulta, não um passo separado que pode ser esquecido. Em multi-tenant, adicione também tenant_id == g.current_user.tenant_id.
2. Autorização centralizada, deny-by-default
Não espalhe if user.is_admin pelo código. Centralize a decisão numa camada/política que todo endpoint atravessa, e faça o padrão ser negar:
def authorize(user, action, resource):
rule = POLICIES.get((action, type(resource).__name__))
if rule is None:
raise Forbidden() # deny-by-default: sem regra = negado
if not rule(user, resource):
raise Forbidden()
@app.patch("/api/orders/<int:order_id>")
@login_required
def update_order(order_id):
order = get_owned_or_404(Order, order_id, g.current_user) # escopo (passo 1)
authorize(g.current_user, "order:update", order) # política (passo 2)
...
Defesa em profundidade: a query com escopo já barra o horizontal; a política explícita cobre regras finas e o vertical.
3. RBAC/ABAC para funções (contra BFLA)
Para escalada vertical, proteja funções por papel. Aplique a checagem de papel no servidor, no roteamento, antes de chegar ao handler — nunca confie em a UI esconder o botão:
@app.post("/api/admin/users")
@require_role("admin") # RBAC: BFLA bloqueado na porta
def create_user(): ...
Para regras dependentes de contexto (horário, departamento, status do recurso), use ABAC — atributos do usuário e do objeto na decisão.
4. Allowlist de campos contra mass assignment
Nunca faça bind cego do body no modelo. Aceite apenas os campos que o usuário pode controlar:
ALLOWED = {"name", "email", "phone"} # 'role' e 'tenant_id' fora
data = {k: v for k, v in request.json.items() if k in ALLOWED}
user.update(**data)
5. Referências indiretas quando fizer sentido
Em vez de expor o id global do banco, exponha um mapa indireto por sessão (ex.: índice 0,1,2... que o servidor traduz para os IDs reais daquele usuário). É uma camada extra, não um substituto da checagem de propriedade — mas reduz a superfície de enumeração. Jamais trate UUID/hash como “autorização por obscuridade”.
6. Testes automatizados de autorização
Transforme a metodologia de pentest em teste de regressão: para cada endpoint sensível, um teste que verifica que a conta A recebe 404/403 ao acessar objeto da conta B, em todos os verbos. Rode no CI. BAC é a categoria que mais se beneficia de testes negativos automatizados, porque o scanner não pega.
Checklist de mitigação
- Toda consulta de objeto é escopada pela identidade da sessão (
WHERE owner_id/tenant_id = :session). - Autorização centralizada numa camada única, com política deny-by-default.
- Checagem de propriedade por objeto em todos os verbos (
GET,PUT,PATCH,DELETE) — não só na leitura. - RBAC/ABAC aplicado no servidor para funções administrativas (contra BFLA), antes do handler.
- Allowlist de campos em create/update (contra mass assignment);
role/tenant_id/is_adminnunca vêm do cliente. - Resolvers de GraphQL autorizam por objeto individualmente, incluindo
node(id:). - Resposta consistente (
404) para objeto inexistente ou não autorizado — não vaze existência via403vs404. - Identificadores não previsíveis (UUID v4 aleatório) como camada extra — nunca como controle único.
- Testes automatizados de autorização (conta A x objeto de B) rodando no CI.
No fim, IDOR e Broken Access Control se resumem a uma pergunta que o servidor esqueceu de fazer: “esse objeto é mesmo seu?”. Autenticar responde quem está batendo na porta. Autorizar decide quais portas se abrem. São coisas diferentes, e confundir as duas é o bug. Quando o código busca pelo id que o cliente mandou e devolve sem checar de quem é, ele entrega a segurança à boa-fé do usuário, que nunca foi um bom modelo de ameaça. Escope cada query à sessão, negue por padrão e teste o acesso cruzado: feito isso, trocar um número na URL deixa de vazar dados e vira só um 404.


