Lucid Nightmares: Hijacking Internal State in AdonisJS
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 anUPDATEvsINSERT).$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:
- Target: We want to escalate privileges or modify restricted fields (like
roleorsubscription_status) that are usually protected by not including them in thefillablelogic or by relying on decorators. - 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
}
}- The Execution:
- Lucid iterates the keys. It sees
$attributes. - It checks
user.hasOwnProperty('$attributes'). It returnstrue. - 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.
- Lucid iterates the keys. It sees
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
$attributesdirectly bypasses mass-assignment protections defined on the model (like separatepublicvsprivatecolumns). - Data Corruption: Overwriting
$originalbreaks the "dirty checking" logic. Lucid compares$attributesto$originalto 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
$isDeletedcould 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:
- Update: Run
npm install @adonisjs/lucid@latestimmediately. - 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.
Official Patches
Fix Analysis (2)
Technical Appendix
CVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:N/VC:N/VI:H/VA:N/SC:N/SI:N/SA:NAffected Systems
Affected Versions Detail
| Product | Affected Versions | Fixed Version |
|---|---|---|
@adonisjs/lucid AdonisJS | <= 21.8.1 | 21.8.2 |
@adonisjs/lucid AdonisJS | >= 22.0.0-next.0, < 22.0.0-next.6 | 22.0.0-next.6 |
| Attribute | Detail |
|---|---|
| CWE ID | CWE-915 (Mass Assignment) |
| Attack Vector | Network (AV:N) |
| CVSS Score | 8.2 (High) |
| Impact | Integrity (High) |
| Exploit Status | PoC Available |
| KEV Status | Not Listed |
MITRE ATT&CK Mapping
The product allows input to modify dynamically-determined object attributes, but it does not prevent the modification of attributes that should be restricted.
Known Exploits & Detection
Vulnerability Timeline
Subscribe to updates
Get the latest CVE analysis reports delivered to your inbox.