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: ~17 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.)

🧪 Run it yourself. This attack ships as a runnable lab in django-security-lab: a @login_required profile-edit form with fields = '__all__', and a secure twin with an explicit field allowlist. Everything runs from the command line — log in as a regular member, POST role=staff to the vulnerable view, and watch a staff-only flag come within reach; the allowlist view drops the field and the flag stays locked. Clone it and reproduce every step.

Post 6 introduced the fundamental access-control failure in Django applications: a view that checks identity but never checks entitlement. That was 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.

The scenario that makes this concrete is a platform with tiers of power — say a civic-petition site where citizens create and sign petitions, moderators approve or reject content through the Django admin, and administrators run everything, with the tiers enforced by is_staff plus Django group membership. Now imagine the profile-edit endpoint binds whatever the form sends: a regular citizen POSTs {"is_staff": true} alongside their name change, the form writes it, and they have moderator access — enough to approve their own petition, dismiss flags against it, or take down a rival's. No stolen credentials, no exploit chain; one extra field in a form they were already allowed to submit. That is the gap this post is about: the distance between the fields a form renders and the fields it accepts.

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 (Django REST Framework — the de facto library for building Web APIs in Django) 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

A user opens their browser's DevTools, inspects the profile-edit form, and sees three fields: first_name, last_name, email. They intercept the form submission and add one more field to the POST body: is_staff=true. The server accepts it. The next time they visit /admin/, Django lets them in. They are now staff.

That is vertical privilege escalation: gaining a higher level of access than the one granted to you. Post 6 covered the horizontal case (one user accessing another user's resources at the same privilege level). This post is about reaching upward. 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.

The mechanics come down to 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. 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.

The attack requires no special tools. The attacker submits the profile form normally, intercepts the request with a proxy or the browser's network tab, and adds is_staff=true (form POST) or "is_staff": true (JSON API) to the body. If the backend uses fields = '__all__', the field is accepted and written to the database. If the admin site exposes user management (it does by default for superusers), the attacker can go further and submit "is_superuser": true in the same request. It works through both stacks: with a Django ModelForm the attacker crafts the POST body or adds hidden fields and the server-side form binds anything in its fields list; with a DRF ModelSerializer the attacker adds extra keys to the JSON body, DRF deserializes them into validated_data, and serializer.save() writes them.


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. In MITRE ATT&CK terms the initial access maps to T1190 — Exploit Public-Facing Application: the attacker exploited a software flaw — the unguarded field-binding surface — to gain unauthorised access. (T1548, Abuse Elevation Control Mechanism, describes OS-level elevation like UAC or sudo, and T1078, Valid Accounts, covers credential abuse — neither captures a mass-assignment bug in application code.)

The regulatory stakes have only risen since 2012. GitHub's incident predated modern breach-notification law, but the same bug lands differently today: a mass-assignment escalation that hands an attacker administrative access to personal data is a reportable security incident — under Brazil's LGPD (Art. 48, notify the ANPD and the affected data subjects) or the EU's GDPR (Art. 33, a 72-hour notification duty). A forgotten fields = '__all__' on a User model is therefore not just a code smell but a compliance liability.

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__'.

What surprised me while researching this is that there is no runtime guard at all. I expected Django to at least log a warning when fields = '__all__' includes is_staff or is_superuser on the User model. It doesn't. Not a log message, not a system check, nothing. The gap between reading the documentation warning and what developers actually build — ModelForm(model=User, fields='__all__') on a profile-edit page — is the entire vulnerability, and Django is completely silent about it.


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__'

This variant is deceptive because the rendered form looks safe. The template displays only first_name, last_name, and email. But the ProfileForm accepts every field on the User model because fields = '__all__'. What the template renders and what the form accepts are two completely different things. 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 was cosmetic; the server-side form was wide open.

# 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})

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

The registration endpoint expects username, email, and password. But **request.data passes every key in the payload to create_user(), which internally calls User(**kwargs). An attacker submits {"username": "evil", "password": "pass123", "is_staff": true, "is_superuser": true} and registers as a superuser. One line. No exploit toolkit.

# 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)   # ← every key in the payload becomes a field
    return Response({'status': 'created'}, status=201)

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

I'm not fully sure where the line is between exclude (denylist) and explicit fields (allowlist) in practice. The Django docs warn that exclude is dangerous because new fields added to the model are automatically included, and that makes sense. But I've seen projects use exclude = ('is_staff', 'is_superuser', 'groups', 'user_permissions') and argue it's clearer about intent. I went with explicit fields because the allowlist principle is safer, but I can see the other argument. The Django docs side with me here, but the fact that exclude exists and isn't deprecated probably means the core team considers it legitimate for some use cases.


The Analyst's View

Vertical privilege escalation is where the CySA+ idea of least privilege meets the ORM's indifference: to Django, is_staff is just another column, so "least privilege" is not a setting you switch on — it is a property you have to maintain in every form and serializer's field list. The mass-assignment bug is the failure of that maintenance, and it is worth triaging high: treat fields = '__all__' on any model carrying permission flags as a critical finding, the same class of defect as an unparameterised raw() query in Series I.

The compensating idea is defence in depth — never let a single writable field be the whole boundary. A well-designed role system makes escalation a two-step problem: even if an attacker flips is_staff, the powers that matter (moderating content, reading personal data) should require a second gate they cannot set themselves — Django group membership granted only through the admin, an IsAdminUser check on the sensitive endpoint, a has_object_permission on the object. The allowlist stops the write; the second gate ensures that one forgotten allowlist is not game over.


Catching It Automatically

Testing Your Defence

The test that proves the fix is an attempted self-promotion that must fail: a regular user PATCHes {"is_staff": true} (or POSTs is_staff=on) and you assert the flag stays False while the legitimate fields still save. The DRF API tests cover the serializer path:

# 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)

The Django ModelForm path deserves its own test — a crafted POST with is_staff=on must not promote the user:

# 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)

You can also prove it by hand against a running instance — a regular user's token PATCHing the permission flags, then confirming they did not stick:

# 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)

Scanning It

Here is the twist the tooling teaches: the standard scanners this series runs do not catch fields = '__all__'. Point Bandit at the lab and it reports nothing on the views and forms — its checks are for dangerous calls (eval, subprocess … shell=True, mark_safe), and a form's field list is not one; the only thing it flags is a hardcoded password in the test file, noise unrelated to the class. Semgrep's community packs (p/django, p/python, p/owasp-top-ten) report nothing either, and — checked directly — neither does Semgrep's registry ruleset r/python.django (only an unrelated "use render() rather than HttpResponse" style nit fires).

That is worth sitting with, because fields = '__all__' is a well-known antipattern — it simply isn't a Semgrep rule. The tools that flag it are dedicated Django linters: Ruff's DJ007 (django-all-with-model-form) and flake8-django's DJ07. Run those and you will catch it — but an analyst standardised on Bandit + Semgrep gets a clean report on genuinely vulnerable code, which is a miss, not a pass.

So this post's lab ships a small custom Semgrep rule, rules/mass_assignment.yaml, that flags fields = "__all__" inside a Meta (covering both ModelForm and ModelSerializer). It fires on the vulnerable view and stays silent on the explicit-allowlist fix — the fires/silent assert the standard tools couldn't give — enforced in a hermetic CI job. The same rule serves Post 10 (Mass Assignment): the same sink, a different exposed field.

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

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

There is no push-button DAST for this one: a scanner can send is_staff=true, but it cannot know that field was never meant to be client-writable or that flipping it crosses a privilege boundary — that is your allowlist and role model, not something a black-box tool infers. The captured Bandit, Semgrep, and custom-rule runs are committed under scans/; tests.py is the runnable proof.


Django's ORM makes no distinction between first_name and is_superuser — both are just columns on auth_user. That distinction lives entirely in the fields list you write and maintain, which is why vertical escalation is a maintenance discipline, not a framework feature you can switch on.

Post 8 continues Series II with Cross-Site Request Forgery (CSRF), where the attacker doesn't need the victim's credentials at all. 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