← Attack pathsmass assignment bug lets users grant themselves admin

mass assignment bug lets users grant themselves admin

$user.update(**request.json)

the form is not the api

you build a nice clean profile form. name, email, maybe an avatar upload. that's all the html shows, so that's all a user can send, right? wrong. the browser is a suggestion. the actual request goes straight to your api, and nothing stops someone from opening dev tools, editing the request body, and adding fields your form never had. there is no such thing as a hidden field. there is only what your server actually accepts.

the bug, line by line

this whole class of vulnerability usually comes down to one lazy line of code:

user.update(**request.json)

walk through what this actually does. request.json is a dictionary built directly from whatever the client sent in the request body. the ** unpacks that dictionary and dumps every key straight into user.update() as an argument. the developer was thinking "the form sends name and email, so this updates name and email." the code doesn't know that. the code sees a bag of key value pairs and writes every single one to the database column that matches, no questions asked.

so if the form normally sends {"name": "alex", "email": "alex@site.com"}, that's what gets written. but nothing on the server checks what's allowed to be in that bag. if someone resends the request with {"name": "alex", "email": "alex@site.com", "role": "admin"}, and there's a role column on the user table, congratulations, that request just promoted itself to admin. the server returns 200 ok because as far as the code is concerned, it did exactly what it was told. every column is writable. that's the whole bug in five words.

why this keeps happening

this isn't some exotic exploit. it's what happens when a framework makes the easy path also the dangerous path. orms love giving you one liners that map request data straight to model fields because it's fast to write and demos great. it works fine right up until your user table has a column like role, is_admin, account_balance, or verified, and someone realizes the api never actually checks who's allowed to touch it. the form hid the field from view. it did not remove it from the request path. those are two very different kinds of "hidden."

the fix: allowlist, never blocklist

the instinct a lot of people have is to blocklist the dangerous fields, like stripping out role before saving. don't do this. blocklists rely on you remembering every sensitive field forever, including the ones you add six months from now. the field you forget is the one that gets exploited. instead, say exactly what a user is allowed to write, and reject everything else by default:

ALLOWED = {'name', 'email', 'avatar'}
data = {k: v for k, v in request.json.items() if k in ALLOWED}
user.update(**data)

here's what changed. ALLOWED is a set containing only the fields you actually intend for a regular user to update. the dict comprehension goes through everything the client sent and keeps only the keys that appear in that set, throwing away anything else, silently or with a logged warning, your call. now role, or any other field an attacker tries to sneak in, never even makes it into data. it doesn't matter what garbage shows up in the request body. only the fields you explicitly named can ever reach the database.

check your own app right now

you don't need a pentest budget to find this in your own code. go find every place you have something like update(**request.json), update(**request.form), or the equivalent in whatever framework you're using, django's Model.objects.filter().update(**data), rails' mass assignment before strong parameters, express routes that spread req.body straight into a mongoose update. grep for the unpacking operator next to anything that touches your database and audit each hit. then actually test it: log in as a normal user, capture the request your profile form sends, and manually add a field that shouldn't be writable, like role or is_admin. if the response is 200 and the field changed, you have this bug.

the takeaway

trusting the shape of the request just because your frontend controls the shape of the form is how mass assignment bugs happen every single time. your frontend is not a security boundary, it's a suggestion box that anyone can ignore. the fix isn't clever, it's just discipline: name the fields a user may write, reject everything else, and repeat that check on every model that touches sensitive data. go audit your update endpoints today, this is a five minute grep that can save you from a very bad day.

watch the reel ↗
the weekly drop

one command a week that makes you harder to hack.

a single tool, explained in plain english, every week. straight to your inbox.

no spam. one email a week. unsubscribe anytime.