cd ../blog

Mass Assignment: Smuggling Privileged Fields Into the Model

When a framework auto-binds request keys to object attributes, sending an extra field like role or is_admin can escalate privileges. We cover discovering bindable fields and a working autobinding PoC.

Mass assignment (autobinding, over-posting) happens when a framework maps incoming request keys directly onto a model's attributes. If the binding is not restricted to a safe set, you add a field the UI never exposes — role, is_admin, verified, balance, owner_id — and the object adopts it. One extra JSON key becomes privilege escalation or data tampering.

Where it hides

The pattern is "bind the whole request body to the object," common in ORMs and model binders. Hunt for create/update endpoints backed by frameworks that do this by default:

  • Rails (update(params[:user])), Laravel (fill/create without $fillable), Spring (@ModelAttribute), Django/DRF serializers with fields = '__all__', sequelize/mongoose create(req.body).
  • Profile/account update, registration, and settings endpoints.
  • "Edit object" APIs (orders, posts, teams, memberships).

The discovery method is to learn the object's full field set, then submit fields the client should not control. First, read an existing object to enumerate attributes — the response often reveals server-managed fields you can try to write:

GET /api/users/me HTTP/1.1
Host: app.example.com
Authorization: Bearer eyJ...
{"id": 501, "email": "me@x.com", "role": "user", "is_verified": false,
 "credits": 0, "team_id": 7}

Now you know the bindable candidates. Echo them back on an update, adding the privileged ones the form never sends. The signals of success: the protected field changes, or a follow-up read shows it stuck. Param-mining tools (Arjun, Param Miner) and simply replaying the read-response back as the write body both surface bindable keys fast.

Reproducing it

The headline repro is self-promotion to admin. Take a normal profile update and inject role/is_admin:

PATCH /api/users/me HTTP/1.1
Host: app.example.com
Authorization: Bearer eyJ...
Content-Type: application/json

{"display_name": "test", "role": "admin", "is_verified": true}

Then confirm it bound by re-reading the object:

GET /api/users/me HTTP/1.1
Host: app.example.com
Authorization: Bearer eyJ...

If the response now shows "role": "admin", the framework bound your extra field and you escalated. Hitting an admin-only endpoint and getting a 200 is the corroborating proof.

Several escalation fields are worth trying on the relevant object type — submit each and check whether it persists:

role / roles / is_admin / isAdmin / admin / user_type / permission_level
is_verified / verified / email_verified / approved / active
balance / credits / wallet / price / discount / amount
owner_id / user_id / account_id / org_id / tenant_id   (reassign ownership)

A particularly clean PoC is price/total manipulation on a checkout. If the order model binds total or price, set it directly:

POST /api/orders HTTP/1.1
Host: shop.example.com
Authorization: Bearer eyJ...
Content-Type: application/json

{"items": [{"sku": "ABC", "qty": 1}], "total": 0.01}

An order that records 0.01 instead of the server-computed price proves the field was trusted from the client.

When the field name is nested, match the structure the binder expects ({"user": {"role": "admin"}} or address[is_default]=true); when keys are filtered by name, try case and snake/camel variants since allowlists are often incomplete.

Going further

Impact tracks the field you can bind:

  • Privilege escalation via role/is_admin.
  • Ownership/tenancy takeover by setting owner_id/tenant_id to attach or reassign objects across accounts.
  • Financial tampering through balance/total/discount.
  • Trust-flag bypass by flipping is_verified/approved to skip a gate.

A practical recon habit is to GET an object, then PATCH it with the exact response body plus a handful of privileged keys, and diff a fresh GET — any protected field that changed was mass-assignable. Capture the read showing the field set, the write injecting the extra field, and the follow-up read (or admin-endpoint 200) proving it bound. Test only on authorized targets with accounts you control.