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:2021 — Broken Access Control | Tempo de leitura: ~17 min
(Nota de mapeamento: O mecanismo subjacente — mass assignment — é CWE-915, que o OWASP mapeia para A08 (Software & 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_requiredcomfields = '__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, POSTrole=staffna 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. É controle administrativo total sobre a aplicação.
O cenário que torna isso concreto é uma plataforma com camadas de poder — digamos um site de petições cívicas onde cidadãos criam e assinam petições, moderadores aprovam ou rejeitam conteúdo pelo Django admin, e administradores gerenciam tudo, com os níveis aplicados por is_staff mais pertencimento a grupo Django. Agora imagine que o endpoint de edição de perfil vincula tudo o que o formulário envia: um cidadão comum faz POST de {"is_staff": true} junto com a mudança do seu nome, o formulário grava, e ele tem acesso de moderador — o suficiente para aprovar sua própria petição, dispensar denúncias contra ela, ou derrubar a de um rival. Nenhuma credencial roubada, nenhuma cadeia de exploits; um campo a mais em um formulário que ele já tinha permissão de enviar. É essa a lacuna de que trata este post: a distância entre os campos que um formulário renderiza e os campos que ele aceita.
A escalação de privilégios em uma aplicação Django quase sempre chega pelo mesmo mecanismo: um formulário ou serializer que vincula dados da requisição a um modelo contendo campos que carregam permissões (is_staff, is_superuser, groups, user_permissions) sem excluí-los. O desenvolvedor construiu um endpoint de edição de perfil, usou fields = '__all__' por conveniência, e silenciosamente deu a cada usuário a capacidade de escrever seu próprio papel. O admin do Django é o prêmio final: defina is_staff = True e você pode fazer login na interface de administração, inspecionar todos os modelos e — se is_superuser também for gravável — conceder a si mesmo acesso irrestrito à aplicação inteira e seus dados.
A razão pela qual esta vulnerabilidade persiste é que o Django e o DRF (Django REST Framework — a biblioteca padrão de facto para construir APIs Web em Django) tratam campos de permissão como campos de modelo comuns. O ORM não faz distinção entre first_name e is_superuser: ambos são colunas na tabela auth_user, ambos são graváveis por save(), e ambos aparecem em fields = '__all__'. Nem ModelForm nem ModelSerializer aplica qualquer proteção especial a campos que carregam permissões. O desenvolvedor deve explicitamente excluí-los, e a falha em fazê-lo é invisível no desenvolvimento, onde o único desenvolvedor testando o endpoint já é o superuser e nunca pensaria em POSTar {"is_staff": true} contra seu próprio formulário de edição de perfil.
Neste post veremos como a escalação vertical de privilégios funciona no Django, por que ModelForm e ModelSerializer tornam fácil publicá-la, as armadilhas específicas de vinculação de campos tanto em formulários Django quanto em serializers DRF, e como fechar a lacuna com listas explícitas de campos permitidos e uma mentalidade de defesa em profundidade que trata cada endpoint gravável de User como um caminho potencial de escalação.
O Ataque: O Que É e Como Funciona
Um usuário abre as DevTools do navegador, inspeciona o formulário de edição de perfil e vê três campos: first_name, last_name, email. Ele intercepta a submissão do formulário e adiciona mais um campo ao corpo do POST: 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 Post 6 cobriu o caso horizontal (um usuário acessando recursos de outro usuário no mesmo nível de privilégio). Este post é sobre alcançar para cima. 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 vinculação de serializers do DRF funcionam. Quando um ModelForm é instanciado com request.POST, ele vincula cada chave presente nos dados POST ao campo de modelo correspondente, desde que esse campo esteja listado no atributo fields do formulário. Quando fields = '__all__', todo campo do modelo é listado. O mesmo se aplica ao ModelSerializer do DRF. O desenvolvedor pretendia que o endpoint atualizasse first_name, last_name e email, mas como nenhuma lista de permissão explícita foi definida, o endpoint também aceita is_staff, is_superuser e groups.
O ataque não exige ferramentas especiais. O atacante submete o formulário de perfil normalmente, intercepta a requisição com um proxy ou a aba de rede do navegador, e adiciona is_staff=true (POST de formulário) ou "is_staff": true (API JSON) ao corpo. Se o backend usa fields = '__all__', o campo é aceito e gravado no banco de dados. Se o site admin expõe o gerenciamento de usuários (expõe por padrão para superusers), o atacante pode ir além e submeter "is_superuser": true na mesma requisição. Funciona em ambas as stacks: com um Django ModelForm o atacante cria o corpo POST ou adiciona campos ocultos e o formulário no servidor vincula qualquer coisa em sua lista fields; com um DRF ModelSerializer ele adiciona chaves extras ao corpo JSON, o DRF as desserializa em validated_data, e serializer.save() as grava.
Incidentes Reais
Incidente de Mass Assignment no GitHub (2012)
Em março de 2012, o pesquisador de segurança Egor Homakov demonstrou uma vulnerabilidade de mass assignment contra o próprio GitHub — a plataforma que hospeda a maioria do código open-source do mundo. A aplicação Ruby on Rails do GitHub aceitava parâmetros submetidos pelo usuário sem uma lista explícita de permissão (attr_accessible não estava aplicado). Homakov criou uma requisição que fez over-posting no campo user_id no formulário de atualização de chaves públicas SSH, associando sua própria chave à conta da organização Rails — uma das contas mais privilegiadas da plataforma. Como chaves SSH são associadas a contas (não a repositórios), o ataque deu a ele acesso de commit a cada repositório pertencente àquela conta. Ele provou o ponto fazendo um commit de um arquivo no repositório oficial do Rails. O GitHub respondeu suspendendo temporariamente a conta dele (depois revertido), corrigindo a vulnerabilidade e fortalecendo suas práticas de filtragem de parâmetros. O incidente se tornou uma força motriz por trás do padrão strong-parameters introduzido no Rails 4, que substituiu a abordagem mais fraca de attr_accessible em nível de modelo por whitelisting de parâmetros em nível de controller.
O incidente foi o momento divisor de águas que fez a indústria levar a sério mass assignment e escalação de privilégios via over-posting. Suas lições se aplicam identicamente ao ModelForm do Django e ao ModelSerializer do DRF: se você não enumera quais campos o cliente pode gravar, o cliente decide por você, e os campos de permissão estão a apenas um parâmetro POST de distância de serem graváveis. O ataque não exigiu exploit, nenhuma injeção, nenhum zero-day — apenas o conhecimento de que o servidor aceitava campos que não deveria. Em termos de MITRE ATT&CK, o acesso inicial mapeia para T1190 — Exploit Public-Facing Application: o atacante explorou uma falha de software — a superfície de vinculação de campos desprotegida — para obter acesso não autorizado. (T1548, Abuse Elevation Control Mechanism, descreve elevação em nível de SO como UAC ou sudo, e T1078, Valid Accounts, trata de abuso de credenciais — nenhum captura um bug de mass assignment em código de aplicação.)
Os riscos regulatórios só aumentaram desde 2012. O incidente do GitHub é anterior às leis modernas de notificação de incidentes, mas o mesmo bug hoje tem outro peso: uma escalação por mass assignment que dá a um atacante acesso administrativo a dados pessoais é um incidente de segurança notificável — sob a LGPD do Brasil (Art. 48, comunicar a ANPD e os titulares afetados) ou o GDPR da UE (Art. 33, dever de notificação em 72 horas). Um fields = '__all__' esquecido em um modelo User é, portanto, não apenas um code smell mas uma responsabilidade de conformidade.
Source: GitHub Blog — Public Key Security Vulnerability and Mitigation (2012)
Proteções Padrão do Django
A resposta do Django à escalação de privilégios é direta: não há nenhuma. O framework não fornece proteção automática contra um formulário ou serializer que expõe campos que carregam permissões. Eis por quê:
-
ModelFormtrata todos os campos igualmente. Quando você definefields = '__all__', todo campo no modelo — incluindois_staff,is_superuser,groupseuser_permissions— se torna um campo de formulário que aceita dados POST. A documentação do Django explicitamente alerta contrafields = '__all__'por exatamente esta razão, mas o alerta é uma nota na documentação, não um mecanismo de aplicação. -
O
ModelSerializerdo DRF faz o mesmo.fields = '__all__'em umUserSerializersignifica que o serializer aceita e gravais_staffeis_superusera partir do payload da requisição. A documentação do DRF recomenda listas explícitas de campos, mas nada previne ou alerta em tempo de execução. -
O modelo
Usernão tem conceito de "campos protegidos".is_staffeis_superusersãoBooleanFields no modeloUser, 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. -
O admin do Django é a única interface embutida que aplica restrições de campos no modelo
User.UserAdminusa umUserChangeFormcustomizado com fieldsets cuidadosamente curados — mas o admin não é seu endpoint voltado ao usuário. Suas views customizadas 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ê. -
@permission_requiredePermissionRequiredMixinsão em nível de modelo, não em nível de campo. Eles controlam acesso a uma view, não quais campos dentro da view são graváveis. Um usuário que passahas_perm('auth.change_user')pode alterar qualquer campo em qualquer usuário — incluindo promover a si mesmo — a menos que o formulário ou serializer o restrinja.
A única coisa que o Django faz corretamente é separar o admin da aplicação: o admin é uma interface de power-user com suas próprias restrições de campos. A própria página de ModelForm da documentação do Django alerta que “a falha em [definir fields explicitamente] pode facilmente levar a problemas de segurança quando um formulário permite que um usuário defina certos campos, especialmente quando novos campos são adicionados a um modelo.” Esse alerta mira diretamente em fields = '__all__'.
O que me surpreendeu durante a pesquisa é que 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 — ModelForm(model=User, fields='__all__') em uma página de edição de perfil — é toda a vulnerabilidade, e o Django é completamente silencioso sobre isso.
Padrão Vulnerável: O Que NÃO Fazer
Padrão 1 — Um serializer DRF com fields = '__all__' no modelo User
# INSEGURO — todo campo do modelo User é gravável pela API
from rest_framework import serializers
from django.contrib.auth.models import User
class UserSerializer(serializers.ModelSerializer):
class Meta:
model = User
fields = '__all__'
# INSEGURO — a view aceita PATCH com qualquer campo
from rest_framework import generics, permissions
from django.contrib.auth.models import User
from .serializers import UserSerializer
class UserProfileView(generics.RetrieveUpdateAPIView):
serializer_class = UserSerializer
permission_classes = [permissions.IsAuthenticated]
def get_object(self):
return self.request.user
O que dá errado: Um usuário autenticado envia PATCH /api/profile/ {"is_superuser": true}. O serializer valida o campo (é um booleano, e true é um booleano válido), a view chama serializer.save(), e o usuário agora é superuser. 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.
Padrão 2 — Um Django ModelForm com fields = '__all__'
Esta variante é enganosa porque o formulário renderizado parece seguro. O template exibe apenas first_name, last_name e email. Mas o ProfileForm aceita todo campo no modelo User porque fields = '__all__'. O que o template renderiza e o que o formulário aceita são duas coisas completamente diferentes. O atacante cria um POST com is_staff=on&is_superuser=on (convenção de checkbox do Django), o formulário valida, e form.save() grava ambas as flags no banco de dados. O template era cosmético; o formulário no servidor estava escancarado.
# INSEGURO — um formulário de edição de perfil que expõe todo campo do User
from django import forms
from django.contrib.auth.models import User
class ProfileForm(forms.ModelForm):
class Meta:
model = User
fields = '__all__'
# INSEGURO — a view vincula qualquer dado POST ao modelo User
from django.contrib.auth.decorators import login_required
from django.shortcuts import render, redirect
@login_required
def edit_profile(request):
if request.method == 'POST':
form = ProfileForm(request.POST, instance=request.user)
if form.is_valid():
form.save()
return redirect('profile')
else:
form = ProfileForm(instance=request.user)
return render(request, 'accounts/edit_profile.html', {'form': form})
Padrão 3 — Uma view customizada de registro ou atualização que espalha dados da requisição
O endpoint de registro espera username, email e password. Mas **request.data passa cada chave no payload para create_user(), que internamente chama User(**kwargs). Um atacante submete {"username": "evil", "password": "pass123", "is_staff": true, "is_superuser": true} e se registra como superuser. Uma linha. Nenhum toolkit de exploits.
# INSEGURO — passando dados da requisição diretamente ao construtor do modelo
from django.contrib.auth.models import User
from rest_framework.decorators import api_view, permission_classes
from rest_framework.permissions import AllowAny
from rest_framework.response import Response
@api_view(['POST'])
@permission_classes([AllowAny])
def register(request):
User.objects.create_user(**request.data) # ← cada chave no payload vira um campo
return Response({'status': 'created'}, status=201)
Implementação Segura: O Jeito Django
Regra 1 — Enumere campos explicitamente — nunca use '__all__' em modelos com campos de permissão
A correção principal é a enumeração disciplinada de campos. Liste exatamente quais campos o cliente pode ler e gravar — todo o resto é excluído:
# SEGURO — apenas campos seguros de perfil são expostos; campos de permissão são excluídos
from rest_framework import serializers
from django.contrib.auth.models import User
class UserProfileSerializer(serializers.ModelSerializer):
class Meta:
model = User
fields = ['id', 'username', 'first_name', 'last_name', 'email']
read_only_fields = ['id', 'username']
Mesmo se o modelo ganhar novos campos no futuro (uma fonte comum de regressões), eles não são automaticamente expostos — apenas os campos na lista explícita são vinculados. Este é o princípio da allowlist: tudo é negado a menos que explicitamente permitido.
Para o equivalente em Django ModelForm:
# SEGURO — lista explícita de campos exclui flags de permissão
from django import forms
from django.contrib.auth.models import User
class ProfileForm(forms.ModelForm):
class Meta:
model = User
fields = ['first_name', 'last_name', 'email']
Regra 2 — Use read_only_fields como uma camada de defesa em profundidade para campos de permissão
Mesmo quando você tem uma lista explícita de fields que não inclui campos de permissão, adicionar read_only_fields para atributos sensíveis fornece uma segunda barreira contra refatorações futuras que possam acidentalmente reexpô-los:
# SEGURO — mesmo que alguém adicione 'is_staff' a fields depois, não pode ser gravado
class UserAdminSerializer(serializers.ModelSerializer):
"""Serializer para uso administrativo — lê campos de permissão mas nunca os grava
a partir da requisição. Apenas código interno (management commands, signals) os define."""
class Meta:
model = User
fields = ['id', 'username', 'first_name', 'last_name', 'email',
'is_active', 'is_staff', 'is_superuser', 'date_joined']
read_only_fields = ['id', 'username', 'is_staff', 'is_superuser',
'is_active', 'date_joined']
read_only_fields garante que mesmo se o campo aparecer na saída do serializer (para exibição), ele nunca é populado a partir dos dados da requisição recebida. O DRF silenciosamente o remove de validated_data antes de chamar save().
Regra 3 — Nunca espalhe dados da requisição em construtores de modelo ou create_user()
Substitua **request.data por extração explícita dos campos esperados:
# SEGURO — apenas campos esperados são extraídos; flags de permissão não podem ser injetadas
from django.contrib.auth.models import User
from rest_framework.decorators import api_view, permission_classes
from rest_framework.permissions import AllowAny
from rest_framework.response import Response
from rest_framework import status
@api_view(['POST'])
@permission_classes([AllowAny])
def register(request):
username = request.data.get('username')
email = request.data.get('email')
password = request.data.get('password')
if not all([username, email, password]):
return Response({'error': 'Campos obrigatórios ausentes.'}, status=status.HTTP_400_BAD_REQUEST)
if User.objects.filter(username=username).exists():
return Response({'error': 'Nome de usuário já existe.'}, status=status.HTTP_409_CONFLICT)
User.objects.create_user(username=username, email=email, password=password)
return Response({'status': 'created'}, status=status.HTTP_201_CREATED)
A extração explícita significa que nenhum campo inesperado — is_staff, is_superuser, groups — pode entrar no payload. Isso é o equivalente da parametrização SQL do Post 1: a estrutura da operação é definida no código, e apenas os valores vêm do usuário.
Regra 4 — Proteja ações administrativas com verificações explícitas de permissão
Quando uma view legitimamente precisa modificar campos de permissão — por exemplo, um endpoint administrativo que ativa ou desativa um usuário — proteja-a com uma verificação explícita de papel, nunca com o mesmo serializer usado pelo endpoint de perfil voltado ao usuário:
# SEGURO — um endpoint separado apenas para admin com aplicação explícita de permissão
from rest_framework import generics, permissions, serializers
from django.contrib.auth.models import User
class AdminUserActivationSerializer(serializers.ModelSerializer):
class Meta:
model = User
fields = ['is_active']
class AdminUserActivationView(generics.UpdateAPIView):
queryset = User.objects.all()
serializer_class = AdminUserActivationSerializer
permission_classes = [permissions.IsAdminUser] # apenas superusers
O princípio é serializers separados para audiências separadas: um serializer voltado ao usuário que expõe apenas campos de perfil, e um serializer voltado ao admin que expõe apenas os campos que a ação administrativa precisa, protegido por uma classe de permissão que aplica o papel de admin.
Regra 5 — Audite cada formulário e serializer que toca o modelo User
Execute uma varredura estática do seu projeto para encontrar cada ModelForm e ModelSerializer que referencia o modelo User (ou um AUTH_USER_MODEL customizado). Para cada um, verifique que:
fieldsé uma lista explícita (nunca'__all__').- Campos que carregam permissões (
is_staff,is_superuser,is_active,groups,user_permissions) estão ausentes defieldsou presentes emread_only_fields. - Nenhuma view espalha
request.dataourequest.POSTem uma chamadaUser()oucreate_user().
Uma verificação com Semgrep ou grep pode automatizar isso:
# Encontre qualquer ModelSerializer ou ModelForm em User com fields='__all__'
grep -rn "model = User" --include="*.py" | \
xargs grep -l "fields.*=.*'__all__'"
Qualquer resultado é um vetor potencial de escalação.
Checklist de Prevenção de Escalação de Privilégios
| Controle | O que cobre |
|---|---|
Lista explícita de fields em cada ModelForm / ModelSerializer que toca User |
O vetor principal de escalação — '__all__' silenciosamente expõe is_staff/is_superuser |
read_only_fields para is_staff, is_superuser, is_active, groups, user_permissions |
Defesa em profundidade — bloqueia gravações mesmo se uma refatoração futura readicionar o campo a fields |
Nunca espalhar **request.data / **request.POST em um construtor de modelo |
O bypass de registro/atualização — atacante injeta campos arbitrários junto com os esperados |
| Serializers separados para endpoints voltados ao usuário vs. voltados ao admin | Fronteira de privilégio — o endpoint do usuário fisicamente não pode referenciar campos de permissão |
IsAdminUser ou PermissionRequiredMixin em ações apenas para admin |
Aplicação de gate — mesmo se o serializer fosse mal configurado, a view rejeita não-admins |
Auditoria estática de cada formulário/serializer no modelo User |
Prevenção de regressão — captura reintrodução de '__all__' antes de publicar |
Não tenho certeza completa de onde está a linha entre exclude (denylist) e fields explícito (allowlist) na prática. A documentação do Django alerta que exclude é perigoso porque novos campos adicionados ao modelo são automaticamente incluídos, e isso faz sentido. Mas já vi projetos usando exclude = ('is_staff', 'is_superuser', 'groups', 'user_permissions') e argumentando que é mais claro sobre a intenção. Optei por fields explícito porque o princípio de allowlist é mais seguro, mas consigo ver o outro argumento. A documentação do Django concorda comigo aqui, mas o fato de exclude existir e não estar deprecated provavelmente significa que o core team o considera legítimo para alguns casos de uso.
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 crítico, a mesma classe de defeito que uma query raw() sem parametrização na Série I.
A ideia compensatória é 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 vire is_staff, 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 IsAdminUser no endpoint sensível, um has_object_permission no objeto. O allowlist barra a escrita; o segundo portão garante que um allowlist esquecido não seja o fim do jogo.
Detectando Automaticamente
Testando Sua Defesa
O teste que prova a correção é uma tentativa de autopromoção que deve falhar: um usuário comum faz PATCH de {"is_staff": true} (ou POST de is_staff=on) e você afirma que a flag continua False enquanto os campos legítimos ainda salvam. Os testes de API DRF cobrem o caminho do serializer:
# tests/test_privilege_escalation.py
from rest_framework.test import APITestCase
from django.contrib.auth.models import User, Group
class PrivilegeEscalationTests(APITestCase):
def setUp(self):
self.user = User.objects.create_user(
'regular', email='regular@example.com', password='testpass123'
)
self.staff_user = User.objects.create_user(
'staffmember', email='staff@example.com', password='testpass123',
is_staff=True,
)
self.target_group = Group.objects.create(name='Moderadores')
def test_user_cannot_set_is_staff_via_profile_update(self):
"""Um usuário regular NÃO deve poder se promover a staff."""
self.client.force_authenticate(user=self.user)
response = self.client.patch('/api/profile/', {'is_staff': True}, format='json')
self.assertEqual(response.status_code, 200) # campo silenciosamente ignorado
self.user.refresh_from_db()
self.assertFalse(self.user.is_staff)
def test_user_cannot_set_is_superuser_via_profile_update(self):
"""Um usuário regular NÃO deve poder se promover a superuser."""
self.client.force_authenticate(user=self.user)
response = self.client.patch(
'/api/profile/', {'is_superuser': True}, format='json'
)
self.assertEqual(response.status_code, 200)
self.user.refresh_from_db()
self.assertFalse(self.user.is_superuser)
def test_user_cannot_add_groups_via_profile_update(self):
"""Um usuário regular NÃO deve poder se adicionar a grupos."""
self.client.force_authenticate(user=self.user)
response = self.client.patch(
'/api/profile/', {'groups': [self.target_group.pk]}, format='json'
)
self.assertEqual(response.status_code, 200)
self.user.refresh_from_db()
self.assertNotIn(self.target_group, self.user.groups.all())
def test_user_can_update_safe_profile_fields(self):
"""Um usuário PODE atualizar seu próprio first_name, last_name, email."""
self.client.force_authenticate(user=self.user)
response = self.client.patch(
'/api/profile/',
{'first_name': 'Atualizado', 'last_name': 'Nome', 'email': 'novo@example.com'},
format='json',
)
self.assertEqual(response.status_code, 200)
self.user.refresh_from_db()
self.assertEqual(self.user.first_name, 'Atualizado')
self.assertEqual(self.user.last_name, 'Nome')
self.assertEqual(self.user.email, 'novo@example.com')
def test_registration_cannot_set_is_staff(self):
"""O registro NÃO deve aceitar flags de permissão no payload."""
response = self.client.post('/api/register/', {
'username': 'attacker',
'email': 'attacker@example.com',
'password': 'strongpass123',
'is_staff': True,
'is_superuser': True,
}, format='json')
self.assertEqual(response.status_code, 201)
new_user = User.objects.filter(username='attacker').first()
self.assertIsNotNone(new_user) # usuário deve ser criado
self.assertFalse(new_user.is_staff)
self.assertFalse(new_user.is_superuser)
def test_staff_cannot_promote_self_to_superuser(self):
"""Mesmo usuários staff NÃO devem poder se autopromover a superuser via API."""
self.client.force_authenticate(user=self.staff_user)
response = self.client.patch(
'/api/profile/', {'is_superuser': True}, format='json'
)
self.assertEqual(response.status_code, 200)
self.staff_user.refresh_from_db()
self.assertFalse(self.staff_user.is_superuser)
O caminho do ModelForm do Django merece seu próprio teste — um POST forjado com is_staff=on não deve promover o usuário:
# tests/test_privilege_escalation_form.py
from django.test import TestCase, RequestFactory
from django.contrib.auth.models import User
class ProfileFormEscalationTests(TestCase):
def setUp(self):
self.user = User.objects.create_user(
'alice', email='alice@example.com', password='testpass123'
)
def test_form_post_cannot_set_is_staff(self):
"""Um POST criado com is_staff=on não deve promover o usuário."""
self.client.login(username='alice', password='testpass123')
response = self.client.post('/accounts/profile/edit/', {
'first_name': 'Alice',
'last_name': 'Silva',
'email': 'alice@example.com',
'is_staff': 'on', # injetado
'is_superuser': 'on', # injetado
})
self.user.refresh_from_db()
self.assertFalse(self.user.is_staff)
self.assertFalse(self.user.is_superuser)
Você também pode provar à mão contra uma instância em execução — o token de um usuário comum fazendo PATCH nas flags de permissão, e então confirmando que elas não pegaram:
# Tentar escalação de privilégio contra o endpoint de perfil
curl -X PATCH https://staging.example.com/api/profile/ \
-H "Authorization: Token <token-usuario-regular>" \
-H "Content-Type: application/json" \
-d '{"is_staff": true, "is_superuser": true}'
# Verificar que o usuário NÃO foi promovido
curl https://staging.example.com/api/profile/ \
-H "Authorization: Token <token-usuario-regular>" | python -m json.tool
# Esperado: "is_staff": false, "is_superuser": false (ou campos ausentes da resposta)
Fazendo a Varredura
Eis o que a ferramentaria ensina: 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 são para chamadas perigosas (eval, subprocess … shell=True, mark_safe), 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 e — verificado diretamente — nem o ruleset de registro r/python.django (só dispara um nit de estilo não relacionado, "use render() em vez de HttpResponse").
Vale parar nisso, porque fields = '__all__' é um antipadrão bem conhecido — ele só não é uma regra 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. Rode-os e você o pega — 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 dispara na view vulnerável e fica em silêncio na correção com allowlist — o teste dispara/silencia que as ferramentas padrão não conseguiram dar — imposto em um job de CI hermético. A mesma regra serve ao Post 10 (Mass Assignment): o mesmo sink, um campo exposto diferente.
# as ferramentas padrão — perdem a classe
bandit -r labs/post_07_privesc/
semgrep scan --config p/django --config p/python --config p/owasp-top-ten labs/post_07_privesc/
# a regra personalizada — pega
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 achado
semgrep scan --config rules/mass_assignment.yaml labs/post_07_privesc/views_secure.py # 0 achados
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 virá-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 ORM do Django não faz distinção entre first_name e is_superuser — ambos são apenas colunas em auth_user. Essa distinção vive inteiramente na lista fields que você escreve e mantém, e é por isso que a escalação vertical é uma disciplina de manutenção, não um recurso do framework que você liga.
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
- django-security-lab — o laboratório executável deste post (
labs/post_07_privesc/) - A regra Semgrep personalizada deste post —
rules/mass_assignment.yaml(com sua fixture de teste,rules/mass_assignment.py) - Ruff —
DJ007 django-all-with-model-form(a verificação de linter parafields='all') - MITRE ATT&CK — T1190 Exploit Public-Facing Application
- Django Docs — Permissions and Authorization
- Django Docs — User Model Reference
- DRF Docs — Specifying Which Fields to Include
- PortSwigger Web Security Academy — Vertical Privilege Escalation
- OWASP A01:2021 — Broken Access Control
- OWASP Cheat Sheet — Mass Assignment
- Web Security for Developers: Real Threats, Practical Defense (Malcolm McDonald) — Chapter 11: Privilege Escalation and Access Control