DEV Community

Davi
Davi

Posted on Originally published at blog.mago.team

Framework Binding Gap: How the ORM Accepts Fields You Never Documented

In March 2012, Egor Homakov committed directly to the rails/rails repository without write access. No shellcode. No buffer overflow. One extra field in an HTTP request: public_key_ids[]. Rails auto-bound it to the SSH key model because no allowlist existed. GitHub patched within hours; Rails shipped strong_parameters three months later. Fourteen years on, the same binding gap ships to production in every framework, renamed and slightly obscured.

The problem is not a validation failure. The framework binds ORM model fields, not documented API fields. Every field in the model that the ORM binds automatically is a potential privilege escalation vector.

The Framework Binds What the Developer Forgot to Block

Developers control routes, handlers, and business logic. The framework controls binding. In every major stack, the default is to bind everything the ORM model exposes, not everything the developer intended to accept.

Rails before version 4.0 exposed all model attributes by default. attr_accessible was opt-in, not opt-out. DRF with fields = '__all__' binds every model column, including role, is_staff, and is_superuser. Laravel Eloquent without $fillable set treats all columns as mass-assignable. Spring @RequestBody pointing directly at an @Entity class gives Jackson access to every mapped column, including audit fields and discriminators.

The binding layer executes before any validation layer. Input reaches the ORM before the developer's code can inspect it. The developer documents 3 fields; the framework binds 47.

The pattern is worst on partial update endpoints. A PATCH that accepts any subset of model fields is, by definition, a mass assignment endpoint if no explicit allowlist exists. Most tutorials address the allowlist for POST (creation) but skip PATCH (update), treating it as safe by design.

CVE-2025-2304, CVE-2024-7297, CVE-2024-24573: The Pattern Repeats Across Three Ecosystems

CVE-2025-2304 (Camaleon CMS, Rails, CVSS 9.4 critical): params.require(:password).permit! in UsersController#updated_ajax let any authenticated user inject role=admin during a password change. The escalation vector was the role field on the User model, never documented in the password-change API but accessible via permit!. Published March 2025, patched in version 2.9.1 by replacing permit! with explicit field names. CVSS 4.0 scored 9.4 because the attack requires only basic authentication, no user interaction, with full confidentiality and integrity impact.

CVE-2024-7297 (Langflow, FastAPI/Python, CVSS 8.8 high): PATCH /api/v1/users/[USER_ID] accepted {"is_superuser": true} without a role check. A low-privilege authenticated user escalated to super admin with a single PATCH request. The is_superuser field existed in the Pydantic model but should never have been writable via the public API. Discovered June 2024, patched before Langflow version 1.0.13.

CVE-2024-24573 (facileManager, PHP, CVSS 8.8 high): A POST to processPost.php with extra permission fields let non-admin users grant themselves superuser privileges. The update handler accepted the fields without binding restriction. Fix commit: 0aa850d.

All three share root cause CWE-915 (Improperly Controlled Modification of Dynamically-Determined Object Attributes). In each case, the framework's binding layer accepted fields the endpoint handler never validated. None of these are beginner mistakes. Camaleon CMS has a production release history; Langflow is a widely used LLM workflow platform; facileManager is enterprise DNS management software.

Three Exploitation Patterns

Role escalation via role field: The attacker includes {"is_admin": true} or {"role": "admin"} in the profile update request body. If the ORM accepts it, escalation is immediate. CVE-2025-2304 is the canonical example: the role field was never part of the password change form, but permit! made it writable.

Account takeover via authentication fields: The attacker sends {"email": "attacker@evil.com", "password": "newpassword"} to an endpoint meant to update only profile fields. If the endpoint uses the same model for profile data and authentication credentials, and binding does not distinguish between them, the attacker can reset credentials. No verification flow is required. HackerOne #99424 (Uber) documents the variant in driver profiles: the acceptance endpoint bound fields that should have been locked after the acceptance step.

Financial manipulation via billing fields: The attacker includes {"balance": 999999} or {"credit_limit": 50000} in a user preferences update request. HackerOne #267781 documents this exact pattern: a billing plan access field accepted in the update request body, letting users activate paid access for free. HackerOne #1607756 (Omise, payment platform) shows the invitation variant. An invited user retained admin modification capability through a direct PATCH that bypassed the invitation role restriction.

Why Allowlists Break at Scale

An allowlist works when the developer keeps 3 things synchronized: the ORM model, the API schema, and the controller allowlist. All 3 diverge.

Serializer inheritance is the first failure vector. An AdminSerializer inherits from PublicSerializer and adds fields. If the admin endpoint accidentally uses PublicSerializer after an incomplete refactor, the correct admin behavior leaks into the public context. The reverse also happens: PublicSerializer silently inherits admin fields without the developer noticing.

Versioned APIs create the second problem. The v1 allowlist does not match the v2 schema. Version 2 added columns (kyc_verified, stripe_customer_id, synced_at) to the model for internal use. The v1 endpoint still uses the same ORM model, and v1 binding now exposes columns that should only exist for internal systems.

Admin and public endpoints sharing the same model is the third pattern. AdminUserSerializer has is_staff, is_superuser, role. PublicUserUpdateSerializer should have only name and email. One accidental merge or simplification refactor consolidating both into a single serializer exposes all admin fields on the public route. Regression tests for the admin endpoint test the correct path, not the attacker's path. The regression on the public endpoint goes undetected.

Detection: The Gap Between the OpenAPI Spec and What the ORM Accepts

The binding gap is structural: API specs describe intended inputs; ORM models describe database columns; the framework binds from the model. Every undocumented field in the model is an attack surface that no spec-based scanner will find.

Detection requires comparing the fields an endpoint actually accepts against the fields documented in the OpenAPI spec. Spec-based scanners test documented parameters for injection; mass assignment lives in undocumented parameters.

The MAGO team tool (mago.team) compares the documented OpenAPI schema against the fields the endpoint actually accepts, identifying discrepancies that indicate exposed mass assignment.

The manual complement is fuzzing with a wordlist of common field names: role, is_admin, admin, verified, credit_balance, plan, is_superuser, is_staff, kyc_verified, stripe_customer_id. Any field accepted without appearing in the spec is a mass assignment candidate. Columns added to the database for internal use become bindable without any spec update, and the divergence grows over time.

additionalProperties: false in the OpenAPI schema is the only control that works independently of the framework. It rejects extra fields at the gateway, before the request reaches the binding layer. Any endpoint where the developer forgot to update the controller allowlist is covered.

Per-Framework Hardening: Binding Contracts, Not Input Sanitization

The correct fix is not validating or sanitizing input after binding. It is preventing the framework from binding unauthorized fields before binding happens.

Rails: permit() at every write action, never permit!. A separate set of permitted params for admin versus regular user routes:

# SAFE: explicit allowlist
params.require(:user).permit(:name, :email, :password, :password_confirmation)

# DANGEROUS: root cause of CVE-2025-2304
params.require(:user).permit!
Enter fullscreen mode Exit fullscreen mode

DRF (Django REST Framework): Two serializer classes per model: ReadSerializer and WriteSerializer. read_only_fields does not prevent a field from being accepted on write if explicitly included in the request. The real fix is a WriteSerializer with an explicit fields list:

# SAFE: write serializer with explicit field list
class UserWriteSerializer(ModelSerializer):
    class Meta:
        model = User
        fields = ['name', 'email', 'password']
        # role, is_staff, is_superuser: deliberately absent

# DANGEROUS: all model fields writable
class UserSerializer(ModelSerializer):
    class Meta:
        model = User
        fields = '__all__'
Enter fullscreen mode Exit fullscreen mode

Spring: Dedicated DTO classes per endpoint, never @RequestBody pointing at an @Entity class:

// SAFE: dedicated DTO with only editable fields
@PatchMapping("/users/{id}")
public User update(@RequestBody UserUpdateRequest req) { ... }

record UserUpdateRequest(String name, String email) {}

// DANGEROUS: Jackson gets access to every mapped field
@PatchMapping("/users/{id}")
public User update(@RequestBody User user) { ... }
Enter fullscreen mode Exit fullscreen mode

Express (Node.js): Explicit destructuring from req.body, never Object.assign(user, req.body):

// SAFE: explicit field extraction
const { name, email } = req.body
await user.update({ name, email })

// DANGEROUS: passes the entire request body to ORM
await user.update(req.body)
Enter fullscreen mode Exit fullscreen mode

Gateway: additionalProperties: false in the OpenAPI schema enforces the contract before the request reaches the framework:

components:
  schemas:
    UserUpdateRequest:
      type: object
      additionalProperties: false
      properties:
        name:
          type: string
        email:
          type: string
      required: [name, email]
Enter fullscreen mode Exit fullscreen mode

When you audit an API endpoint, open the ORM model alongside the controller, not just the route handler. Every column in the model that does not appear in an explicit binding allowlist is a field an attacker can attempt to write. The audit question is not "what does the code validate". The question is "what does the framework bind". Those are two different lists. The gap between them is the vulnerability.

Top comments (0)