CVE-2026-22814

Lucid Nightmares: Hijacking Internal State in AdonisJS

Alon Barad
Alon Barad
Software Engineer

Jan 14, 2026·6 min read

Executive Summary (TL;DR)

Developers using `request.all()` to fill Lucid models are inadvertantly handing attackers the keys to the ORM engine. CVE-2026-22814 allows the injection of internal properties (like `$isPersisted`), tricking the application into treating new records as existing ones (or vice versa) and bypassing field protection. Patch immediately to v21.8.2+.

A critical Mass Assignment vulnerability in the official AdonisJS ORM (@adonisjs/lucid) allows attackers to overwrite internal model state. By injecting reserved properties like '$isPersisted' or '$attributes', remote actors can bypass business logic, escalate privileges, and corrupt database integrity.

The Hook: Active Record's Dark Side

We all love Active Record. It's the comfort food of backend development. You grab some data from a request, throw it at a model, and save. It feels like magic. In the AdonisJS ecosystem, @adonisjs/lucid is the wizard behind the curtain, handling SQL queries with elegant TypeScript syntax.

But here's the thing about magic: if you don't understand the spell, it can blow up in your face. In Lucid's case, the convenience of methods like merge(), fill(), and create() hid a dangerous assumption. These methods take an object—usually the request body—and map keys to model columns. It’s supposed to be a direct pipeline from user input to database columns.

However, in JavaScript, the line between "data" and "internal state" is notoriously blurry. Objects are just bags of properties. If an attacker can control the keys in that bag, and the ORM isn't checking the label on the bottle, they aren't just changing the data in the model—they are changing how the model behaves. CVE-2026-22814 is exactly this: a way to reach into the guts of the ORM and rewire its brain while it's running.

The Flaw: Trusting the 'Own' Property

To understand this exploit, you have to look at how Lucid decides what is safe to copy. The logic was deceptively simple. When you call model.merge(payload), Lucid iterates over the keys in the payload and checks if the model instance has a matching property.

Specifically, it relied on a check similar to this.hasOwnProperty(key). The intention was likely to ensure that we are only setting properties that actually exist on the model definition. It sounds reasonable, right? If I define a User model with a username field, user.hasOwnProperty('username') should be true.

The Trap: In Lucid (and many JS classes), internal state variables are also initialized as "own" properties on the instance. These aren't hidden symbols or private fields #field; they are just public properties sitting right next to your data.

Key internal properties included:

  • $isPersisted: A boolean flag telling Lucid if this record exists in the DB (triggering an UPDATE vs INSERT).
  • $attributes: The raw object holding the dirty data.
  • $original: The snapshot of data loaded from the DB.

Because these exist on this, the validation check passes. The ORM sees $isPersisted in the payload, checks this.hasOwnProperty('$isPersisted'), sees it returns true, and happily overwrites its own internal logic flag with attacker-controlled data. It's like a bank vault checking if you have a key, but not checking if it's the key to the safe or the key to the janitor's closet—and then letting you open anything.

The Code: The Smoking Gun

Let's look at the fix to understand the severity. The maintainers had to implement a hardcoded 'denylist' of properties that should never be mass-assignable. This is a classic cat-and-mouse game patch.

Here is the essence of the patch introduced in commit b007b12b40cc4a033bc06402b2e40d30fc9f3b85:

// The new guard railing
const INTERNAL_INSTANCE_PROPERTIES = new Set([
  '$columns',
  '$attributes',
  '$original',
  '$isPersisted',
  '$isDeleted',
  'modelTrx',
  // ... other internals
])
 
// Inside BaseModelImpl
if (INTERNAL_INSTANCE_PROPERTIES.has(key)) {
  return // Block the assignment
}

Before this patch, the code was essentially performing a naive loop. If you sent {"$isPersisted": true}, the model effectively believed it was already in the database. When save() is called, Lucid checks $isPersisted. If true, it runs a SQL UPDATE. If false, it runs an INSERT.

By toggling this single boolean, an attacker can force an UPDATE on a record that doesn't exist (causing errors or weird state) or, more dangerously, force an INSERT when they should be updating, potentially duplicating records or bypassing unique constraints depending on DB driver behavior.

The Exploit: Logic Inception

Let's weaponize this. Imagine a typical user registration or profile update controller. This pattern is incredibly common in tutorials and quick-start guides:

// Vulnerable Controller
public async update({ request, auth }: HttpContext) {
  const user = auth.user!
  // The developer assumes request.all() only contains form fields
  user.merge(request.all())
  await user.save()
}

The Attack Chain:

  1. Target: We want to escalate privileges or modify restricted fields (like role or subscription_status) that are usually protected by not including them in the fillable logic or by relying on decorators.
  2. The Payload: Instead of just sending username, we send a nested object targeting $attributes.
{
  "username": "hacker_elite",
  "$attributes": {
    "username": "hacker_elite",
    "role": "admin",
    "is_verified": true
  }
}
  1. The Execution:
    • Lucid iterates the keys. It sees $attributes.
    • It checks user.hasOwnProperty('$attributes'). It returns true.
    • It overwrites the model's internal data storage ($attributes) with our dictionary.
    • When user.save() is called, Lucid generates the SQL based on the contents of $attributes.

Result: The resulting SQL UPDATE statement includes role = 'admin', bypassing any safeguards that might have checked individual keys during the merge process. The attacker has effectively performed a state-level override of the object.

The Impact: Why Panic?

This isn't just about changing a username. The impact of overwriting internal state is broad and highly context-dependent.

  • Privilege Escalation: As demonstrated, overwriting $attributes directly bypasses mass-assignment protections defined on the model (like separate public vs private columns).
  • Data Corruption: Overwriting $original breaks the "dirty checking" logic. Lucid compares $attributes to $original to decide which fields to update in the SQL query. If an attacker manipulates $original, they can prevent legitimate data from being saved or trick the ORM into saving unchanged data.
  • Logic Bypass: Manipulating $isDeleted could allow operations on soft-deleted rows that the application logic assumes are gone.

This vulnerability scores an 8.2 (High) because it is network-exploitable, requires no privileges (if the registration endpoint is open), and directly impacts the integrity of the data. The only reason it's not a 10.0 is that it usually requires an authenticated context or a specific endpoint structure to trigger meaningful damage.

The Fix: Closing the Window

The remediation is straightforward but urgent. The AdonisJS team released patched versions 21.8.2 and 22.0.0-next.6. These versions include the hardcoded denylist preventing internal properties from being overwritten via mass assignment.

Immediate Steps:

  1. Update: Run npm install @adonisjs/lucid@latest immediately.
  2. Audit: Search your codebase for usages of .merge(request.all()), .fill(request.all()), or .create(request.all()).

Long-term Defense: Stop trusting request.all(). It is a relic of a more innocent web. Modern security standards demand explicit allowlisting.

  • Use Validators: Use libraries like VineJS or Zod to validate and type-cast input before it ever touches your ORM.
    // Do this:
    const payload = await validator.validate(request.all())
    user.merge(payload)
  • Use request.only(): If you must skip full validation, manually select keys.
    // At least do this:
    user.merge(request.only(['username', 'bio', 'avatar_url']))

Remember: The ORM is a tool, not a security guard. It assumes you are handing it safe materials. Don't hand it a bomb.

Fix Analysis (2)

Technical Appendix

CVSS Score
8.2/ 10
CVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:N/VC:N/VI:H/VA:N/SC:N/SI:N/SA:N

Affected Systems

@adonisjs/lucid <= 21.8.1@adonisjs/lucid >= 22.0.0-next.0, < 22.0.0-next.6

Affected Versions Detail

Product
Affected Versions
Fixed Version
@adonisjs/lucid
AdonisJS
<= 21.8.121.8.2
@adonisjs/lucid
AdonisJS
>= 22.0.0-next.0, < 22.0.0-next.622.0.0-next.6
AttributeDetail
CWE IDCWE-915 (Mass Assignment)
Attack VectorNetwork (AV:N)
CVSS Score8.2 (High)
ImpactIntegrity (High)
Exploit StatusPoC Available
KEV StatusNot Listed
CWE-915
Improperly Controlled Modification of Dynamically-Determined Object Attributes

The product allows input to modify dynamically-determined object attributes, but it does not prevent the modification of attributes that should be restricted.

Vulnerability Timeline

Vulnerability Published
2026-01-13
Patch v21.8.2 Released
2026-01-13

Subscribe to updates

Get the latest CVE analysis reports delivered to your inbox.