⚠ Authorized testing only. Disclosed public bug-bounty data for defensive/educational research. Use payloads only against systems you are permitted to test.
LogoThe Hacktivity Field GuideReal-world web hacking, catalogued
🔎
Field Guide/Vulnerabilities/Mass Assignment
Vulnerabilities

Mass Assignment

⚠ Thin coverage — only 16 disclosed reports for this class; illustrative, not exhaustive.

§Basic information

Mass assignment (a.k.a. autobinding / over-posting) happens when a create or update endpoint binds attacker-supplied request keys straight onto a server object with no field allowlist. The UI submits name and bio; the controller does model.update(request.body); so you append one extra key the UI never sends — admin, role, status, approval, visibility, permissions, or the account's own email — and the server dutifully writes it.

The core mechanism is a trust mismatch between the client-drawn form and the server-side model. Frameworks that map a request body onto a record (Rails, Node ORM binding via Mongoose/Sequelize, Meteor insert, Spring MVC data binding, Laravel $fillable gaps) will happily set server-authoritative attributes if the developer forgot to constrain which keys are bindable. One extra JSON key silently rewrites state the UI presented as read-only or absent: self-promote to admin, self-approve a paid campaign, or rebind the recovery anchor and take the account over. A sibling family — prototype pollution — is the same bug in JavaScript: a recursive merge/set walks attacker keys onto an object without excluding __proto__, mass-assigning onto Object.prototype itself.

§Methodology

  1. Read the object first — the response is the schema. GET the resource and record every attribute name. Fields the read exposes but the write form omits are your prime candidates (admin_approval, effective_status, role, email, visibility…).
  2. Diff read vs. write. Compare the GET body against the POST/PATCH body the UI actually sends. The delta is the set of fields the server manages and the client "shouldn't" touch — try touching them.
  3. Enumerate privileged keys. Replay the normal write and append best-first guesses (see the canary list below). Keep every legitimate field the UI sent; add one privileged key at a time so you know which one bound.
  4. Prove persistence, not echo. Re-GET the object — ideally on an independent backend (a different REST route or GraphQL query). A field mirrored back in the write response may be a harmless echo; only a re-read on another path proves it was persisted.
  5. Escalate to impact. A bound admin/role/approval field is already the finding; a bound email chains to account takeover; a bound __proto__ chains to XSS/RCE/DoS.
# 1. Read the object, capture every attribute name GET /api/v2.0/accounts/ID/ads/AD_ID # response reveals: admin_approval, effective_status, configured_status, ... # 2. Replay the normal write, appending privileged keys (best-first): # admin is_admin role user_type is_staff owner owner_id permissions[] # status active state public approval admin_approval effective_status # verified visibility plan price seats votes package_name
▸ TIP
The privileged field's exact name is almost always already visible to you — it's in the GET response for the same object, or leaked in a listing endpoint. Do not guess blindly if a read gives you the real schema (a team_members listing leaked the caller's own object id and an is_admin flag in #42961, which pointed straight at the writable role key).

§Technique variants

Group your write request by what you are trying to overwrite; the mechanism is identical, only the field and endpoint change.

Self-promote on account/profile update

The account's own model is bound whole on a profile save. Add the role/privilege field you saw in the read response. Watch the Content-Type — some stacks filter form-encoded params but auto-bind the JSON path.

POST /account/update HTTP/1.1 Content-Type: application/json; charset=UTF-8 X-CSRF-Token: VALID {"name":"cur","bio":"cur","admin":true}

Server-authoritative workflow / state fields

status, approval, effective_status, configured_status, active are meant to be set only by review/payment/moderation logic. If a PATCH honors them, you skip the gate — self-approve, publish without review, activate without paying.

PATCH /api/v2.0/accounts/ID/ads/AD_ID HTTP/1.1 Content-Type: application/json {"data":{"configured_status":"ACTIVE","effective_status":"ACTIVE","admin_approval":"APPROVED"}}

Rebind the recovery anchor (email) — the ATO variant

A field the UI renders read-only is frequently not protected server-side, and often a sibling endpoint writes it with weaker checks than the dedicated one (the /password route enforces old-password; the /bio route rebinding email enforces nothing). Rebind email, then chain forgot-password.

POST /account/info/bio/v1 HTTP/1.1 x-auth-token: VICTIM_TOKEN Content-Type: application/json {"first_name":"cur","last_name":"cur","email":"attacker@COLLAB","role":"OWNER"}

Client-dictated create fields (permissions / entitlements)

On create, the object is built from the body with no allowlist. Supply the privileged attribute the server should compute: a full permissions[] array, a premium-only visibility, or Meteor-style state/public/votes on an insert.

POST /ORG/stores/create_managed_store HTTP/1.1 Content-Type: application/json {"message":"","permissions":["applications","customers","orders","edit_orders","view_billing_details","edit_private_apps"],"store_domain":"myStore1"}
# Entitlement gated only in the UI — send it verbatim, server never re-checks the plan POST /workspaces/WORKSPACE_ID/projects HTTP/1.1 Content-Type: application/json {"description":"x","visibility":"Personal","initial_message":{"message":"x","files":[]}}

Registration role/type

Self-registration binds a client-supplied user_type/role. Enumerate integer tiers to find the privileged one; you self-provision an admin at signup.

POST /newuser.cfm?loc_class=L HTTP/1.1 Content-Type: application/x-www-form-urlencoded user_type=4&fname=Test&lname=Test&ssn=VALID_UNUSED_SSN

GraphQL Input objects

A GraphQL Input type routinely accepts more fields than the form submits. Dump the Input type with introspection, then inject a staff-only field. Verify with the matching query.

mutation AutosavePentestOpportunity($input: AutosavePentestOpportunityInput!) { autosavePentestOpportunity(input: $input) { was_successful } } # add to $input: "package_name": "premium_p80"

Forced-browse targets (no edit UI ≠ no controller)

An object hidden from the UI still has a standard REST route. Guess /{resource}/{id}/edit, diff a normal update body, and append the privileged attribute (admin, role, owner). The change can be invisible to other admins if the column isn't rendered for that object type.

POST /users/USER_ID HTTP/1.1 Content-Type: application/x-www-form-urlencoded user[email]=attacker@COLLAB&user[admin]=1

Prototype pollution (recursive property-write sink)

The JavaScript incarnation: any deep-merge / extend / clone / dotted-path setter that walks attacker keys without excluding __proto__ mass-assigns onto Object.prototype. Use JSON.parse so __proto__ is a real own enumerable key (a literal {__proto__:…} just sets the prototype and won't reproduce it).

// deep-merge sink ($.extend deep, lodash merge, dot-prop, ...): merge({}, JSON.parse('{"__proto__":{"polluted":true}}')); console.log({}.polluted); // true -> pollutes Object.prototype // dotted-path setter sink: set(obj, '__proto__.isAdmin', true); console.log({}.isAdmin); // true
● NOTE
Prototype pollution is not server-only. A front-end library that deep-merges config you can influence (chart datasets/options, plugin settings) is a client-side PP sink, and it frequently escalates to DOM XSS when a later gadget reads the polluted property into an HTML/attribute sink.

§Bypasses

Filter / controlBypassSeen in
Rails strong-params permitparams.each / .map / .to_a yields the unfiltered hash, re-exposing unpermitted keys (CVE-2020-8164)#292797
Rails nested-attr :reject_ifadd _destroy=1 to the nested object — skips reject_if; with allow_destroy:false the rejected values are written anyway (CVE-2015-7577)#90457
Form-encoded param filterresend as Content-Type: application/json — the JSON path auto-binds where the form path filtered#42961
UI read-only fieldwrite it via a sibling REST endpoint; protection asymmetry (/password guarded, /bio not)#3766455
No edit UI for the objecthit the standard /{resource}/{id}/edit route directly; it still accepts privileged params#962895
Coarse "can edit" authztamper a hidden status/state input the single edit-permission gates without its own check#3678828
__proto__ string blacklistretry via the constructor.prototype.X traversal path#968355
Deep-merge guardsink lacks a hasOwnProperty check on the target key → recurses into __proto__#776371
Non-merge PP sinkpass __proto__ as a key-list arg to a formatter (console.table properties, CVE-2022-21824)#1431042
▲ WARNING
A write that echoes your privileged field back in the response is not proof of impact — it may be a harmless round-trip. Always confirm on an independent read path (a different backend or a GraphQL query) that the value actually persisted before you claim mass assignment.

§Escalation & impact

§Prevention

§Tools

Specimens — real-world examples

The techniques above are the general method. Below, each disclosed HackerOne report is a catalogued example — concrete payload, outcome, and matching practice lab. 16 in this class.

Real-world example

Privilege escalation by tampering role parameter at registration

◆ Critical
Specimen #796379 · deptofdefense · none · 7 votes · resolved
Program deptofdefenseSurface webChain self-register as admin -> read all applicants' PII/SSNs -

Root cause

The registration request accepts a client-supplied user_type/role parameter that the server trusts, so submitting a privileged value self-provisions an administrator account.

Method

  1. Start a normal self-registration and intercept the submit request
  2. Change user_type to a privileged value (e.g. 4)
  3. Complete registration and log in with administrator access to all applicant PII/SSNs
POST /.../newuser.cfm?loc_class=L ...&user_type=4&fname=Test&lname=Test&ssn=VALID_UNUSED_SSN

Insight — Intercept every account-creation/profile-update request and look for role/type/is_admin/permission fields to overwrite (mass assignment). Servers frequently bind whatever the client sends. Enumerate role integer values to find privileged tiers.

Real-world example

Client sets server-owned approval/status fields

◆ High
Specimen #1543159 · reddit · 5000 · 189 votes · resolved
Program redditSurface api

Root cause

The ads PATCH endpoint accepted admin_approval and effective_status in the body and honored them, letting a user self-approve a campaign and mark it ACTIVE without review or payment.

Method

  1. Create a campaign (status PENDING)
  2. PATCH the ad with {configured_status:ACTIVE, effective_status:ACTIVE, admin_approval:APPROVED}
  3. Campaign goes live, approval email received, no payment/review
PATCH /api/v2.0/accounts/<id>/ads/<id> {"data":{"configured_status":"ACTIVE","effective_status":"ACTIVE","admin_approval":"APPROVED"}}

Insight — Enumerate object fields (from GET responses) and try setting server-authoritative workflow fields (approval, status, role, is_admin, verified, price) in write requests. Mass-assignment of state fields bypasses review/payment gates.

Real-world example

Prototype pollution __proto__ blacklist bypass via constructor.prototype

◆ High
Specimen #968355 · nodejs-ecosystem · none · 10 votes · resolved
Program nodejs-ecosystemSurface otherTag file-upload

Root cause

A deep-merge guard that only skips the literal key '__proto__' still recurses into 'constructor' and 'prototype', so the payload constructor.prototype.X reaches Object.prototype and pollutes it despite the blacklist.

Method

  1. Identify a merge/extend sink that appears patched because it rejects __proto__.
  2. Send the alternate traversal path through constructor.prototype instead.
  3. Confirm pollution on a fresh object.
// i18next deepExtend skips '__proto__' only: deepExtend({}, JSON.parse('{"constructor":{"prototype":{"polluted":true}}}'), true); console.log({}.polluted); // true // path-setter form (property-expr): setter('constructor.prototype.isAdmin')(obj, true); console.log({}.isAdmin); // true

Insight — When a __proto__ filter exists, always retry with constructor.prototype (and mix key casing/Unicode). A blacklist that names only __proto__ is almost always incomplete. Proper fixes null-proto the map, use Map, or reject constructor/prototype and hasOwnProperty-check before recursing.

Real-world example

Rails _destroy flag bypasses reject_if validation (CVE-2015-7577)

◆ High
Specimen #90457 · rails · awarded · 9 votes · resolved
Program railsSurface webTag account-takeover

Root cause

In accepts_nested_attributes_for, the presence of a _destroy flag skipped the :reject_if proc on the assumption the record would be destroyed; when allow_destroy:false the record is NOT destroyed, so rejected/invalid nested changes get applied anyway.

Method

  1. Find a form/endpoint using accepts_nested_attributes_for with reject_if and allow_destroy:false
  2. Submit nested attributes that reject_if would normally reject, plus _destroy=1 (or true)
  3. reject_if is skipped, allow_destroy:false prevents deletion, so the otherwise-rejected values are written
# nested params that reject_if would block, smuggled past it: user[posts_attributes][0][title]=<invalid/blank> user[posts_attributes][0][_destroy]=1

Insight — When testing Rails nested attributes / mass-assignment, always add a _destroy parameter to nested objects; it can flip validation logic and let you clear or set fields the app intended to reject.

Real-world example

UI-locked email editable via API mass-assignment -> password-reset ATO chain

◆ Medium
Specimen #3766455 · yelp · awarded · 70 votes · resolved
Program yelpSurface apiChain session token -> mass-assign email -> forgot-password Tag graphqlTag account-takeover

Root cause

A profile/bio API accepts and applies an 'email' field with no current-password/step-up and no confirmation-to-new-address, even though the UI renders email as read-only and the dedicated password endpoint does enforce old_password; the change silently rebinds the account recovery anchor.

Method

  1. Capture the Save request of the account screen: POST /account/info/bio/v1 with x-auth-token
  2. Add "email":"attacker@example.com" to the JSON body and send
  3. Confirm on an independent backend (GraphQL GetAccountInfo) that private.email changed -> persisted, not echoed
  4. Chain: POST /account/forgot_password/v1 for the new email, receive the reset link at attacker inbox, set a new password (no old password needed), log in
POST https://biz-app.yelp.com/account/info/bio/v1 x-auth-token: <victim_token> Content-Type: application/json {"first_name":"<cur>","last_name":"<cur>","email":"attacker@example.com","role":"OWNER"}

Insight — A field the UI presents as read-only is not protected server-side - resend the write request with the locked field included. When the write target is the email/recovery anchor and the protection is asymmetric (password endpoint guarded, bio endpoint not), a brief session-token hold escalates to permanent ATO via forgot-password. Verify persistence on a second backend to rule out an echo.

Real-world example

Client-supplied visibility field bypasses paid-tier gate

◆ Medium
Specimen #3370430 · lovable-vdp · none · 68 votes · resolved
Program lovable-vdpSurface apiTag account-takeover

Root cause

Project creation trusts a client-supplied visibility value (Personal/Workspace) that is a premium-subscription feature; the server never checks the caller's plan before honoring it.

Method

  1. Start creating a project and intercept POST /workspaces/{id}/projects
  2. Set visibility to Personal or Workspace in the JSON body
  3. Send; response is 201 and the project gets the premium visibility without a subscription
POST /workspaces/{WORKSPACE_ID}/projects {"description":"landing view","visibility":"Personal","initial_message":{"message":"landing view","files":[]}}

Insight — Premium/entitlement features enforced only in the UI are often accepted verbatim from the request body. On every create/update, inject the fields that paid tiers control (visibility, plan, role, seats) and check whether the server re-validates entitlement server-side.

Real-world example

GraphQL mutation accepts staff-only package_name field

◆ Medium
Specimen #2431495 · security · awarded · 62 votes · resolved
Program securitySurface graphqlTag graphql

Root cause

The AutosavePentestOpportunity GraphQL mutation lets the client set package_name (e.g. premium_p80), a field intended to be assigned only by internal reviewing staff; there is no server-side restriction on that input field.

Method

  1. Create a new Pentest and let the form's autosave mutation fire
  2. Intercept the AutosavePentestOpportunity mutation and add the extra input field "package_name": "premium_p80"
  3. Complete/submit the form
  4. Reopen it (OpportunityStatusQuery) and confirm package_name is set to the attacker-chosen value
mutation AutosavePentestOpportunity($input: AutosavePentestOpportunityInput!){ autosavePentestOpportunity(input: $input){ was_successful } } # add to $input: "package_name": "premium_p80"

Insight — GraphQL input objects often accept more fields than the UI submits. Enumerate the mutation's Input type (introspection) and inject privileged/internal fields (package_name, price, status, role, is_staff). Verify persistence via the corresponding query. Similar to #2040756.

Real-world example

Self-grant full permissions via client-controlled permissions array on store creation

◆ Medium
Specimen #1167753 · shopify · awarded · 32 votes · resolved
Program shopifySurface web

Root cause

A staff member limited to add/archive/unarchive development stores can reach the create_managed_store endpoint and supply an arbitrary `permissions` array in the request body, self-assigning every permission the server fails to constrain.

Method

  1. As a staff member with only store add/archive permission, capture the store-create flow
  2. POST to /<org>/stores/create_managed_store
  3. Include a permissions array listing all desired permissions
  4. Store is created with the elevated permission set
POST /100808/stores/create_managed_store Content-Type: application/json {"message":"","permissions":["applications","customers","orders","edit_orders","gift_cards","view_shopify_payments_payouts","view_billing_details","edit_private_apps"],"store_domain":"myStore1","collaborator_access_code":""}

Insight — When creating/updating an object, add fields the UI never sends (permissions, role, is_admin, owner_id). Mass-assignment lets a low-priv actor set attributes the server should control; test every create endpoint with an injected privilege field.

Real-world example

Prototype pollution via unsafe deep-merge / path-setter (__proto__)

◆ Medium
Specimen #719856 · nodejs-ecosystem · none · 10 votes · resolved
Program nodejs-ecosystemSurface otherChain prototype pollution -> app-specific gadget (default-optioTag file-upload

Root cause

Recursive deep-merge/extend and dotted-path setter utilities walk attacker-controlled keys and write to target[key] without excluding __proto__, so a __proto__ key reaches and mutates Object.prototype, adding a property visible on every object in the process.

Method

  1. Find a sink that merges/extends/clones user JSON into an object, or a set(obj, path, value) helper that splits a dotted path.
  2. Feed a JSON body or path whose first key is __proto__ (via JSON.parse so __proto__ is an own enumerable key, not the object's real prototype slot).
  3. Confirm pollution by reading the injected property off a fresh empty object ({}.polluted).
  4. Escalate per app: flip a default option/flag, inject a gadget the app later reads (e.g. a template/HTML sink -> XSS, a shell/opts sink -> RCE), or crash with a bad type -> DoS.
// JSON deep-merge sink (dot-prop, utils-extend, extend-merge, objtools, supermixer, plain-object-merge, json8-merge-patch, jQuery $.extend deep): merge({}, JSON.parse('{"__proto__":{"isAdmin":true}}')); console.log({}.isAdmin); // true // dotted path-setter sink (dot-prop, nested-property, keyd, object-path-set): set(obj, '__proto__.polluted', 'yes'); console.log({}.polluted); // 'yes' // jQuery deep extend: $.extend(true, {}, JSON.parse('{"__proto__": {"devMode": true}}'));

Insight — Any library function that does recursive property assignment from user input is a prototype-pollution sink. Test every merge/extend/clone/defaults/set-by-path helper with a __proto__ (and constructor.prototype) key. Use JSON.parse so __proto__ is a real own key; a literal {__proto__:...} just sets the prototype and won't reproduce it.

Real-world example

Forced-browse edit of hidden POS user + user[admin]=1 backdoor

◆ Medium
Specimen #962895 · shopify · awarded · 8 votes · resolved
Program shopifySurface webChain forced browsing -> mass assignment -> persistent adminTag account-takeover

Root cause

Stocky POS users have no edit UI, but the edit endpoint /users/{id}/edit is reachable by forced browsing, and the update accepts an unexposed user[admin] parameter (mass assignment). An app admin can set a POS user's email to one they own and promote it to admin, creating a hidden backdoor account.

Method

  1. Get a POS user's id by hovering its delete button on /preferences/users
  2. Open /users/{id}/edit directly (no link exists in UI)
  3. Set email to an attacker-owned address so the account can set a password
  4. Intercept the save and append user[admin]=1
POST /users/{user_id} ... user[email]=attacker@evil.tld&user[admin]=1

Insight — For objects hidden from the UI, guess the standard REST edit/update route (/{resource}/{id}/edit). Then diff a normal update body and try appending privileged attributes (admin, role, is_staff, owner). Missing-from-UI != missing-from-controller.

Real-world example

Rails strong-params bypass: .each returns an unfiltered hash (CVE-2020-8164)

◆ Medium
Specimen #292797 · rails · awarded · 4 votes · resolved
Program railsSurface webChain strong-params bypass -> mass assignment -> privilege e

Root cause

ActionController::Parameters#each yields the underlying unpermitted hash (like to_unsafe_h) instead of a permitted:false Parameters object, so code that iterates params with .each and passes values to a model silently reintroduces unpermitted, attacker-controlled keys.

Method

  1. Identify a controller that processes params via params.each { |k,v| ... } rather than permit()
  2. Submit extra parameters not present in the form (e.g. is_admin=true)
  3. Because .each exposes all keys/values as a plain hash, the extra params flow into the model update
params = ActionController::Parameters.new(city: 'Nijmegen', country: 'Netherlands') params.each {} # => {"city"=>"Nijmegen", "country"=>"Netherlands"} (unsafe hash, no permit needed) # vs params.select { true } => still permitted:false Parameters # Attacker adds &is_admin=true to a form post; controller's params.each re-exposes it

Insight — When auditing Rails source, grep controllers for params.each / params.map / params.to_a used to build model attributes. These enumerator methods historically leaked the unfiltered hash, defeating strong parameters. Send extra privileged keys (is_admin, role, user_id, verified) and watch for mass assignment.

Real-world example

Hidden status field lets low-priv user toggle object state (banner activate/deactivate)

◆ Medium
Specimen #3678828 · revive_adserver · none · 3 votes · resolved
Program revive_adserverSurface web

Root cause

The banner-edit handler accepted a hidden 'status' form field and applied it based only on the banner-edit permission, with no separate authorization check for activation/deactivation. An advertiser-level user could therefore change a state field they should not control.

Method

  1. Open the banner edit screen as a low-privilege (advertiser) user.
  2. Add/modify the hidden 'status' parameter in the edit POST to the desired active/inactive value.
  3. Submit; the server overwrites banner status because it authorizes only 'edit', not the status change.
POST /www/admin/banner-edit.php HTTP/1.1 ... (advertiser session) ... bannerid=<id>&status=1 # or status=0 to deactivate; hidden field honored without a status-specific permission check

Insight — On edit/update forms, enumerate hidden fields and try tampering state-bearing ones (status, active, role, owner, is_admin). A single coarse 'can edit' check often gates a set of fields that individually deserve their own authorization — classic mass-assignment / parameter tampering.

Real-world example

Prototype pollution via console.table properties argument (Node core, CVE-2022-21824)

◆ Low
Specimen #1431042 · nodejs · none · 15 votes · resolved
Program nodejsSurface otherTag file-upload

Root cause

console.table builds its column map keyed by the caller-supplied properties array without excluding __proto__; passing properties=['__proto__'] with a plain object argument assigns to Object.prototype numeric indices (empty strings), a pollution vector outside the usual merge/set family.

Method

  1. Locate code that forwards user input into the second (properties) argument of console.table with an object as the first argument.
  2. Pass ['__proto__'] as properties; add N properties to the first object to write indices 0..N-1.
  3. Verify Object.prototype[0] === ''.
console.table({foo:'bar'}, ['__proto__']); // Object.prototype[0] === '' console.table({a:1,b:1,c:1}, ['__proto__']); // Object.prototype => { '0':'', '1':'', '2':'' }

Insight — Prototype-pollution sinks are not limited to merge/set utilities. Any API that indexes an object by an untrusted key list (formatters, serializers, table/column builders) can pollute. When auditing, flag any user-controlled key array used to build a keyed map. Control here is weak (only empty strings to numeric keys) so impact is mainly DoS.

Real-world example

Client-side prototype pollution via chart/config deep-merge -> DOM XSS

◆ Low
Specimen #776371 · nodejs-ecosystem · none · 1 votes · resolved
Program nodejs-ecosystemSurface webChain client-side prototype pollution -> polluted property readTag file-upload

Root cause

Chart.js recursively merges caller-supplied dataset/options config; its _merger recurses into any object-valued key including __proto__ (it does not hasOwnProperty-check the target key), so attacker-influenced chart config pollutes the browser's Object.prototype.

Method

  1. Find a client library that deep-merges attacker-influenceable config (chart dataset/options, plugin options, settings).
  2. Include a dataset/options object with a __proto__ key.
  3. Read the injected property off {} to confirm; then chain to DOM XSS if the app later reads a polluted property into an HTML/attr sink.
new Chart(ctx, { type: 'line', data: { datasets: [ { data:[1,2] }, JSON.parse('{"__proto__":{"abc":"injected"}}') ] }, options: JSON.parse('{"__proto__":{"def":"injected"}}') }); console.log({}.abc, {}.def); // both 'injected' // Fix pattern (patch): guard recursion with hasOwnProperty // if (Object.prototype.hasOwnProperty.call(target, key) && isObject(tval) && isObject(sval)) merge(...)

Insight — Prototype pollution is not server-only: any front-end lib that deep-merges config you can influence is a client-side PP sink, and client-side PP frequently escalates to DOM XSS via a later gadget. Audit chart/UI/config-merge calls the same way as server merges.

Real-world example

Mass assignment on a create method to forge state/visibility/votes/category

◆ Info
Specimen #260632 · legalrobot · awarded · 10 votes · resolved
Program legalrobotSurface api

Root cause

The Meteor /Issues/insert method persists whatever object the client sends without whitelisting fields, so a user can set server-controlled attributes: publish without review (state:Open, public:true), pre-attach votes, or recategorize (type).

Method

  1. Intercept the create call and add server-controlled fields to the inserted object
  2. Set state/public to publish without moderation, or populate votes[] with arbitrary IDs, or change type to move the item into a privileged category
  3. Observe the forged object in the public UI
["{\"msg\":\"method\",\"method\":\"/Issues/insert\",\"params\":[{\"name\":\"x\",\"state\":\"Open\",\"type\":\"feature\",\"public\":true,\"votes\":[\"id1\",\"id2\"]}],\"id\":\"23\"}"]

Insight — On insert/update APIs (esp. Meteor/Mongo/Rails), add fields the UI never sends: state, public, is_admin, owner_id, votes, type. If the server round-trips them, you have mass assignment. Diff a GET response to learn attribute names.

Real-world example

Self-promotion to admin via {"admin":true} mass assignment

◆ Info
Specimen #42961 · x · USD 1400 · 5 votes · resolved
Program xSurface api

Root cause

The account update endpoint binds request JSON directly to the account model without restricting the role/admin field, so a plain member can PUT their own account id with {"admin":true} and become an admin.

Method

  1. Find your own object id (leaked in the team_members listing response)
  2. Send a PUT/PATCH to /accounts/{yourId} with the privilege field set true
  3. Content-Type: application/json is required for the binding to occur; refresh to confirm admin role
PUT /accounts/54aa4ab19ea6961359001260 HTTP/1.1 Host: fabric.io Content-Type: application/json; charset=UTF-8 X-CSRF-Token: VALID {"admin":true}

Insight — On any 'update profile/account' endpoint, try adding privilege fields you can observe in GET responses (is_admin, role, admin, is_owner). Backends using auto model-binding frequently accept them. The role field name is usually visible in the read response for the same object.

§References & practice

  1. PortSwigger Web Security Academy — API testing labs (hands-on practice).
  2. All 16 disclosed reports for this class are catalogued as specimens above.
  3. See also: exploit chains · payload libraries · methodology.