Combines IDOR with mass assignment to both access and modify protected attributes of another user's object in a single request.
TL;DR
role, is_admin, balanceMass assignment IDOR occurs when a web framework automatically binds HTTP request body parameters to internal object properties without restricting which properties are writable. When a developer writes new User(req.body) in Node.js, User.create(params) in Rails, or uses fields = '__all__' in Django REST Framework, every field in the request body is mapped to the corresponding model property. An attacker who injects a privileged field — is_admin, role, balance, owner_id, tenant_id — overwrites the server-side value, often achieving immediate privilege escalation.
CWE-915 (Improperly Controlled Modification of Dynamically-Determined Object Attributes) is the precise classification. OWASP API Security Top 10:2023 positions this as API3:2023 BOPLA — Broken Object Property Level Authorization — merging the former API6:2019 (Mass Assignment) and API3:2019 (Excessive Data Exposure) categories because they share an identical root cause and fix: property-level authorization through explicit field allowlists.
The GitHub 2012 incident established this vulnerability class as a critical industry concern. Researcher Egor Homakov exploited Rails mass assignment to set user_id to the Rails organization's user ID via an unprotected controller action, uploading a public SSH key to the official Rails repository and gaining push access to the project. This triggered Rails 4's introduction of Strong Parameters as a required default, shifting the burden from implicit deny to explicit allow for all HTTP parameter binding.
The vulnerability has two root causes that appear together: framework auto-binding (the mechanism) and missing property-level authorization (the authorization gap).
HTTP request with attacker-controlled fields:
PATCH /api/users/me
Content-Type: application/json
Authorization: Bearer <user_token>
{
"display_name": "Alice",
"is_admin": true, ← privileged field
"role": "admin", ← privileged field
"credits": 99999, ← financial field
"tenant_id": "org_B" ← tenant boundary violation
}
Without field restriction:
user = User.findById(id)
Object.assign(user, req.body) ← all fields applied including privileged ones
user.save()The detection confirmation pattern requires a GET re-read — a field echoed in the POST response may be a display artifact without database persistence:
Step 1 — Inject:
PATCH /api/users/me
{"is_admin": true, "role": "admin", "credits": 99999}
Step 2 — Re-read (CRITICAL — confirms persistence):
GET /api/users/me
→ is_admin: true → CONFIRMED
→ role: "admin" → CONFIRMED
→ credits: 99999 → CONFIRMED
Step 3 — Privilege verification (optional, highest confidence):
GET /api/admin/users # with same token
→ 200 OK → CONFIRMED with privilege action verified (CRITICAL severity)| Framework | Vulnerable Pattern | Safe Pattern |
|---|---|---|
| Rails | params.permit! / User.update(params[:user]) | params.require(:user).permit(:email, :name) |
| Laravel | protected $guarded = [] / forceFill() | protected $fillable = ['name', 'email'] |
| Django DRF | fields = '__all__' in ModelSerializer | fields = ['name', 'email'] + read_only_fields = ['is_staff'] |
| Express/Mongoose | new User(req.body) / findByIdAndUpdate(id, req.body) | _.pick(req.body, ['name', 'email']) or Zod schema |
| Spring Boot | @ModelAttribute User user binding all fields | @InitBinder setAllowedFields(...) or DTO pattern |
| FastAPI/Pydantic | class Config: extra = 'allow' | Schema with explicit fields only, extra = 'forbid' |
The minimum viable injection wordlist for mass assignment testing (30+ fields):
role, isAdmin, is_admin, admin, is_staff, is_superuser, superuser,
is_moderator, permissions, capabilities, scope, groups,
credits, balance, account_balance, available_credit, wallet,
email_verified, verified, is_verified, kyc_level, kyc_status,
subscription_tier, plan, is_premium, premium,
active, is_active, locked, banned, blocked,
created_at, owner_id, user_id, account_id, price, discount| Variant | Technique | Impact |
|---|---|---|
| Role escalation | {"role": "admin", "is_admin": true} | Full privilege escalation |
| Financial fraud | {"balance": 99999, "credits": 99999} | Free credits, negative pricing |
| Tenant bypass | {"tenant_id": "org_B"} | Cross-tenant data access |
| Account unlock | {"locked": false, "banned": false} | Bypass account suspension |
| Email verification bypass | {"email_verified": true} | Skip verification step |
| Ownership transfer | {"owner_id": 1338} | Take ownership of another user's objects |
| OpenAPI readOnly bypass | Inject readOnly: true fields from spec | Schema-documented fields accepted in writes |
GitHub 2012 — The Canonical Mass Assignment Incident — Researcher Egor Homakov discovered that a GitHub controller accepted user_id as a parameter when creating SSH public keys. Rails mass assignment bound the parameter directly to the PublicKey model without whitelisting, allowing Homakov to set user_id to the Rails organization's user account. He uploaded an SSH key scoped to the Rails org, gaining push access to the Rails repository — the framework used by GitHub itself. The incident directly caused Rails 4.0's Strong Parameters to become a required default, replacing the opt-in attr_accessible pattern.
CVE-2026-29056 — Kanboard User Invite Mass Assignment (High) — Kanboard ≤ 1.2.50 contained a classic developer-inconsistency vulnerability. The standard UserController for account settings correctly called $this->userModel->update() with an explicit field whitelist that excluded role. The UserInviteController::register() method, added later by a different developer, called $this->userModel->create($this->request->getValues()) — passing all POST body parameters without filtering. An email-invite recipient included role=app-admin in the form body, registering as a full administrator. Plugin installation rights followed, creating a path to remote code execution. Fixed by adding unset($values['role']) before model creation.
CVE-2024-7297 — Langflow Super-Admin Escalation (CVSS 8.8, July 2024) — Langflow (the LangChain-based LLM application builder) versions below 1.0.13 accepted {"is_superuser": true} via PATCH /api/v1/users/<USER_ID>. Any authenticated low-privilege user gained immediate super-admin access to all AI flows, API credentials, and infrastructure settings. Discovered by Tenable Research (TRA-2024-26) and remediated in version 1.0.13 with server-side field filtering.
CVE-2022-22968 — Spring Framework WebDataBinder Denylist Bypass (CVSS 5.3, April 2022) — Spring Framework versions before 5.3.19 and 6.0.0 used WebDataBinder.setDisallowedFields("balance") as a blocklist approach. The implementation was case-sensitive: balance was blocked but Balance (capital B) was not. Clients could submit Balance=99999 and the field would be bound. The patch improved pattern matching in WebDataBinder to be case-insensitive. This CVE demonstrates why denylist-based defenses are structurally fragile: any bypass vector — case, encoding, alternate field name — defeats the protection.
CVE-2023-4836 — WordPress User Private Files IDOR — Subscribers and low-privilege WordPress users crafted requests to access private files uploaded by any other user. The mass assignment overlap: the plugin constructed file paths directly from user-controlled parameters without validating that the requesting user owned the referenced file. This combined indirect object reference (filename) with insufficient property-level authorization on file access.
readOnly: true, and inject them as write candidates.is_admin, isAdmin, IsAdmin, ISADMIN.# Python — automated mass assignment test loop
import httpx
import asyncio
PRIVILEGED_FIELDS = [
"role", "is_admin", "isAdmin", "admin", "is_staff", "is_superuser",
"credits", "balance", "account_balance", "email_verified", "verified",
"subscription_tier", "plan", "owner_id", "tenant_id", "price"
]
async def test_mass_assignment(base_url: str, token: str, endpoint: str):
async with httpx.AsyncClient() as client:
for field in PRIVILEGED_FIELDS:
# Inject privileged field
payload = {field: True} # boolean escalation attempt
patch_resp = await client.patch(
f"{base_url}{endpoint}",
json=payload,
headers={"Authorization": f"Bearer {token}"}
)
# GET re-read — only flag if field persisted
get_resp = await client.get(
f"{base_url}{endpoint}",
headers={"Authorization": f"Bearer {token}"}
)
if get_resp.status_code == 200:
body = get_resp.json()
if body.get(field) is True:
print(f"CONFIRMED: {field} persisted on {endpoint}")BreachVex's mass assignment detection injects a broad privilege-field wordlist into POST/PUT/PATCH endpoints. Prioritized endpoint patterns: paths containing /users, /accounts, /profile, /register, /signup, /settings, /orders with JSON content types. It also specifically targets OpenAPI readOnly: true fields — the documented schema is parsed, readOnly fields are extracted as candidates, and they are injected as write targets.
Confidence tiers: CONFIRMED when GET re-read shows field persistence; POTENTIAL when field appears in the POST response but GET re-read is inconclusive; INFORMATIONAL when the server returns 422 Unprocessable Entity naming the unexpected field (Pydantic extra="forbid" mode — indicates the field was rejected, not accepted).
Never report mass assignment as CONFIRMED without GET re-read verification. Pydantic (Python) and Zod (TypeScript) by default echo unknown fields in error responses without persisting them. A 422 response naming the injected field is not a vulnerability — it is the correct rejection behavior. Only persistence confirmed via independent GET qualifies as a finding.
The most robust pattern: declare exactly which fields are writable. Any field not in the schema cannot be submitted regardless of what the client sends:
from pydantic import BaseModel
from typing import Optional
# Input schema — only user-writable fields
class UserUpdateRequest(BaseModel):
display_name: str
bio: Optional[str] = None
# is_admin, role, credits, tenant_id: NOT declared → rejected automatically
# Output schema — includes fields users can read but not write
class UserResponse(BaseModel):
id: int
display_name: str
bio: Optional[str]
is_admin: bool # readable in response
credits: int # readable in response
# These are in UserResponse but NOT in UserUpdateRequest = never writable
@router.patch("/users/me", response_model=UserResponse)
async def update_me(user_id: str = Depends(get_current_user_id),
data: UserUpdateRequest):
# data.model_dump() contains only declared fields — is_admin cannot be here
await db.users.update(id=user_id, **data.model_dump(exclude_unset=True))
return await db.users.get(id=user_id)# VULNERABLE — exposes all model fields including is_staff, is_superuser
class UserSerializer(ModelSerializer):
class Meta:
model = User
fields = '__all__' # every field is writable
# VULNERABLE — exclude only protects named fields; new fields are auto-exposed
class UserSerializer(ModelSerializer):
class Meta:
model = User
exclude = ['password'] # is_staff, is_superuser still writable
# SAFE — explicit allowlist; any new model field requires explicit addition
class UserUpdateSerializer(ModelSerializer):
class Meta:
model = User
fields = ['display_name', 'bio', 'email']
read_only_fields = ['id', 'is_staff', 'is_superuser', 'date_joined', 'credits']import { z } from 'zod';
// Schema declares exactly what is writable
const UserUpdateSchema = z.object({
displayName: z.string().min(1).max(100),
bio: z.string().max(500).optional(),
// isAdmin, role, credits: not in schema → rejected by .strict()
}).strict(); // .strict() throws on extra fields
app.patch('/api/users/me', authenticate, async (req, res) => {
const result = UserUpdateSchema.safeParse(req.body);
if (!result.success) {
return res.status(400).json({ error: result.error.flatten() });
}
// result.data contains only schema-declared fields
await User.update({ id: req.user.id }, result.data);
res.json(await User.findById(req.user.id));
});// VULNERABLE — @ModelAttribute binds all request fields to User entity
@PatchMapping("/users/me")
public ResponseEntity<User> updateUser(@ModelAttribute User user) {
userRepository.save(user); // isAdmin, role, balance all accepted
return ResponseEntity.ok(user);
}
// SAFE — DTO restricts bindable fields; User entity is never directly bound
public class UserUpdateDTO {
private String displayName;
private String bio;
// isAdmin, role, balance: NOT in DTO — Spring cannot bind them
// Getters/setters only for above fields
}
@PatchMapping("/users/me")
public ResponseEntity<UserResponse> updateUser(
@RequestBody UserUpdateDTO dto,
@AuthenticationPrincipal UserDetails principal) {
User user = userRepository.findByUsername(principal.getUsername());
user.setDisplayName(dto.getDisplayName()); // explicit field mapping
user.setBio(dto.getBio());
// isAdmin is never touched — it is not in UserUpdateDTO
return ResponseEntity.ok(toResponse(userRepository.save(user)));
}Mass assignment IDOR occurs when a framework automatically maps HTTP request body fields to ORM model properties without restricting which fields are writable. An attacker injects sensitive fields — is_admin, role, balance, owner_id — that the application never intended to be user-controllable, modifying them in a single request.
CWE-915 (Improperly Controlled Modification of Dynamically-Determined Object Attributes) is the precise classification for mass assignment. OWASP reclassified it as API3:2023 BOPLA (Broken Object Property Level Authorization) — both API6:2019 mass assignment and API3:2019 excessive data exposure share the same fix: property-level authorization via explicit allowlists.
Researcher Egor Homakov exploited Rails mass assignment to upload an SSH key to the official Rails organization repository without authorization. He set user_id to the Rails org's user ID via an unprotected controller, gaining push access to the Rails source code. This triggered Rails 4's Strong Parameters as a default protection and became the canonical mass assignment case study.
In DRF, use explicit field lists in serializers instead of fields='__all__'. Mark sensitive fields as read_only_fields. Avoid exclude=[password] patterns — any new model field added later is automatically exposed. The safe pattern: fields=['id','username','email'] with read_only_fields=['id','is_staff','is_superuser'].
CVE-2022-22968 in Spring Framework showed that setDisallowedFields('balance') did NOT block 'Balance' (capital B) due to case-sensitivity in WebDataBinder. Fields intended to be blocked could be submitted via alternate casing. Denylist approaches are fundamentally fragile — allowlists are the only reliable defense.
OpenAPI specifications mark some fields as readOnly: true — they appear in GET responses but should not be accepted in POST/PUT/PATCH bodies. Some servers honor the schema in documentation but not in validation, accepting readOnly fields from clients and applying them to the model. BreachVex specifically tests readOnly fields from the spec as injection candidates.
In Kanboard ≤ 1.2.50, the standard account settings controller correctly whitelisted fields excluding the 'role' parameter. The user invite registration controller (UserInviteController::register()) passed all POST parameters directly to UserModel::create() without the same filter. An invite recipient included role=app-admin in the form body, registering as a full administrator — a classic developer-inconsistency-across-code-paths vulnerability.
Mass assignment must never be reported without GET re-read confirmation to avoid false positives. After injecting a privileged field (e.g., PATCH /users/me with {is_admin: true}), issue GET /users/me and verify the field persisted. If is_admin is true in the GET response, the finding is CONFIRMED. If the field is echoed in the PATCH response but absent after GET, it is at most POTENTIAL.