Mass assignment vulnerability: a local reproduction
We reproduced a mass assignment vulnerability with twenty real HTTP requests against a small local profile API. In vulnerable mode, a normal user sent "role": "admin" and "credit": 9999 to the profile endpoint, both values were saved, and every authenticated response included a password hash stand-in. With a per-role allowlist and a response serializer, the same requests returned 400 or 403 and responses omitted the hash, while legitimate edits still worked.
This is a scripted Python and curl experiment with downloadable source and responses. No AI agent or model ran the test.
What a mass assignment vulnerability is
A mass assignment vulnerability exists when an API copies client-supplied fields straight onto a stored object without checking which fields that caller may change. Depending on the framework, it is also called autobinding or object injection. Some .NET writing calls the same flaw over-posting. The OWASP Mass Assignment Cheat Sheet describes it as binding request parameters into program objects so that a client can create or overwrite values the developer never meant to expose.
OWASP now files this under API3:2023, Broken Object Property Level Authorization. That entry joins two older categories. Its write half, previously named Mass Assignment, covers an endpoint that lets a user change a property they should not touch. Its read half, previously named Excessive Data Exposure, covers an endpoint that returns a property the user should not see. Our fixture contains both halves, and the fix addresses both.
The flaw differs from the object-level problem in our API authorization testing post. There the question was which object a user may reach. Here the user reaches only their own record, and the question is which fields on that record they may read or write.
Fixture scope and authorized use
- Target: two short-lived HTTP servers bound to IPv4 loopback,
127.0.0.1. The runner picks free ports. - Endpoints:
GET /profile,PATCH /profilefor the caller's own record, andPATCH /users/<id>, which only an admin may call. - Identities:
demo-alice-token(roleuser) anddemo-admin-token(roleadmin). They are fixed test strings with no value outside this fixture. - Data: three synthetic users held in memory. Each record has
role,creditand apassword_hashstring that is a stand-in, not a real hash.
The vulnerable mode applies target.update(payload) and returns the whole stored record. The fixed mode applies the same code after an allowlist check and returns a filtered copy. The Excession Labs Acceptable Use Policy describes authorized use, and this article gives no permission to test another system. For a real engagement, agree the hosts, accounts, records and stopping conditions with the system owner first.
Run the downloadable fixture
Download fixture.py and verify.py into one directory. You need Python 3.9 or later and curl. The Python code uses only the standard library. Inspect the source, then run:
python3 verify.py --output rerun
The output directory must be new. The runner starts the vulnerable server, sends ten real curl requests, stops it, then repeats the same ten against the fixed server. Each request bypasses proxies with --noproxy '*', has a one-second connection timeout and a three-second total timeout, and follows no redirects. Later requests read state that earlier ones changed.
Our recorded command was python3 verify.py --output recorded-run. It exited with code 0 and printed {"checks": 20, "passed": true, "failures": []}. That line means all twenty responses matched what the fixture is meant to produce, including the intentionally unsafe ones. It does not mean the vulnerable mode is safe. The results file records which responses violate the field policy: nine of the ten vulnerable-mode responses violate it and none of the fixed-mode responses do.
The run on 10 November 2026 used fixture and runner version 1.0.0, Python 3.15.2 and curl 8.7.1 on macOS ARM64. The vulnerable server listened on port 53348 and the fixed server on port 53359. Runtime strings, UTC times, commands and source SHA-256 hashes are in results.json. A rerun uses different ports and timestamps.
Observed results: escalation, disclosure and controls
The policy under test is short. A user may write name, email and bio on their own record. Only an admin may write role and credit. No response may contain password_hash. Positive controls confirm that legitimate writes still work, and the anonymous request confirms that authentication still applies.
| Request | Policy expects | Vulnerable mode | Fixed mode |
|---|---|---|---|
| Alice reads her profile | 200, no hash | 200: includes password_hash | 200: no hash |
| Alice sets name and bio | 200, fields saved | 200 | 200 |
Alice sends role and credit | 400, rejected | 200: role admin, credit 9999 | 400 |
| Alice reads her profile again | role user, credit 0 | role admin, credit 9999 | role user, credit 0 |
Alice sends is_admin | 400, rejected | 200: field stored | 400 |
Alice sends bio plus role | 400, nothing applied | 200: bio overwritten | 400 |
| Alice calls the admin endpoint for Bob | 403 | 200: Bob's credit set to 1 | 403 |
| Admin sets Alice's credit to 50 | 200 | 200 | 200 |
| Alice reads her profile at the end | name and bio kept, role user, credit 50 | role admin, bio overwritten, is_admin stored | name and bio kept, role user, credit 50 |
| Anonymous PATCH | 401 | 401 | 401 |
In vulnerable mode, Alice's single request changed her role and credit. The follow-up read shows both values persisted, and the request to the admin endpoint shows that the new role has an effect: she edited Bob's record. Every authenticated vulnerable response also included the hash stand-in, so the read half of API3:2023 appears even on requests that changed nothing.
Verify the finding before you report it
A 200 status alone proves little, because an API can return 200 and ignore the extra field. Confirm the change by reading the object back through a separate request. In the recorded vulnerable run, this was the escalating request:
curl --noproxy '*' --silent --show-error --include \
--http1.1 --connect-timeout 1 --max-time 3 --request PATCH \
--header 'Authorization: Bearer demo-alice-token' \
--header 'Content-Type: application/json' \
--data '{"role":"admin","credit":9999}' \
http://127.0.0.1:53348/profile
The recorded response shows the saved values, with headers omitted from this excerpt:
HTTP/1.0 200 OK
{"bio": "Synthetic bio", "credit": 9999, "email": "alice@example.test", "id": "alice", "name": "Alice Example", "password_hash": "demo-hash-not-a-real-hash-alice", "role": "admin"}
The follow-up read returned the same admin and 9999 in a fresh request, and the admin endpoint call succeeded. Together these verify a privilege change and its effect under this fixture's policy.
A useful finding names the endpoint and method, the authenticated identity, the field sent, the policy that forbids it, the response that proves it, and the read-back request. It also records which fields a response disclosed. Here the impact is a user granting themselves an admin role and a balance on synthetic data. This experiment does not give a production severity rating.
Fix with a per-role allowlist and a serializer, then retest
The fix has two parts that match the two halves of API3:2023. A writable-field allowlist per role controls what a request may change, and a response serializer controls what a response may contain. The cheat sheet recommends an allowlist over a blocklist. Under a blocklist, a new sensitive field stays writable until someone adds it to the list. The code below is excerpted from fixture.py. The fixed mode runs it, and the vulnerable mode skips it.
WRITABLE = {
"user": {"name", "email", "bio"},
"admin": {"name", "email", "bio", "role", "credit"},
}
PUBLIC_FIELDS = ("id", "name", "email", "bio", "role", "credit")
def serialize(self, user):
if self.server.mode == "fixed":
return {key: user[key] for key in PUBLIC_FIELDS}
return dict(user)
# inside do_PATCH, before the update:
if self.server.mode == "fixed":
rejected = sorted(set(payload) - WRITABLE[me["role"]])
if rejected:
return self.reply(400, {"error": "fields_not_writable", "fields": rejected})
target.update(payload)
self.reply(200, self.serialize(target))
The fixture rejects instead of ignoring. A payload with any non-writable field gets a 400 that names the fields, and nothing in it is applied. Silently dropping the field would also stop the escalation, but a client then cannot tell that its change was discarded. The mixed request in the table shows the difference: the bio in that payload was not saved, because the whole request failed.
The retest used a fresh fixed-mode server on port 53359 and the same ten requests. Alice's request for role and credit returned 400 with the rejected field names, and her profile still read role user and credit 0 afterwards. is_admin, which no field in the fixture uses, was also rejected, so the check covers unknown names as well as known sensitive ones. The positive controls passed: Alice's name and bio update returned 200 and persisted, and the admin changed her credit to 50 through the admin endpoint. No fixed-mode response contained password_hash.
To inspect the servers by hand, use python3 fixture.py --mode vulnerable --port 8765 and python3 fixture.py --mode fixed --port 8766 in two terminals, and point the recorded curl commands at those ports.
Inspect the evidence bundle
- Machine-readable results: all twenty cases with identities, request bodies, expected and observed outcomes, response filenames and hashes.
- Exact recorded curl commands: the ports belong to the finished run, so use the runner for fresh servers.
- SHA-256 manifest: compare downloaded sources and evidence. This is an integrity check, not independent attestation.
- Fixture instructions and limitations, server source and verification source.
All tokens, names and hash strings are synthetic public demo data, and the bundle contains the complete HTTP response text, including dates and server headers. No production credentials or real user data were involved.
What this experiment does not cover
Twenty requests against one toy service show one write path and one read path. We did not test nested objects, arrays, PUT or POST create flows, JSON merge semantics, GraphQL mutations, framework binders such as Spring or Rails, value validation (the fixture never checks that credit is a number), concurrent writes, or fields that the fixture does not model. The allowlist here is a literal Python set. A real system must also decide per tenant, per relationship and per object state which fields are writable, and its response filtering must cover nested objects, error messages and logs.
For a real engagement, list every writable endpoint, build a test matrix of sensitive fields against roles, send each field as a normal user, and read the object back each time. Keep failed runs with the successful ones and repeat the same matrix after each fix. Our posts on IDOR, broken access control and the OWASP API Top 10 cover neighboring checks.
Excession Labs runs this kind of field-level authorization testing in its security assessments. See how a finding like this looks in a sample report.