Deno's Undead Ciphers: Breaking node:crypto with Infinite Loops
Jan 16, 2026·5 min read
Executive Summary (TL;DR)
Deno versions prior to 2.6.0 contain a critical logic error in the `node:crypto` compatibility layer. Calling `.final()` on a Cipher instance fails to nullify the internal Rust handle. This allows the object to be reused for subsequent encryption operations with the same state, leading to IV reuse and potential plaintext recovery. Update to Deno 2.6.0 immediately.
In Deno's quest for Node.js compatibility, a critical flaw in the `node:crypto` polyfill allowed cryptographic handles to survive past their intended lifespan. By failing to invalidate the internal state after `.final()`, Deno < 2.6.0 permitted 'infinite encryptions'—allowing attackers to reuse key streams and IVs, completely shattering confidentiality guarantees.
The Hook: When Compatibility Becomes a Liability
Deno is the cool kid on the block—secure by default, Rust-based, and generally intolerant of the sins of the past. But in the modern web ecosystem, you can't survive without speaking the language of the ancients: Node.js. To support the massive npm registry, Deno implements a compatibility layer for Node's built-in modules, including the notoriously complex node:crypto.
Here is the problem: node:crypto is a wrapper around OpenSSL, and Deno is backed by BoringSSL (via Rust). Bridging these two worlds requires a delicate dance of state machines, memory pointers, and garbage collection. When you create a Cipher object, you are spinning up a sensitive cryptographic operation that relies on a specific sequence of events: Init, Update, Final.
CVE-2026-22863 is what happens when that sequence loses its termination signal. It’s like firing a gun, but instead of the slide locking back, the magazine magically refills itself. Deno implemented the encryption logic but forgot to enforce the 'End of Life' for the cipher object, leaving a powerful cryptographic weapon lying around in memory, ready to be fired again by anyone who knew it wasn't actually dead.
The Flaw: The Zombie Handle
In a proper cryptographic implementation, the lifecycle of a Cipher object is finite. You feed it data, you call .final() to handle padding and flush the last block, and then the object should essentially self-destruct. The internal pointers to the cryptographic context should be nullified to prevent reuse. This isn't just best practice; it's a requirement for modes like AES-GCM or AES-CTR, where reusing a state means reusing a nonce.
In affected versions of Deno, the node:crypto implementation failed to transition the underlying Rust-backed CipherBase into a terminal state. Specifically, the JavaScript side maintained a reference to Symbol(kHandle)—the bridge to the raw BoringSSL context—even after the user explicitly signaled that they were done.
Technical analysis suggests this was exacerbated by poor error handling in the compatibility layer. If the native layer returned a specific status code or if a utility like getSystemErrorMessage was missing during the cleanup path, the code responsible for nullifying the handle would be skipped entirely. The result? A zombie object. It looks dead, the user thinks it's dead, but the internal engine is still idling, waiting for more input on the same key and IV.
The Code: Evidence of Immortality
Let's look at the smoking gun. A simple inspection of the Cipheriv object after finalization reveals the persistence of the internal state. In a patched environment, the handle should be gone. In a vulnerable one, it persists.
The Vulnerable State (Deno < 2.6.0):
import crypto from "node:crypto";
const key = crypto.randomBytes(32);
const iv = crypto.randomBytes(16);
const cipher = crypto.createCipheriv("aes-256-cbc", key, iv);
// We tell the cipher we are done.
cipher.final();
// But looking inside, the ghost remains:
console.log(cipher);
/*
Cipheriv {
_readableState: ReadableState { ... },
_writableState: WritableState { ... },
[Symbol(kHandle)]: CipherBase {} // <--- CRITICAL: The handle is still alive!
}
*/The Patched State (Deno 2.6.0+):
In the fixed version, the developers enforced a hard cleanup. The state machine explicitly disentangles the JavaScript object from the Rust resource table.
/*
Cipheriv {
_decoder: null,
[Symbol(kHandle)]: null // <--- SAFE: The handle is nuked.
}
*/The fix involved standardizing the 'host object branding' and ensuring that the internal close() method is unconditionally called and the handle set to null immediately upon finalization, regardless of the underlying stream state.
The Exploit: The Infinite Encryption Machine
Why is a zombie handle so dangerous? It allows for state reuse. If an attacker can invoke .final() multiple times, or continue writing to the stream after finalization, they can force the cipher to encrypt new data using the initial state (Key + IV).
Consider an application using AES-CTR (Counter Mode). Security in CTR mode relies entirely on the uniqueness of the Key+Counter pair. If you reuse the state, you generate the same keystream (the pseudo-random bits XORed with the plaintext).
Attack Scenario:
- Setup: The vulnerable app encrypts a secret:
Cipher.update(secret); Cipher.final(); - The Flaw: The attacker has access to the app logic (perhaps via a reused object in a persistent worker or a confused dependency injection) and grabs the 'finalized' cipher.
- The Reuse: The attacker calls
Cipher.update(known_plaintext)on the same object. - The Impact: Because the internal handle wasn't cleared, the cipher encrypts the known plaintext using the same keystream used for the secret. XORing the two ciphertexts reveals the secret.
Even in CBC mode, this allows for 'Infinite Encryptions', enabling an attacker to perform padding oracle attacks or probe the internal state with zero overhead, bypassing the need to re-initialize connections or handshake parameters.
The Fix: Nuke It From Orbit
The remediation logic introduced in Deno v2.6.0 is straightforward but vital: when a cipher is done, it must die completely. The patch explicitly nullifies [Symbol(kHandle)] and clears the _readableState and _writableState buffers.
If you are running Deno in production, check your version immediately:
deno --versionIf the output is anything less than 2.6.0, you are vulnerable. Upgrading is the only viable path. While you could theoretically monkey-patch the Cipher.prototype.final method to manually delete the handle, that is a band-aid on a bullet wound. Trust the vendor patch that fixes the Rust-side resource table management.
Official Patches
Technical Appendix
CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:H/VI:N/VA:N/SC:H/SI:N/SA:NAffected Systems
Affected Versions Detail
| Product | Affected Versions | Fixed Version |
|---|---|---|
Deno Denoland | < 2.6.0 | 2.6.0 |
| Attribute | Detail |
|---|---|
| CWE | CWE-325 (Missing Cryptographic Step) |
| CVSS v4.0 | 9.2 (Critical) |
| Attack Vector | Network |
| Impact | Confidentiality Loss / Keystream Reuse |
| Exploit Status | PoC Available |
| EPSS Score | 0.00017 (Low Probability) |
MITRE ATT&CK Mapping
The product does not perform a required step in a cryptographic algorithm, resulting in a weakening of the encryption.
Known Exploits & Detection
Vulnerability Timeline
Subscribe to updates
Get the latest CVE analysis reports delivered to your inbox.