Escalação de Privilégios: Como fields='__all__' Entrega a Cada Usuário as Chaves do Seu Django Admin

Escalação de Privilégios: Como fields='__all__' Entrega a Cada Usuário as Chaves do Seu Django Admin

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

(Nota de mapeamento: O mecanismo subjacente — mass assignment — é CWE-915, que o OWASP mapeia para A08 (Software or Data Integrity Failures). Este post categoriza sob A01 porque o impacto é controle de acesso quebrado: elevação de privilégio não autorizada. Ambos os enquadramentos descrevem o mesmo bug de ângulos diferentes.)

🧪 Rode você mesmo. Este ataque vem como um laboratório executável no django-security-lab: um formulário de edição de perfil sob @login_required com fields = '__all__', e uma versão segura com uma lista explícita de campos. Tudo roda pela linha de comando — faça login como um membro comum, POST role=staff na view vulnerável e veja uma flag exclusiva de staff ficar ao alcance; a view com allowlist descarta o campo e a flag continua trancada. Clone e reproduza cada passo.

O Post 6 introduziu a falha fundamental de controle de acesso em aplicações Django: uma view que verifica identidade mas nunca verifica direito. Esse era o caso horizontal, em que um usuário lê ou escreve objetos de outro usuário no mesmo nível de privilégio. O Post 7 leva a mesma categoria na vertical. O atacante não está alcançando lateralmente os dados de outro usuário; ele está alcançando para cima, promovendo-se de um usuário regular a staff ou de staff a superuser. O resultado não é um vazamento de dados. No pior caso, é controle administrativo total sobre a aplicação.

Este me toca de perto porque o Petição Brasil tem exatamente a forma que torna o bug perigoso: um sistema de papéis em três níveis onde cidadãos criam e assinam petições, moderadores aprovam ou rejeitam conteúdo pelo Django admin, e administradores gerenciam a plataforma, com os níveis aplicados por is_staff mais pertencimento a grupo Django. Em uma plataforma assim, um endpoint de edição de perfil que vincula tudo o que o formulário envia seria catastrófico: um cidadão comum que enfia "is_staff": true e o grupo dos moderadores em uma requisição de mudança de nome se dá acesso de moderador: aprovar sua própria petição, dispensar denúncias contra ela, derrubar a de um rival. Nenhuma credencial roubada, nenhuma cadeia de exploits; um campo a mais em uma requisição que ele já tinha permissão de enviar. Garantir que essa lacuna não exista, que todo formulário e serializer que toca o usuário enumere seus campos, é o tipo de verificação que o sistema de papéis torna inegociável. É disso que trata este post: a distância entre os campos que um formulário renderiza e os campos que ele aceita.


O Ataque: O Que É e Como Funciona

Um usuário abre as DevTools do navegador na página de perfil e observa a requisição que ela envia: PATCH /api/profile/ com três campos, first_name, last_name e email. Ele a reenvia com mais um: "is_staff": true. O servidor aceita. Da próxima vez que ele visita /admin/, o Django o deixa entrar. Ele agora é staff.

Isso é escalação vertical de privilégios: obter um nível de acesso superior ao que foi concedido a você. O atacante tem uma conta legítima e autenticada. Ele descobre, por documentação, revisão de código-fonte ou simples experimentação, que um endpoint de edição de perfil ou atualização de usuário aceita campos que não deveria, e que esses campos mapeiam para as flags de permissão do Django. O ataque é uma única requisição.

A mecânica se resume a como a vinculação de formulários do Django e a de serializers do DRF funcionam (DRF é o Django REST Framework, a biblioteca padrão de facto para construir APIs Web em Django). Um ModelForm instanciado com request.POST vincula cada campo listado em seu atributo fields, e fields = '__all__' lista todo campo editável do modelo; um ModelSerializer do DRF com a mesma configuração desserializa cada chave do corpo JSON que corresponde a um campo gravável do modelo em validated_data, e serializer.save() a grava. O desenvolvedor pretendia que o endpoint atualizasse um nome e um e-mail, mas como nenhuma lista de permissão explícita foi definida, ele também aceita is_staff, is_superuser e groups. Nenhuma ferramenta especial é necessária: um proxy ou a aba de rede do navegador bastam para acrescentar a chave. O caminho da API é o mais fácil dos dois, porque um PATCH parcial precisa só da chave extra, enquanto um ModelForm sobre o próprio User também exige os obrigatórios username, password e date_joined, então um POST só com a flag não passa na validação desse formulário; o laboratório usa um modelo de perfil separado que carrega um campo de papel, em que role=staff sozinho basta.

A mesma promoção tem outras portas de entrada. Uma view que entrega a requisição inteira ao modelo, como User.objects.create_user(**request.data) no cadastro, transforma cada chave do corpo num campo sem formulário nenhum. Um usuário que pode editar outras contas não precisa de campo escondido algum: a documentação do Django avisa que dar a um não superusuário a permissão change_user "is ultimately the same as giving them superuser status", porque ele consegue elevar as permissões de qualquer um, inclusive as próprias. E uma ação administrativa protegida só por is_staff, como faz o IsAdminUser do DRF, cai diante do primeiro campo que o define.


Incidentes Reais

Camaleon CMS — CVE-2025-2304 (2025)

O Camaleon CMS é um sistema de gestão de conteúdo construído sobre Ruby on Rails. Seu controller de usuários tinha uma ação, updated_ajax, que rodava quando um usuário trocava a senha, e ela salvava a requisição numa linha: @user.update(params.require(:password).permit!). O permit! do Rails marca como permitido todo parâmetro sob aquela chave, o gêmeo no controller do fields = '__all__', então um formulário que mostrava uma senha e sua confirmação aceitava qualquer atributo do usuário. Joshua Martinelle, da Tenable, descobriu que um usuário de baixo privilégio podia acrescentar mais um parâmetro, o role do usuário, e salvar a si mesmo como administrador. A Tenable classificou a falha como High (CVSS 8.8) e contatou os mantenedores em novembro de 2024; a correção, incorporada em janeiro de 2025 e lançada na versão 2.9.1 em março, trocou o permit! por uma allowlist de exatamente password e password_confirmation.

O aviso não relata exploração; a lição é o tamanho do bug. Em termos de ATT&CK, o encaixe mais próximo é T1068 (Exploitation for Privilege Escalation), um erro de programação usado para elevar privilégios, embora o ATT&CK escreva essa técnica pensando em falhas do sistema operacional e do kernel. Para desenvolvedores Django, a tradução é uma linha: permit! é fields = '__all__', e a correção, uma lista dos dois campos que o formulário existe para alterar, é a lista fields explícita do Padrão 1. O Rails exige uma lista explícita de parâmetros permitidos desde a versão 4, lançada em 2013; o atalho que desliga a verificação é uma palavra, como o '__all__' é no Django.

Fontes: Tenable — TRA-2025-09: Camaleon CMS Privilege Escalation (CVE-2025-2304); Camaleon CMS — a correção: permitir só os campos de senha


Proteções Padrão do Django

A proteção do Django contra escalação de privilégios é mínima: ele obriga você a escolher uma lista de campos e depois aceita o que você escolheu.

  • O ModelForm se recusa a adivinhar, e depois confia em você. Um ModelForm cujo Meta não define nem fields nem exclude lança ImproperlyConfigured, então fields = '__all__' é um valor que alguém digitou. A documentação do Django chama os dois atalhos, '__all__' e exclude, de "much less secure" e diz que deixar os campos implícitos "can easily lead to security problems when a form unexpectedly allows a user to set certain fields, especially when new fields are added to a model." Definido o '__all__', todo campo editável, inclusive is_staff, is_superuser, groups e user_permissions, é vinculado a partir do POST.

  • O ModelSerializer do DRF faz o mesmo. O DRF também recusa um serializer sem fields ou exclude, mas fields = '__all__' em um UserSerializer aceita e grava is_staff, is_superuser e groups a partir do payload — e também password, salvo como foi enviado, sem hash.

  • O modelo User não tem conceito de "campos protegidos". is_staff e is_superuser são BooleanFields no modelo User, armazenados na mesma tabela, sem nenhuma anotação em nível de Django marcando-os como sensíveis. O ORM grava qualquer valor que você definir na instância. A falha também é invisível no desenvolvimento, onde quem testa o endpoint já é o superuser e nunca pensaria em enviar {"is_staff": true} contra o próprio perfil.

  • Verificações de permissão protegem views, não campos. @permission_required e PermissionRequiredMixin decidem quem chega a uma view, não quais campos dentro dela são graváveis. Até o UserChangeForm do próprio Django declara fields = '__all__'; o admin o limita com fieldsets. Suas próprias views e endpoints de API usam seus formulários e serializers, e se esses não excluem os campos de permissão, o Django não vai fazer isso por você.

O que me surpreendeu durante a pesquisa é que, passada essa primeira escolha, não existe nenhuma proteção em tempo de execução. Eu esperava que o Django ao menos registrasse um warning quando fields = '__all__' inclui is_staff ou is_superuser no modelo User. Não registra. Nem mensagem de log, nem system check, nada. A lacuna entre ler o alerta da documentação e o que desenvolvedores realmente constroem, fields = '__all__' em um formulário de edição de perfil, é toda a vulnerabilidade, e o Django é completamente silencioso sobre isso.


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

Padrão 1 — Um ModelForm de Perfil com fields = '__all__'

A edição de perfil do laboratório é o caso clássico. O modelo Profile carrega um campo role que a página exibe mas nunca oferece como campo de entrada, e uma view exclusiva de staff entrega uma flag a qualquer perfil cujo role seja "staff". A correção é a lista de campos:

 class ProfileForm(forms.ModelForm):
     class Meta:
         model = Profile
-        fields = "__all__"  # DANGER: exposes `role` to mass assignment
+        fields = ["display_name", "bio"]  # allowlist: `role` is unreachable

Na view vulnerável, um membro que faz POST de display_name=Alice&role=staff é salvo como staff, e a área de staff passa a devolver a flag. Na view segura o formulário não tem campo role, então a chave extra é descartada antes do save() e a área de staff continua respondendo 403. O que a página oferece e o que o formulário aceita eram duas coisas diferentes. As duas views estão no laboratório: views_vulnerable.py e views_secure.py. Mesmo que o modelo ganhe campos novos, uma lista explícita não os expõe. É o princípio da allowlist: tudo é negado a menos que explicitamente permitido.

Padrão 2 — Um Serializer DRF com fields = '__all__' em User

A versão de API fica atrás de uma RetrieveUpdateAPIView cujo get_object() devolve request.user, então cada usuário edita só a própria linha — e todos os campos graváveis dela:

 class UserSerializer(serializers.ModelSerializer):
     class Meta:
         model = User
-        fields = '__all__'
+        fields = ['id', 'username', 'first_name', 'last_name', 'email']
+        read_only_fields = ['id', 'username']

Contra o serializer antigo, PATCH /api/profile/ {"is_superuser": true, "is_staff": true} passa na validação (são booleanos, e true é um booleano válido), e serializer.save() grava os dois; com o DRF 3.18.2, uma lista groups na mesma requisição também é gravada. A próxima requisição para /admin/ é bem-sucedida. O desenvolvedor assumiu que o frontend controla quais campos são submetidos, mas o atacante contorna o frontend e submete diretamente. Com a lista explícita, as chaves extras são ignoradas: a requisição continua respondendo 200, salva o nome e deixa as duas flags em False.

Três regras valem para os dois padrões, e não para um só. Primeiro, em todo serializer que mostra um campo de permissão, liste-o também em read_only_fields, para que uma refatoração que o acrescente a fields ainda não consiga gravá-lo — o DRF descarta campos somente leitura da entrada. A opção cobre só os campos que o DRF gera a partir do modelo: um campo declarado explicitamente na classe do serializer a ignora e continua gravável, então declare esse com read_only=True ele mesmo. Segundo, dê às ações administrativas um serializer e uma view próprios (um endpoint de ativação cujo serializer tem fields = ['is_active'], por exemplo), protegidos por uma permissão que o usuário não consegue conceder a si mesmo. O IsAdminUser do DRF não serve sozinho: ele verifica só is_staff, exatamente a flag que o Padrão 2 deixa o usuário definir. Terceiro, audite cada formulário e serializer sobre User (ou um AUTH_USER_MODEL customizado): fields é uma lista explícita, os campos que carregam permissões (is_staff, is_superuser, is_active, groups, user_permissions) estão ausentes ou são somente leitura. A seção de varredura mostra como automatizar a primeira verificação.

A documentação do Django oferece '__all__' e exclude como atalhos "for cases where you can guarantee these security concerns do not apply to you". Uma denylist como exclude = ('is_staff', 'is_superuser', 'groups', 'user_permissions') deixa a intenção clara, mas falha aberta: um campo acrescentado ao modelo depois fica gravável por padrão. Em um modelo que carrega permissões essa garantia não pode ser dada, então ele recebe uma lista fields explícita.


A Visão do Analista

A escalação vertical de privilégios é onde a ideia de menor privilégio do CySA+ encontra a indiferença do ORM: para o Django, is_staff é só mais uma coluna, então "menor privilégio" não é uma configuração que você liga — é uma propriedade que você tem de manter na lista de fields de cada formulário e serializer. O bug de mass assignment é a falha dessa manutenção, e vale triá-lo como alta severidade: trate fields = '__all__' em qualquer modelo que carregue flags de permissão como um achado prioritário, como faria com uma query raw() sem parametrização na Série I, e classifique-o conforme quem alcança aquele formulário ou serializer e o que as flags concedem.

A segunda ideia é defesa em profundidade — nunca deixe um único campo gravável ser toda a fronteira. Um sistema de papéis bem projetado torna a escalação um problema de dois passos: mesmo que um atacante defina is_staff como verdadeiro, os poderes que importam (moderar conteúdo, ler dados pessoais) deveriam exigir um segundo portão que ele não consegue definir sozinho — pertencimento a grupo Django concedido apenas pelo admin, uma verificação has_perm() no endpoint sensível (não o IsAdminUser, que lê a mesma flag is_staff), um has_object_permission no objeto. Esse segundo portão se sustenta quando o vazamento é um único campo, como o role do laboratório ou uma lista exclude que esqueceu o is_staff; o '__all__' em User entrega groups e user_permissions na mesma requisição, então é o allowlist que barra a escrita, e o segundo portão é o que impede que um campo esquecido seja o fim do jogo.


Detectando Automaticamente

Testando Sua Defesa

Um único teste basta para provar a correção contra este payload: um membro injeta role=staff no formulário seguro, e você verifica que o papel não mudou e que a área de staff continua fechada. Este é o que o laboratório usa:

def test_exploit_is_blocked_on_secure_view(self):
    self.client.post("/privesc/secure/", {"role": "staff"})
    self.assertEqual(self.role(), "member")
    resp = self.client.get("/privesc/staff-area/")
    self.assertEqual(resp.status_code, 403)
    self.assertNotIn(FLAG, resp.content.decode())

O setUp cria alice com um perfil member e faz o login dela; self.role() lê o papel de volta do banco. Aponte o teste para /privesc/vulnerable/ e ele falha: o papel vira staff e a área de staff devolve a flag. Para um endpoint DRF a forma é a mesma: um usuário comum faz PATCH de {"is_staff": true}, depois refresh_from_db() e a verificação de que a flag continua False (a resposta é 200, porque a chave extra é ignorada, não rejeitada); repita para is_superuser, groups e um usuário staff tentando virar superuser. O tests.py do laboratório cobre as outras duas metades: o exploit funciona na view vulnerável, e a view segura continua salvando display_name.

Fazendo a Varredura

Os scanners padrão que esta série roda não pegam fields = '__all__'. Aponte o Bandit para o laboratório e ele não reporta nada nas views e formulários: suas verificações miram construções perigosas (eval, subprocess … shell=True, mark_safe, senhas embutidas), e a lista de fields de um formulário não é uma delas; a única coisa que ele sinaliza é uma senha embutida no arquivo de teste, ruído sem relação com a classe. Os pacotes da comunidade do Semgrep (p/django, p/python, p/owasp-top-ten) também não reportam nada; verificado diretamente, nem o ruleset de registro r/python.django reporta (só dispara a regra de auditoria de XSS direct-use-of-httpresponse, sem relação com a classe).

fields = '__all__' é um antipadrão bem conhecido; ele só não está nesses rulesets do Semgrep. As ferramentas que o sinalizam são linters Django dedicados: o DJ007 (django-all-with-model-form) do Ruff e o DJ07 do flake8-django, os dois escritos para ModelForm. Rode-os e você pega o formulário — mas um analista padronizado em Bandit + Semgrep recebe um relatório limpo sobre código genuinamente vulnerável, o que é um miss, não um passe.

Por isso o laboratório deste post traz uma pequena regra Semgrep personalizada, rules/mass_assignment.yaml, que sinaliza fields = "__all__" dentro de um Meta (cobrindo tanto ModelForm quanto ModelSerializer). Ela também pega as outras grafias de "todos os campos" (a constante ALL_FIELDS do Django, um exclude vazio, modelform_factory(…, fields="__all__")), e não distingue o Meta de um modelo do Meta de um FilterSet do django-filter, que ela também sinaliza. Ela não pega um exclude não vazio que esquece um campo; esse continua na lista de revisão. Ela dispara na view vulnerável e fica em silêncio na correção com allowlist, e um job de CI hermético verifica as duas coisas. A mesma regra serve ao post de mass assignment mais adiante na série: o mesmo sink, um campo exposto diferente.

# the standard tools — miss the class
bandit -r labs/post_07_privesc/
semgrep scan --config p/django --config p/python --config p/owasp-top-ten labs/post_07_privesc/views_vulnerable.py
semgrep scan --config p/django --config p/python --config p/owasp-top-ten labs/post_07_privesc/views_secure.py

# the custom rule — catches it
semgrep --test --config rules/mass_assignment.yaml rules/mass_assignment.py
semgrep scan --config rules/mass_assignment.yaml labs/post_07_privesc/views_vulnerable.py  # 1 finding
semgrep scan --config rules/mass_assignment.yaml labs/post_07_privesc/views_secure.py      # 0 findings

Não há DAST de apertar-o-botão para este: um scanner pode enviar is_staff=true, mas não tem como saber que aquele campo nunca deveria ser gravável pelo cliente ou que alterá-lo cruza uma fronteira de privilégio; isso é o seu allowlist e modelo de papéis, não algo que uma ferramenta black-box infere. As execuções capturadas de Bandit, Semgrep e da regra personalizada estão versionadas em scans/; o tests.py é a prova executável.


O hábito que este post me deixou: todo formulário ou serializer que grava em um modelo com um campo de papel ou de permissão recebe uma lista fields explícita, e '__all__' em um modelo assim não passa na revisão.

O Post 8 continua a Série II com Cross-Site Request Forgery (CSRF), em que o atacante não precisa das credenciais da vítima. O próprio navegador da vítima submete a requisição em nome do atacante, aproveitando o cookie de sessão que o sistema de autenticação do Django tão cuidadosamente configurou.

Leitura Complementar

← Voltar para a série