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_requiredprofile-edit form withfields = '__all__', and a secure twin with an explicit field allowlist. Everything runs from the command line — log in as a regular member, POSTrole=staffto 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:
-
ModelFormtreats all fields equally. When you setfields = '__all__', every field on the model — includingis_staff,is_superuser,groups, anduser_permissions— becomes a form field that accepts POST data. Django's documentation explicitly warns againstfields = '__all__'for exactly this reason, but the warning is a documentation note, not an enforcement mechanism. -
DRF's
ModelSerializerdoes the same.fields = '__all__'on aUserSerializermeans the serializer accepts and writesis_staffandis_superuserfrom the request payload. DRF's documentation recommends explicit field lists, but nothing prevents or warns at runtime. -
The
Usermodel has no "protected fields" concept.is_staffandis_superuserareBooleanFields on theUsermodel, 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
Usermodel.UserAdminuses a customUserChangeFormwith 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_requiredandPermissionRequiredMixinare model-level, not field-level. They gate access to a view, not which fields within the view are writable. A user who passeshas_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:
fieldsis an explicit list (never'__all__').- Permission-bearing fields (
is_staff,is_superuser,is_active,groups,user_permissions) are either absent fromfieldsor present inread_only_fields. - No view splats
request.dataorrequest.POSTinto aUser()orcreate_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
- django-security-lab — this post's runnable lab (
labs/post_07_privesc/) - The custom Semgrep rule for this post —
rules/mass_assignment.yaml(with its test fixture,rules/mass_assignment.py) - Ruff —
DJ007 django-all-with-model-form(the linter check forfields='all') - MITRE ATT&CK — T1190 Exploit Public-Facing Application
- Django Docs — Permissions and Authorization
- Django Docs — User Model Reference
- DRF Docs — Specifying Which Fields to Include
- PortSwigger Web Security Academy — Vertical Privilege Escalation
- OWASP A01:2021 — Broken Access Control
- OWASP Cheat Sheet — Mass Assignment
- Web Security for Developers: Real Threats, Practical Defense (Malcolm McDonald) — Chapter 11: Privilege Escalation and Access Control