~/learn / how-apps-are-built / orms-and-serialization

ORMs, Mass Assignment, and Serialization

How object-relational mappers and serializers move data between requests, objects, and the database, how mass assignment and excessive exposure happen, and how deserialization gadget chains and prototype pollution turn generous mapping into code execution.

intermediateupdated

An object-relational mapper (ORM) maps database rows to objects in code. ActiveRecord, Django ORM, Hibernate, Entity Framework, Eloquent, Prisma, and SQLAlchemy are all ORMs. They parameterize queries by default, which removes most SQL injection, and they make it very easy to move data in bulk. That same convenience, moving a whole bag of fields in one call, is where the security bugs come from. The theme of this page is that generous, automatic mapping between untrusted input and powerful objects is a liability.

Mass assignment

Mass assignment is when the framework copies every field in a request onto a model:

# Vulnerable: every submitted attribute is assigned, including admin
User.create(params[:user])

If the users table has an admin column, a request containing user[admin]=true sets it, and the attacker is now an administrator by naming a field. The same bug is “overposting” in ASP.NET, a fillable/guarded slip in Laravel, and User.create({ ...req.body }) in JavaScript.

Fix: allowlist the fields a user may set, per action, and prefer an allowlist to a blocklist because a blocklist forgets the column added next quarter.

def user_params
  params.require(:user).permit(:name, :email, :password)
end

User.create!(user_params)

For APIs, a separate input schema or DTO per endpoint makes the writable fields explicit and reviewable, rather than inferring them from whatever the model happens to expose.

Excessive data exposure

The mirror image: serializing a whole object into a response.

// Vulnerable: returns password hash, reset token, internal flags, everything
res.json(await User.findById(id));

The frontend may display only the name, but the full row is in the response for anyone who opens developer tools. OWASP’s API Security Top 10 lists this as a major category precisely because it is so easy to introduce and so invisible in the rendered UI.

Fix: explicit serializers or response schemas that list exactly which fields leave. Never rely on the frontend to hide data, because the data is already on the wire.

Deserialization: when mapping runs code

ORMs are one kind of automatic object construction; deserializers are another, and the dangerous one. Formats that encode arbitrary typed objects (Java serialization, Python pickle, Ruby Marshal, PHP unserialize, .NET BinaryFormatter) reconstruct whatever the bytes describe, invoking constructors and lifecycle hooks as they go. Attackers craft a byte stream that, during reconstruction, triggers a gadget chain: a sequence of method calls that already exist in the application’s libraries, wired together to end in file writes or command execution. The payload never looks like an attack; the act of rebuilding it is the exploit. This is why pickle.loads(request.body) or a Java endpoint calling readObject on request data is critical, and the fix is to never deserialize untrusted input in these formats, using data-only JSON with a schema instead (see injection and file flaws).

Prototype pollution, the JavaScript variant

In JavaScript, merging attacker-controlled JSON into an object can reach the special __proto__ key and modify Object.prototype, which every object inherits from. Setting a property there silently changes defaults application-wide, which has escalated to authorization bypass (an injected isAdmin) and, when a polluted property later feeds a template or child process, to code execution. It is mass assignment at the language level. Guard against it by rejecting __proto__, constructor, and prototype keys, using Map for untrusted key-value data, and preferring libraries that are hardened against it.

Query-builder escape hatches

ORMs still expose raw SQL: where("...") strings, raw(), extra(), $queryRawUnsafe. String interpolation into any of these reintroduces SQL injection, and dynamic column or sort names must be allowlisted because they cannot be bound as parameters. Some ORMs also accept operator objects from JSON: passing {"password": {"$ne": null}} into a MongoDB-style query changes its logic to “password is not null,” a login bypass. Validate the type of every input before it reaches a query builder, so a string parameter cannot arrive as an object.

N+1 and resource limits

An ORM that loads related records one by one (the N+1 problem) is a performance bug on its own, and with user-controlled page sizes or include depth it becomes a denial-of-service lever: one request asks for thousands of nested loads. Cap page sizes, bound how deep a client can expand relations, and be especially strict in GraphQL, where the client composes the shape.

Field notes: mass assignment and ORM escape hatches

  • Mass assignment / autobinding. The highest-yield ORM bug: send fields the form never showed. PATCH /users/1 with {"role":"admin"}, {"is_admin":true}, {"verified":true}, {"account_id":2}, or {"credits":99999}. Learn the real column names by triggering a verbose response or reading the model in a leaked source map, then set them. Rails permit, Django fields, and Spring @JsonIgnore are the guards; their absence is the bug. Param Miner and arjun discover accepted-but-undocumented parameters.
  • ORM injection through the escape hatch. ORMs parameterize by default, but raw fragments do not: Rails where("name = '#{p}'"), order(params[:sort]), pluck(params[:col]); Django .extra() and .raw(); Sequelize literal(). Grep for these and feed them SQL. See SQL injection.
  • Operator injection (NoSQL). A JSON body bound straight into a Mongo query lets {"password": {"$ne": null}} or {"$gt": ""} bypass authentication. Any query built from an object the client controls is a candidate.

Field notes: deserialization and prototype pollution

  • Deserialization to RCE. When an app deserializes attacker-controlled data in a format that carries types (Java, .NET, Python pickle, Ruby Marshal/Oj, PHP unserialize), you generate a gadget chain rather than writing one: ysoserial (Java), ysoserial.net, and published pickle/Marshal payloads. Recognize the blobs: rO0 (Java base64), O:8: (PHP), AAEAAAD (.NET), \x80\x04 (Python pickle).
  • Prototype pollution (JavaScript). Merging attacker JSON into an object without guarding __proto__ writes onto Object.prototype, so every object in the app inherits your property. A payload like {"__proto__":{"isAdmin":true}} can flip authorization flags, and chained with a sink (template engine, child_process options) it reaches RCE. ppmap and Burp’s DOM Invader find client and server variants. Look for recursive merge, extend, clone, and set(path, value) on user input.
  • Excessive data exposure. Serializing the whole model (render json: user) leaks password_digest, tokens, and internal flags. The fix is an explicit serializer; its absence is a reportable finding you confirm by reading the raw JSON.

For systems you own or are authorized to test.

Review checklist

  • Every create and update uses an explicit allowlist of writable fields.
  • Every response uses a serializer with an explicit field list.
  • No untrusted data is passed to a native-object deserializer.
  • Untrusted JSON merges reject __proto__, constructor, and prototype.
  • No string interpolation in raw query methods; dynamic columns and sorts are allowlisted.
  • Inputs are type-checked before reaching query builders.
  • Pagination and include depth are bounded.