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: ~14 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.)
O Post 6 introduziu a falha fundamental de controle de acesso em aplicações Django: uma view que verifica identidade mas nunca verifica direito — 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.
Este importa para mim porque o Petição Brasil tem um sistema de papéis em três níveis: cidadãos criam e assinam petições, moderadores aprovam/rejeitam conteúdo pelo Django admin, e administradores gerenciam toda a plataforma. A distinção entre esses níveis é aplicada por is_staff + grupo Django ("Moderadores" / "Administradores"). Se um cidadão comum pudesse fazer POST de {"is_staff": true} no seu endpoint de perfil e o formulário gravasse cegamente no banco, ele teria acesso de moderador — a capacidade de aprovar sua própria petição, dispensar denúncias contra ela, ou derrubar petições concorrentes. Precisei verificar que meus formulários de registro e perfil não expõem essa superfície.
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 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
Escalação de privilégios é o ato de obter um nível de acesso superior ao que foi concedido a você. Ela vem em duas formas:
| Tipo | Descrição | Exemplo no Django |
|---|---|---|
| Vertical | Um usuário com menor privilégio obtém um papel com maior privilégio | Um usuário regular define is_staff = True e faz login no admin do Django |
| Horizontal | Um usuário acessa recursos no mesmo nível de privilégio mas pertencentes a um usuário diferente | Coberto pelo Post 6 (IDOR) — incluído aqui por completude |
Este post é exclusivamente sobre o caso vertical. 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: submeter o campo extra junto com os legítimos, e a aplicação o grava no banco de dados porque o formulário ou serializer nunca o excluiu.
A mecânica depende de 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: quando fields = '__all__', todo campo no modelo é aceito a partir do payload da requisição. 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.
Como os Atacantes Exploram
O ataque não exige ferramentas especiais — apenas a capacidade de criar uma requisição com campos adicionais além do que o formulário do frontend apresenta:
- O atacante abre as DevTools do navegador e inspeciona o formulário de edição de perfil. Ele nota os campos visíveis:
first_name,last_name,email. - Ele submete o formulário normalmente, interceptando a requisição com um proxy (Burp Suite) ou a aba de rede do navegador. Ele adiciona um campo extra ao corpo da requisição:
is_staff=true(para um POST de formulário) ou"is_staff": true(para um payload JSON de API). - Se o serializer ou formulário do backend usa
fields = '__all__', o campo é aceito e gravado no banco de dados. - O atacante navega até
/admin/e faz login com suas próprias credenciais. Ele agora é staff. - Se o site admin expõe o gerenciamento de usuários (expõe por padrão para superusers), o atacante se promove a superuser, ou diretamente submeteu
"is_superuser": trueno passo 2.
O ataque tem duas variantes baseadas na stack:
Baseado em formulário (Django ModelForm): O atacante adiciona campos ocultos ao formulário ou cria o corpo POST manualmente. O formulário no servidor vincula os campos extras se fields = '__all__' ou se os campos de permissão estão explicitamente incluídos.
Baseado em API (DRF ModelSerializer): O atacante adiciona chaves extras ao corpo JSON. O DRF as desserializa no dicionário de dados validados e as passa para serializer.save(), que chama instance.save() com os novos valores.
O mapeamento MITRE ATT&CK relevante é T1190 — Exploit Public-Facing Application: o atacante explora uma falha de software (a superfície de vinculação de campos desprotegida) em uma aplicação web pública para obter acesso não autorizado. Esta é uma melhor correspondência do que T1548 (Abuse Elevation Control Mechanism), que descreve o abuso de controles legítimos de elevação em nível de SO como UAC ou sudo, ou T1078 (Valid Accounts), que trata de abuso de credenciais — nenhum dos dois captura a mecânica de explorar um bug de mass assignment em código de aplicação.
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. O MITRE ATT&CK mapeia isso como T1190 (Exploit Public-Facing Application) — uma falha de software em um endpoint acessível pela web explorada para acesso não autorizado.
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__' — mas é uma nota na documentação, não uma proteção em tempo de execução. A lacuna entre ler esse alerta e o que desenvolvedores realmente constroem — ModelForm(model=User, fields='__all__') em uma página de edição de perfil — é toda a vulnerabilidade.
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__'
# 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})
O que dá errado: O formulário renderizado mostra apenas os campos que o template exibe — talvez first_name, last_name e email. Mas o ProfileForm aceita todo campo no modelo User porque fields = '__all__'. 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. A renderização de campos do template não forneceu nenhuma proteção no servidor — era apenas cosmética.
Padrão 3 — Uma view customizada de registro ou atualização que espalha dados da requisição
# 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) # splat: aceita QUALQUER chave
return Response({'status': 'created'}, status=201)
O que dá errado: O endpoint de registro espera username, email e password — mas **request.data passa cada chave no payload para create_user(). Um atacante submete {"username": "evil", "password": "pass123", "is_staff": true, "is_superuser": true}, e create_user() — que internamente chama User(**kwargs) — define ambas as flags na nova conta. O atacante se registra como superuser.
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 |
Testando Sua Defesa
Testes Unitários
# 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)
Testes de Formulário Django
# 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)
Análise Estática
# Semgrep — detectar fields='__all__' em serializers e formulários relacionados a User
semgrep --config "p/django" --include="*.py" .
# Grep — encontrar cada formulário/serializer que toca User com __all__
grep -rn "model.*=.*User" --include="*.py" | xargs grep -l "__all__"
Verificação Manual
# 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)
O Que Encontrei nos Meus Projetos
Auditei os formulários do Petição Brasil em busca exatamente da vulnerabilidade que este post descreve — um formulário que vincula is_staff, is_superuser ou groups a partir de entrada do usuário.
O formulário de registro em apps/accounts/forms.py usa uma whitelist explícita de campos:
class Meta:
model = User
fields = ('username', 'first_name', 'last_name', 'email', 'password1', 'password2')
Sem is_staff, sem is_superuser, sem groups. Um usuário não consegue injetar campos extras porque o ModelForm do Django ignora qualquer dado POST para campos que não estão na lista fields. Confirmei isso verificando cada classe de formulário no projeto — nenhuma usa fields = '__all__' ou exclude (que é o padrão mais fraco, de denylist).
A plataforma adiciona uma segunda camada: mesmo se um usuário de alguma forma conseguisse gravar is_staff=True no banco, ele não teria poderes de moderador. O sistema de permissões exige is_staff E pertencer ao grupo "Moderadores" ou "Administradores":
def has_moderator_permission(user):
return user.is_staff and (
user.groups.filter(name__in=['Moderadores', 'Administradores']).exists() or
user.is_superuser
)
A atribuição de grupo só pode ser feita pelo Django admin (por um superusuário). Essa dupla verificação significa que uma escalação de campo único é insuficiente — o atacante precisaria gravar tanto is_staff quanto a associação a um grupo, e não existe endpoint que exponha nenhum dos dois.
Mais uma decisão de design: o campo creator nas petições é definido server-side na view (form.instance.creator = self.request.user), nunca aceito do formulário. No Django admin, creator está bloqueado em readonly_fields — nem staff consegue reatribuir a propriedade de uma petição.
O Post 7 completa a dimensão vertical do controle de acesso quebrado. Onde o Post 6 era sobre ler objetos de outro usuário (IDOR horizontal), este post é sobre escrever seu próprio papel (escalação vertical de privilégios) — e a lição compartilhada é que o ORM e a camada de vinculação de formulários do Django não fazem distinção entre campos "seguros" e campos "perigosos". Essa distinção vive inteiramente nas definições de seus formulários, na lista explícita de fields que você escreve e mantém. A lição que tirei: trate fields = '__all__' em qualquer modelo que carrega flags de permissão como um achado crítico — a mesma severidade que uma query raw() não parametrizada na Série I.
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, porque 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 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