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:2025 — Broken Access Control | Reading time: ~14 min

(Mapping note: The underlying mechanism — mass assignment — is CWE-915, which OWASP maps to A08 (Software or 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. At its worst, it is full administrative control over the application.

This one hits close to home because Petição Brasil has exactly the shape that makes the bug dangerous: a three-tier role system where citizens create and sign petitions, moderators approve or reject content through the Django admin, and administrators run the platform, with the tiers enforced by is_staff plus Django group membership. On a platform like that, a profile-edit endpoint that binds whatever the form sends would be catastrophic: a regular citizen who slips "is_staff": true and the moderators' group into a name-change request hands themselves moderator access: approve their own petition, dismiss flags against it, take down a rival's. No stolen credentials, no exploit chain; one extra field in a request they were already allowed to send. Making sure that gap does not exist, that every form and serializer touching the user enumerates its fields, is the kind of check the role system makes non-negotiable. That is what this post is about: the distance between the fields a form renders and the fields it accepts.


The Attack: What It Is and How It Works

A user opens their browser's DevTools on the profile page and watches the request it sends: PATCH /api/profile/ with three fields, first_name, last_name and email. They replay it with one more: "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. 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 (DRF is Django REST Framework, the de facto library for building Web APIs in Django). A ModelForm instantiated with request.POST binds every field listed in its fields attribute, and fields = '__all__' lists every editable field on the model; a DRF ModelSerializer with the same setting deserializes every key in the JSON body that names a writable model field into validated_data, and serializer.save() writes it. The developer meant the endpoint to update a name and an email, but because no explicit allowlist was set, it also accepts is_staff, is_superuser and groups. No special tools are needed: a proxy or the browser's network tab is enough to add the key. The API path is the easier of the two, because a partial PATCH needs only the extra key, while a ModelForm on User itself also demands the required username, password and date_joined, so a flag-only POST to that form fails validation; the lab uses a separate profile model carrying a role field, where role=staff alone is enough.

The same promotion has other ways in. A view that hands the whole request to the model, such as User.objects.create_user(**request.data) at registration, turns every key in the body into a field with no form involved. A user who may edit other accounts needs no hidden field at all: Django's documentation warns that giving a non-superuser the change_user permission "is ultimately the same as giving them superuser status", because they can raise anyone's permissions, their own included. And an administrative action guarded only by is_staff, as DRF's IsAdminUser is, falls to the first field that sets it.


Real-World Incidents

Camaleon CMS — CVE-2025-2304 (2025)

Camaleon CMS is a content-management system built on Ruby on Rails. Its users controller had an action, updated_ajax, that ran when a user changed a password, and it saved the request in one line: @user.update(params.require(:password).permit!). Rails' permit! marks every parameter under that key as allowed, the controller-level twin of fields = '__all__', so a form that showed a password and its confirmation accepted any attribute of the user. Joshua Martinelle of Tenable found that a low-privileged user could add one more parameter, the user's role, and save themselves as an administrator. Tenable rated it High (CVSS 8.8) and contacted the maintainers in November 2024; the fix, merged in January 2025 and released in version 2.9.1 that March, replaced permit! with an allowlist of exactly password and password_confirmation.

The advisory does not report exploitation; the lesson is how small the bug was. In ATT&CK terms the closest fit is T1068 (Exploitation for Privilege Escalation), a programming error used to elevate privileges, though ATT&CK writes that technique with operating-system and kernel flaws in mind. For Django developers the translation is one line: permit! is fields = '__all__', and the fix, a list of the two fields the form exists to change, is the explicit fields list in Pattern 1. Rails has required an explicit list of permitted parameters since version 4, released in 2013; the shortcut that switches the check off is one word, as '__all__' is in Django.

Sources: Tenable — TRA-2025-09: Camaleon CMS Privilege Escalation (CVE-2025-2304); Camaleon CMS — the fix: permit only the password fields


Django's Default Protections

Django's protection against privilege escalation is thin: it makes you choose a field list, and then accepts whatever you chose.

  • ModelForm refuses to guess, and then trusts you. A ModelForm whose Meta sets neither fields nor exclude raises ImproperlyConfigured, so fields = '__all__' is a value someone typed. Django's documentation calls both shortcuts, '__all__' and exclude, "much less secure" and says that leaving fields implicit "can easily lead to security problems when a form unexpectedly allows a user to set certain fields, especially when new fields are added to a model." Once '__all__' is set, every editable field, including is_staff, is_superuser, groups and user_permissions, is bound from the POST.

  • DRF's ModelSerializer does the same. DRF, too, refuses a serializer without fields or exclude, but fields = '__all__' on a UserSerializer accepts and writes is_staff, is_superuser and groups from the payload — and password too, saved as sent rather than hashed.

  • 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. The failure is also invisible in development, where the developer testing the endpoint is already the superuser and would never think to send {"is_staff": true} against their own profile.

  • Permission checks gate views, not fields. @permission_required and PermissionRequiredMixin decide who reaches a view, not which fields within it are writable. Even Django's own UserChangeForm declares fields = '__all__'; the admin limits it with fieldsets. Your own 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.

What surprised me while researching this is that, past that first choice, 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, fields = '__all__' on a profile-edit form, is the entire vulnerability, and Django is completely silent about it.


The Vulnerable Pattern and the Fix

Pattern 1 — A Profile ModelForm with fields = '__all__'

The lab's profile edit is the textbook case. Its Profile model carries a role field the page displays but never offers as an input, and a staff-only view hands over a flag to any profile whose role is "staff". The fix is the field list:

 class ProfileForm(forms.ModelForm):
     class Meta:
         model = Profile
-        fields = "__all__"  # DANGER: exposes `role` to mass assignment
+        fields = ["display_name", "bio"]  # allowlist: `role` is unreachable

On the vulnerable view, a member who POSTs display_name=Alice&role=staff is saved as staff, and the staff area then returns the flag. On the secure view the form has no role field, so the extra key is dropped before save() and the staff area still answers 403. What the page offers and what the form accepts were two different things. Both views are in the lab: views_vulnerable.py and views_secure.py. Even if the model grows new fields, an explicit list does not expose them. That is the allowlist principle: everything is denied unless explicitly permitted.

Pattern 2 — A DRF Serializer with fields = '__all__' on User

The API version sits behind a RetrieveUpdateAPIView whose get_object() returns request.user, so every user edits only their own row — and every writable field of it:

 class UserSerializer(serializers.ModelSerializer):
     class Meta:
         model = User
-        fields = '__all__'
+        fields = ['id', 'username', 'first_name', 'last_name', 'email']
+        read_only_fields = ['id', 'username']

Against the old serializer, PATCH /api/profile/ {"is_superuser": true, "is_staff": true} validates (they are booleans, and true is a valid boolean), and serializer.save() writes both; with DRF 3.18.2 a groups list in the same request is written too. 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. With the explicit list, the extra keys are ignored: the request still answers 200, saves the name, and leaves both flags False.

Three rules apply to both patterns rather than one. First, on any serializer that shows a permission field, list it in read_only_fields as well, so a later refactor that adds it to fields still cannot write it — DRF drops read-only fields from the input. The option covers only fields DRF generates from the model: a field declared explicitly on the serializer class ignores it and stays writable, so declare that one with read_only=True itself. Second, give administrative actions their own serializer and view (an activation endpoint whose serializer has fields = ['is_active'], say), gated by a permission the user cannot grant themselves. DRF's IsAdminUser does not qualify on its own: it checks only is_staff, the same flag Pattern 2 lets a user set. Third, audit every form and serializer on User (or a custom AUTH_USER_MODEL): fields is an explicit list, permission-bearing fields (is_staff, is_superuser, is_active, groups, user_permissions) are absent or read-only. The scanning section shows how to automate the first check.

Django's documentation offers '__all__' and exclude as shortcuts "for cases where you can guarantee these security concerns do not apply to you". A denylist such as exclude = ('is_staff', 'is_superuser', 'groups', 'user_permissions') states its intent clearly, but it fails open: a field added to the model later is writable by default. On a model that carries permissions that guarantee cannot be given, so it gets an explicit fields list.


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 priority finding, as you would an unparameterised raw() query in Series I, then rate it by who can reach that form or serializer and what the flags grant.

The second 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, a has_perm() check on the sensitive endpoint (not IsAdminUser, which reads the same is_staff flag), a has_object_permission on the object. That second gate holds when the leak is one field, such as the lab's role or an exclude list that forgot is_staff; '__all__' on User hands over groups and user_permissions in the same request, so the allowlist is what stops the write, and the second gate is what keeps one forgotten field from being game over.


Catching It Automatically

Testing Your Defence

One test proves the fix against this payload: a member injects role=staff into the secure form, and you assert the role did not change and the staff area stays closed. This is the one the lab uses:

def test_exploit_is_blocked_on_secure_view(self):
    self.client.post("/privesc/secure/", {"role": "staff"})
    self.assertEqual(self.role(), "member")
    resp = self.client.get("/privesc/staff-area/")
    self.assertEqual(resp.status_code, 403)
    self.assertNotIn(FLAG, resp.content.decode())

setUp creates alice with a member profile and logs her in; self.role() reads her role back from the database. Point the test at /privesc/vulnerable/ and it fails: the role becomes staff and the staff area returns the flag. For a DRF endpoint the shape is the same: a regular user PATCHes {"is_staff": true}, then refresh_from_db() and assert the flag is still False (the response is 200, because the extra key is ignored rather than rejected); repeat it for is_superuser, groups, and a staff user trying for superuser. The lab's tests.py adds the other two halves: the exploit succeeds on the vulnerable view, and the secure view still saves display_name.

Scanning It

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 target dangerous constructs (eval, subprocess … shell=True, mark_safe, hardcoded passwords), 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; checked directly, neither does Semgrep's registry ruleset r/python.django (only the unrelated XSS audit rule direct-use-of-httpresponse fires).

fields = '__all__' is a well-known antipattern; it simply isn't in those Semgrep rulesets. The tools that flag it are dedicated Django linters: Ruff's DJ007 (django-all-with-model-form) and flake8-django's DJ07, both written for ModelForm. Run those and you will catch the form — 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 also catches the other spellings of "every field" (Django's ALL_FIELDS constant, an empty exclude, modelform_factory(…, fields="__all__")), and it cannot tell a model Meta from a django-filter FilterSet one, which it flags as well. It does not catch a non-empty exclude that forgets a field; that stays on the review checklist. It fires on the vulnerable view and stays silent on the explicit-allowlist fix, and a hermetic CI job asserts both. The same rule serves the mass-assignment post later in the series: 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/views_vulnerable.py
semgrep scan --config p/django --config p/python --config p/owasp-top-ten labs/post_07_privesc/views_secure.py

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


The habit this post left me with: every form or serializer that writes to a model carrying a role or permission field gets an explicit fields list, and '__all__' on such a model does not pass review.

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 the series