Privilege Escalation: How fields='__all__' Hands Every User the Keys to Your Django Admin

Privilege Escalation: How fields='__all__' Hands Every User the Keys to Your Django Admin

Django Security Series — Post 7 | Series II: Broken Access Control
OWASP A01:2021 — Broken Access Control | Reading time: ~14 min

(Mapping note: The underlying mechanism — mass assignment — is CWE-915, which OWASP maps to A08 (Software & Data Integrity Failures). This post categorises under A01 because the impact is broken access control: unauthorised privilege elevation. Both framings describe the same bug from different angles.)

Post 6 introduced the fundamental access-control failure in Django applications: a view that checks identity but never checks entitlement — the horizontal case, where one user reads or writes another user's objects at the same privilege level. Post 7 takes the same category vertical. The attacker is not reaching sideways into another user's data — they are reaching upward, promoting themselves from a regular user to staff, or from staff to superuser. The outcome is not a data leak; it is full administrative control over the application.

This one matters to me because Petição Brasil has a three-tier role system: citizens create and sign petitions, moderators approve/reject content through the Django admin, and administrators manage the full platform. The distinction between these tiers is enforced by is_staff + Django group membership (“Moderadores” / “Administradores”). If a regular citizen could POST {"is_staff": true} to their profile endpoint and the form blindly wrote it to the database, they'd have moderator access — the ability to approve their own petition, dismiss flags against it, or take down competitors' petitions. I needed to verify that my registration and profile forms don't expose this surface.

Privilege escalation in a Django application almost always arrives through the same mechanism: a form or serializer that binds request data to a model containing permission-bearing fields — is_staff, is_superuser, groups, user_permissions — without excluding them. The developer built a profile-edit endpoint, used fields = '__all__' for convenience, and silently handed every user the ability to write their own role. The Django admin is the ultimate prize: set is_staff = True and you can log into the admin interface, inspect every model, and — if is_superuser is also writable — grant yourself unrestricted access to the entire application and its data.

The reason this vulnerability persists is that Django and DRF treat permission fields as ordinary model fields. The ORM makes no distinction between first_name and is_superuser — both are columns on the auth_user table, both are writable through save(), and both appear in fields = '__all__'. Neither ModelForm nor ModelSerializer applies any special protection to permission-bearing fields. The developer must explicitly exclude them — and the failure to do so is invisible in development, where the single developer testing the endpoint is already the superuser and would never think to POST {"is_staff": true} against their own profile-edit form.

In this post we look at how vertical privilege escalation works in Django, why ModelForm and ModelSerializer make it easy to ship, the specific field-binding traps in both Django forms and DRF serializers, and how to close the gap with explicit field allowlists and a defence-in-depth mindset that treats every writable User endpoint as a potential escalation path.


The Attack: What It Is and How It Works

Privilege escalation is the act of gaining a higher level of access than the one granted to you. It comes in two forms:

Type Description Example in Django
Vertical A lower-privileged user gains a higher-privileged role A regular user sets is_staff = True and logs into the Django admin
Horizontal A user accesses resources at the same privilege level but belonging to a different user Covered by Post 6 (IDOR) — included here for completeness

This post is about the vertical case exclusively. The attacker has a legitimate, authenticated account. They discover — through documentation, source-code review, or simple experimentation — that a profile-edit or user-update endpoint accepts fields it should not, and that those fields map to Django's permission flags. The attack is a single request: submit the extra field alongside the legitimate ones, and the application writes it to the database because the form or serializer never excluded it.

The mechanics rely on how Django's form-binding and DRF's serializer-binding work. When a ModelForm is instantiated with request.POST, it binds every key present in the POST data to the corresponding model field — provided that field is listed in the form's fields attribute. When fields = '__all__', every model field is listed. The same applies to DRF's ModelSerializer: when fields = '__all__', every field on the model is accepted from the request payload. The developer intended the endpoint to update first_name, last_name, and email — but because no explicit allowlist was set, the endpoint also accepts is_staff, is_superuser, and groups.

How Attackers Exploit It

The attack requires no special tools — only the ability to craft a request with additional fields beyond what the frontend form presents:

  1. The attacker opens their browser's DevTools and inspects the profile-edit form. They note the visible fields: first_name, last_name, email.
  2. They submit the form normally, intercepting the request with a proxy (Burp Suite) or the browser's network tab. They add an extra field to the request body: is_staff=true (for a form POST) or "is_staff": true (for a JSON API payload).
  3. If the backend serializer or form uses fields = '__all__', the field is accepted and written to the database.
  4. The attacker navigates to /admin/ and logs in with their own credentials. They are now staff.
  5. If the admin site exposes user management (it does by default for superusers), the attacker promotes themselves to superuser, or directly submitted "is_superuser": true in step 2.

The attack has two variants based on the stack:

Form-based (Django ModelForm): The attacker adds hidden form fields or crafts the POST body manually. The server-side form binds the extra fields if fields = '__all__' or if the permission fields are explicitly included.

API-based (DRF ModelSerializer): The attacker adds extra keys to the JSON body. DRF deserializes them into the validated data dict and passes them to serializer.save(), which calls instance.save() with the new values.

The relevant MITRE ATT&CK mapping is T1190 — Exploit Public-Facing Application: the attacker exploits a software flaw (the unguarded field-binding surface) in a public-facing web application to gain unauthorised access. This is a better fit than T1548 (Abuse Elevation Control Mechanism), which describes abusing legitimate OS-level elevation controls like UAC or sudo, or T1078 (Valid Accounts), which concerns credential abuse — neither captures the mechanics of exploiting a mass-assignment bug in application code.


Real-World Incidents

GitHub Mass Assignment Incident (2012)

In March 2012, the security researcher Egor Homakov demonstrated a mass-assignment vulnerability against GitHub itself — the platform that hosts the majority of the world’s open-source code. GitHub’s Ruby on Rails application accepted user-submitted parameters without an explicit allowlist (attr_accessible was not enforced). Homakov crafted a request that over-posted the user_id field on the SSH public-key update form, associating his own key with the Rails organization’s account — one of the most privileged accounts on the platform. Because SSH keys attach to accounts (not repositories), the attack gave him commit access to every repository owned by that account. He proved the point by committing a file to the official Rails repository. GitHub responded by temporarily suspending his account (later reversed), patching the vulnerability, and strengthening their parameter-filtering practices. The incident became a driving force behind the strong-parameters pattern introduced in Rails 4, which replaced the weaker attr_accessible model-level approach with controller-level parameter whitelisting.

The incident was the watershed moment that made the industry take mass assignment and privilege escalation via over-posting seriously. Its lessons apply identically to Django’s ModelForm and DRF’s ModelSerializer: if you do not enumerate which fields the client may write, the client decides for you, and the permission fields are just one POST parameter away from being writable. The attack required no exploit, no injection, no zero-day — only the knowledge that the server accepted fields it should not. MITRE ATT&CK maps this as T1190 (Exploit Public-Facing Application) — a software flaw in a web-accessible endpoint exploited for unauthorised access.

Source: GitHub Blog — Public Key Security Vulnerability and Mitigation (2012)


Django's Default Protections

Django's answer to privilege escalation is blunt: there are none. The framework provides no automatic protection against a form or serializer that exposes permission-bearing fields. Here is why:

  • ModelForm treats all fields equally. When you set fields = '__all__', every field on the model — including is_staff, is_superuser, groups, and user_permissions — becomes a form field that accepts POST data. Django's documentation explicitly warns against fields = '__all__' for exactly this reason, but the warning is a documentation note, not an enforcement mechanism.

  • DRF's ModelSerializer does the same. fields = '__all__' on a UserSerializer means the serializer accepts and writes is_staff and is_superuser from the request payload. DRF's documentation recommends explicit field lists, but nothing prevents or warns at runtime.

  • The User model has no "protected fields" concept. is_staff and is_superuser are BooleanFields on the User model, stored in the same table, with no Django-level annotation marking them as sensitive. The ORM writes whatever value you set on the instance.

  • Django's admin is the only built-in interface that enforces field restrictions on the User model. UserAdmin uses a custom UserChangeForm with carefully curated fieldsets — but the admin is not your user-facing endpoint. Your custom views and API endpoints use your forms and serializers, and if those don't exclude the permission fields, Django will not do it for you.

  • @permission_required and PermissionRequiredMixin are model-level, not field-level. They gate access to a view, not which fields within the view are writable. A user who passes has_perm('auth.change_user') can change any field on any user — including promoting themselves — unless the form or serializer restricts it.

The one thing Django does correctly is separate the admin from the application: the admin is a power-user interface with its own field restrictions. The Django documentation’s own ModelForm page warns that “failure to [explicitly set fields] can easily lead to security problems when a form allows a user to set certain fields, especially when new fields are added to a model.” That warning directly targets fields = '__all__' — but it is a documentation note, not a runtime guard. The gap between reading that warning and what developers actually build — ModelForm(model=User, fields='__all__') on a profile-edit page — is the entire vulnerability.


Vulnerable Pattern: What NOT to Do

Pattern 1 — A DRF serializer with fields = '__all__' on the User model

# INSECURE — every field on the User model is writable from the API
from rest_framework import serializers
from django.contrib.auth.models import User

class UserSerializer(serializers.ModelSerializer):
    class Meta:
        model = User
        fields = '__all__'
# INSECURE — the view accepts PATCH with any field
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

What goes wrong: An authenticated user sends PATCH /api/profile/ {"is_superuser": true}. The serializer validates the field (it is a boolean, and true is a valid boolean), the view calls serializer.save(), and the user is now a superuser. The next request to /admin/ succeeds. The developer assumed the frontend controls which fields are submitted — but the attacker bypasses the frontend and submits directly.

Pattern 2 — A Django ModelForm with fields = '__all__'

# INSECURE — a profile-edit form that exposes every User field
from django import forms
from django.contrib.auth.models import User

class ProfileForm(forms.ModelForm):
    class Meta:
        model = User
        fields = '__all__'
# INSECURE — the view binds any POST data to the User model
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})

What goes wrong: The rendered form shows only the fields the template displays — perhaps first_name, last_name, and email. But the ProfileForm accepts every field on the User model because fields = '__all__'. The attacker crafts a POST with is_staff=on&is_superuser=on (Django checkbox convention), the form validates it, and form.save() writes both flags to the database. The template's field rendering provided no server-side protection — it was cosmetic only.

Pattern 3 — A custom registration or update view that splats request data

# INSECURE — directly passing request data to the model constructor
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: accepts ANY key
    return Response({'status': 'created'}, status=201)

What goes wrong: The registration endpoint expects username, email, and password — but **request.data passes every key in the payload to create_user(). An attacker submits {"username": "evil", "password": "pass123", "is_staff": true, "is_superuser": true}, and create_user() — which internally calls User(**kwargs) — sets both flags on the new account. The attacker registers as a superuser.


Secure Implementation: The Django Way

Rule 1 — Enumerate fields explicitly — never use '__all__' on models with permission fields

The primary fix is disciplined field enumeration. List exactly which fields the client may read and write — everything else is excluded:

# SECURE — only safe profile fields are exposed; permission fields are excluded
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']

Even if the model grows new fields in the future (a common source of regressions), they are not automatically exposed — only the fields in the explicit list are bound. This is the allowlist principle: everything is denied unless explicitly permitted.

For the Django ModelForm equivalent:

# SECURE — explicit field list excludes permission flags
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']

Rule 2 — Use read_only_fields as a defence-in-depth layer for permission fields

Even when you have an explicit fields list that does not include permission fields, adding read_only_fields for sensitive attributes provides a second gate against future refactoring that might accidentally re-expose them:

# SECURE — even if someone adds 'is_staff' to fields later, it cannot be written
class UserAdminSerializer(serializers.ModelSerializer):
    """Serializer for admin use — reads permission fields but never writes them from
    the request. Only internal code (management commands, signals) sets these."""

    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 guarantees that even if the field appears in the serializer output (for display), it is never populated from the incoming request data. DRF silently strips it from validated_data before calling save().

Rule 3 — Never splat request data into model constructors or create_user()

Replace **request.data with explicit extraction of the expected fields:

# SECURE — only expected fields are extracted; permission flags cannot be injected
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': 'Missing required fields.'}, status=status.HTTP_400_BAD_REQUEST)

    if User.objects.filter(username=username).exists():
        return Response({'error': 'Username taken.'}, status=status.HTTP_409_CONFLICT)

    User.objects.create_user(username=username, email=email, password=password)
    return Response({'status': 'created'}, status=status.HTTP_201_CREATED)

The explicit extraction means no unexpected field — is_staff, is_superuser, groups — can ride in with the payload. This is the equivalent of SQL parameterisation from Post 1: the structure of the operation is defined in code, and only the values come from the user.

Rule 4 — Gate administrative actions behind explicit permission checks

When a view legitimately needs to modify permission fields — for example, an admin endpoint that activates or deactivates a user — gate it with an explicit role check, never with the same serializer used by the user-facing profile endpoint:

# SECURE — a separate admin-only endpoint with explicit permission enforcement
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]  # only superusers

The principle is separate serializers for separate audiences: a user-facing serializer that exposes only profile fields, and an admin-facing serializer that exposes only the fields the admin action needs, gated by a permission class that enforces the admin role.

Rule 5 — Audit every form and serializer touching the User model

Run a static sweep of your project to find every ModelForm and ModelSerializer that references the User model (or a custom AUTH_USER_MODEL). For each one, verify that:

  1. fields is an explicit list (never '__all__').
  2. Permission-bearing fields (is_staff, is_superuser, is_active, groups, user_permissions) are either absent from fields or present in read_only_fields.
  3. No view splats request.data or request.POST into a User() or create_user() call.

A Semgrep or grep check can automate this:

# Find any ModelSerializer or ModelForm on User with fields='__all__'
grep -rn "model = User" --include="*.py" | \
  xargs grep -l "fields.*=.*'__all__'" 

Any match is a potential escalation vector.

Privilege Escalation Prevention Checklist

Control What it covers
Explicit fields list on every ModelForm / ModelSerializer touching User The primary escalation vector — '__all__' silently exposes is_staff/is_superuser
read_only_fields for is_staff, is_superuser, is_active, groups, user_permissions Defence-in-depth — blocks writes even if a future refactor re-adds the field to fields
Never splat **request.data / **request.POST into a model constructor The registration/update bypass — attacker injects arbitrary fields alongside expected ones
Separate serializers for user-facing vs. admin-facing endpoints Privilege boundary — the user endpoint physically cannot reference permission fields
IsAdminUser or PermissionRequiredMixin on admin-only actions Gate enforcement — even if the serializer were misconfigured, the view rejects non-admins
Static audit of every form/serializer on the User model Regression prevention — catches '__all__' reintroduction before it ships

Testing Your Defence

Unit Tests

# 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='Moderators')

    def test_user_cannot_set_is_staff_via_profile_update(self):
        """A regular user must NOT be able to promote themselves to 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)  # field silently ignored, not rejected
        self.user.refresh_from_db()
        self.assertFalse(self.user.is_staff)

    def test_user_cannot_set_is_superuser_via_profile_update(self):
        """A regular user must NOT be able to promote themselves to 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):
        """A regular user must NOT be able to add themselves to groups."""
        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):
        """A user CAN update their own first_name, last_name, email."""
        self.client.force_authenticate(user=self.user)
        response = self.client.patch(
            '/api/profile/',
            {'first_name': 'Updated', 'last_name': 'Name', 'email': 'new@example.com'},
            format='json',
        )
        self.assertEqual(response.status_code, 200)
        self.user.refresh_from_db()
        self.assertEqual(self.user.first_name, 'Updated')
        self.assertEqual(self.user.last_name, 'Name')
        self.assertEqual(self.user.email, 'new@example.com')

    def test_registration_cannot_set_is_staff(self):
        """Registration must NOT accept permission flags in the 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)  # user must be created
        self.assertFalse(new_user.is_staff)
        self.assertFalse(new_user.is_superuser)

    def test_staff_cannot_promote_self_to_superuser(self):
        """Even staff users must NOT be able to self-promote to superuser via the 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)

Django Form Tests

# 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):
        """A crafted POST with is_staff=on must not promote the user."""
        self.client.login(username='alice', password='testpass123')
        response = self.client.post('/accounts/profile/edit/', {
            'first_name': 'Alice',
            'last_name': 'Smith',
            'email': 'alice@example.com',
            'is_staff': 'on',          # injected
            'is_superuser': 'on',      # injected
        })
        self.user.refresh_from_db()
        self.assertFalse(self.user.is_staff)
        self.assertFalse(self.user.is_superuser)

Static Analysis

# Semgrep — detect fields='__all__' on User-related serializers and forms
semgrep --config "p/django" --include="*.py" .

# Grep — find every form/serializer touching User with __all__
grep -rn "model.*=.*User" --include="*.py" | xargs grep -l "__all__"

Manual Verification

# Attempt privilege escalation against the profile endpoint
curl -X PATCH https://staging.example.com/api/profile/ \
  -H "Authorization: Token <regular-user-token>" \
  -H "Content-Type: application/json" \
  -d '{"is_staff": true, "is_superuser": true}'

# Verify the user was NOT promoted
curl https://staging.example.com/api/profile/ \
  -H "Authorization: Token <regular-user-token>" | python -m json.tool
# Expected: "is_staff": false, "is_superuser": false (or fields not in response at all)

What I Found in My Projects

I audited Petição Brasil's forms for exactly the vulnerability this post describes — a form that binds is_staff, is_superuser, or groups from user input.

The registration form in apps/accounts/forms.py uses an explicit field whitelist:

class Meta:
    model = User
    fields = ('username', 'first_name', 'last_name', 'email', 'password1', 'password2')

No is_staff, no is_superuser, no groups. A user cannot inject extra fields because Django's ModelForm ignores any POST data for fields not in the fields list. I confirmed this by checking every form class in the project — none uses fields = '__all__' or exclude (which is the weaker, denylist pattern).

The platform adds a second layer: even if a user somehow got is_staff=True written to the database, they would not have moderator powers. The permission system requires is_staff AND membership in the “Moderadores” or “Administradores” Django group:

def has_moderator_permission(user):
    return user.is_staff and (
        user.groups.filter(name__in=['Moderadores', 'Administradores']).exists() or
        user.is_superuser
    )

Group membership can only be granted through the Django admin (by a superuser). This double-gate means a single-field escalation is insufficient — the attacker would need to write both is_staff and a group membership, and there is no endpoint that exposes either.

One more design decision: the creator field on petitions is set server-side in the view (form.instance.creator = self.request.user), never accepted from the form. In the Django admin, creator is locked to readonly_fields — even staff cannot reassign petition ownership.


Post 7 completes the vertical dimension of broken access control. Where Post 6 was about reading another user's objects (horizontal IDOR), this post is about writing your own role (vertical privilege escalation) — and the shared lesson is that Django's ORM and form-binding layer make no distinction between “safe” fields and “dangerous” fields. That distinction lives entirely in your form definitions, in the explicit fields list you write and maintain. The lesson I took away: treat fields = '__all__' on any model that carries permission flags as a critical finding — the same severity as an unparameterised raw() query in Series I.

Post 8 continues Series II with Cross-Site Request Forgery (CSRF) — where the attacker does not need the victim's credentials at all, because the victim's own browser submits the request on the attacker's behalf, riding the session cookie Django's authentication system so carefully set up.

Further Reading

← Back to all posts