Commit 8c44e38dc6c for nodejs
commit 8c44e38dc6c7cf3a8d058439479acd3446d2b9dd
Author: Filip Skokan <panva.ip@gmail.com>
Date: Fri Sep 25 18:29:36 2026 +0200
crypto: reset random caches across snapshots
Startup snapshots retain unused randomInt() and UUID cache entries,
causing deserialized processes to replay the same random values.
Clear the caches after user serialization callbacks and before user
deserialization callbacks. The latter also handles caches refilled by
microtasks, next ticks, or exit handlers after serialization callbacks.
Refs: https://hackerone.com/reports/4059814
Signed-off-by: Filip Skokan <panva.ip@gmail.com>
Assisted-by: Codex
PR-URL: https://github.com/nodejs-private/node-private/pull/987
Reviewed-By: Anna Henningsen <anna@addaleax.net>
Reviewed-By: Ruy Adorno <ruy@vlt.sh>
CVE-ID: CVE-2026-106074
diff --git a/lib/internal/crypto/random.js b/lib/internal/crypto/random.js
index 0dc03e236a0..d13faf2d75d 100644
--- a/lib/internal/crypto/random.js
+++ b/lib/internal/crypto/random.js
@@ -21,6 +21,7 @@ const {
NumberPrototypeToString,
StringFromCharCodeApply,
StringPrototypePadStart,
+ TypedArrayPrototypeFill,
TypedArrayPrototypeGetBuffer,
TypedArrayPrototypeGetByteLength,
TypedArrayPrototypeGetLength,
@@ -75,6 +76,14 @@ const {
const { FastBuffer } = require('internal/buffer');
+const {
+ namespace: {
+ isBuildingSnapshot,
+ },
+ addAfterUserSerializeCallback,
+ addBeforeUserDeserializeCallback,
+} = require('internal/v8/startup_snapshot');
+
const kMaxInt32 = 2 ** 31 - 1;
const kMaxPossibleLength = MathMin(kMaxLength, kMaxInt32);
@@ -388,6 +397,27 @@ let uuidData;
let uuidNotBuffered;
let uuidBatch = 0;
+if (isBuildingSnapshot()) {
+ const resetRandomCaches = () => {
+ TypedArrayPrototypeFill(randomCache, 0);
+ randomCacheOffset = randomCache.length;
+ if (uuidData !== undefined) {
+ TypedArrayPrototypeFill(uuidData, 0);
+ uuidData = undefined;
+ }
+ if (uuidNotBuffered !== undefined) {
+ TypedArrayPrototypeFill(uuidNotBuffered, 0);
+ uuidNotBuffered = undefined;
+ }
+ uuidBatch = 0;
+ };
+ // Clear cached random data after user serialization callbacks.
+ addAfterUserSerializeCallback(resetRandomCaches);
+ // Microtasks and exit handlers can refill the caches after serialization
+ // callbacks, so also reset them before any user deserialization callbacks.
+ addBeforeUserDeserializeCallback(resetRandomCaches);
+}
+
let hexBytesCache;
function getHexBytes() {
if (hexBytesCache === undefined) {
diff --git a/lib/internal/v8/startup_snapshot.js b/lib/internal/v8/startup_snapshot.js
index 27836f2ca7c..8b57d324506 100644
--- a/lib/internal/v8/startup_snapshot.js
+++ b/lib/internal/v8/startup_snapshot.js
@@ -35,8 +35,14 @@ function throwIfBuildingSnapshot(reason) {
}
const deserializeCallbacks = [];
+const beforeUserDeserializeCallbacks = [];
let deserializeCallbackIsSet = false;
function runDeserializeCallbacks() {
+ for (let i = 0; i < beforeUserDeserializeCallbacks.length; i++) {
+ const { 0: callback, 1: data } = beforeUserDeserializeCallbacks[i];
+ callback(data);
+ }
+ beforeUserDeserializeCallbacks.length = 0;
for (let i = 0; i < deserializeCallbacks.length; i++) {
const { 0: callback, 1: data } = deserializeCallbacks[i];
callback(data);
@@ -45,6 +51,14 @@ function runDeserializeCallbacks() {
}
function addDeserializeCallback(callback, data) {
+ addDeserializeCallbackImpl(deserializeCallbacks, callback, data);
+}
+
+function addBeforeUserDeserializeCallback(callback, data) {
+ addDeserializeCallbackImpl(beforeUserDeserializeCallbacks, callback, data);
+}
+
+function addDeserializeCallbackImpl(callbacks, callback, data) {
throwIfNotBuildingSnapshot();
validateFunction(callback, 'callback');
if (!deserializeCallbackIsSet) {
@@ -55,7 +69,7 @@ function addDeserializeCallback(callback, data) {
setDeserializeCallback(runDeserializeCallbacks);
deserializeCallbackIsSet = true;
}
- deserializeCallbacks.push([callback, data]);
+ callbacks.push([callback, data]);
}
const serializeCallbacks = [];
@@ -130,4 +144,5 @@ module.exports = {
isBuildingSnapshot,
},
addAfterUserSerializeCallback,
+ addBeforeUserDeserializeCallback,
};
diff --git a/test/fixtures/snapshot/crypto-random-cache.js b/test/fixtures/snapshot/crypto-random-cache.js
new file mode 100644
index 00000000000..838a159c55b
--- /dev/null
+++ b/test/fixtures/snapshot/crypto-random-cache.js
@@ -0,0 +1,96 @@
+'use strict';
+
+const assert = require('node:assert');
+const {
+ addDeserializeCallback,
+ addSerializeCallback,
+ setDeserializeMainFunction,
+} = require('node:v8').startupSnapshot;
+
+// Register before loading crypto to ensure its cleanup takes precedence even
+// over user callbacks that were registered earlier.
+let deserialized;
+addDeserializeCallback(() => {
+ deserialized = sample();
+});
+
+const {
+ randomInt,
+ randomUUID,
+ randomUUIDv7,
+} = require('node:crypto');
+
+const buildValues = [];
+const max = 2 ** 48 - 1;
+
+function primeCaches() {
+ buildValues.push(
+ randomUUID(),
+ randomInt(max),
+ randomUUID({ disableEntropyCache: true }),
+ );
+}
+
+switch (process.env.NODE_TEST_CRYPTO_RANDOM_MODE) {
+ case 'uuid':
+ primeCaches();
+ break;
+ case 'uuidv7':
+ buildValues.push(
+ randomUUIDv7(),
+ randomInt(max),
+ randomUUIDv7({ disableEntropyCache: true }),
+ );
+ break;
+ case 'object-url':
+ URL.revokeObjectURL(URL.createObjectURL(new Blob(['snapshot'])));
+ break;
+ case 'async':
+ randomInt(max, (err, value) => {
+ assert.ifError(err);
+ buildValues.push(value);
+ });
+ break;
+ case 'serialize':
+ primeCaches();
+ // A user callback can refill the caches after an earlier cleanup callback.
+ addSerializeCallback(primeCaches);
+ break;
+ case 'microtask':
+ primeCaches();
+ addSerializeCallback(() => queueMicrotask(primeCaches));
+ break;
+ case 'next-tick':
+ primeCaches();
+ addSerializeCallback(() => process.nextTick(primeCaches));
+ break;
+ case 'exit':
+ primeCaches();
+ process.once('exit', primeCaches);
+ break;
+ case 'unprimed':
+ break;
+ default:
+ assert.fail('Unknown cache priming mode');
+}
+
+function sample() {
+ return {
+ uuid: Array.from({ length: 4 }, () => randomUUID()),
+ // Exclude the timestamp and version so clock changes cannot hide replay.
+ uuidv7: Array.from({ length: 4 }, () => randomUUIDv7().slice(15)),
+ webcrypto: Array.from({ length: 4 }, () => globalThis.crypto.randomUUID()),
+ int: Array.from({ length: 4 }, () => randomInt(max)),
+ };
+}
+
+setDeserializeMainFunction(async () => {
+ const main = sample();
+ main.asyncInt = await Promise.all(Array.from({ length: 4 }, () => new Promise((resolve, reject) => {
+ randomInt(max, (err, value) => {
+ if (err) reject(err);
+ else resolve(value);
+ });
+ })));
+ console.log(JSON.stringify({ buildValues, deserialized, main }));
+});
diff --git a/test/parallel/test-crypto-random-cache-snapshot.js b/test/parallel/test-crypto-random-cache-snapshot.js
new file mode 100644
index 00000000000..201d040896b
--- /dev/null
+++ b/test/parallel/test-crypto-random-cache-snapshot.js
@@ -0,0 +1,30 @@
+'use strict';
+
+const common = require('../common');
+if (!common.hasCrypto)
+ common.skip('missing crypto');
+
+const assert = require('node:assert');
+const fixtures = require('../common/fixtures');
+const tmpdir = require('../common/tmpdir');
+const { buildSnapshot, runWithSnapshot } = require('../common/snapshot');
+
+const entry = fixtures.path('snapshot', 'crypto-random-cache.js');
+
+tmpdir.refresh();
+for (const mode of [
+ 'uuid', 'uuidv7', 'object-url', 'async', 'serialize',
+ 'microtask', 'next-tick', 'exit', 'unprimed',
+]) {
+ buildSnapshot(entry, { NODE_TEST_CRYPTO_RANDOM_MODE: mode });
+ const a = JSON.parse(runWithSnapshot().stdout);
+ const b = JSON.parse(runWithSnapshot().stdout);
+
+ // Values retained by the application at build time are intentionally shared.
+ assert.deepStrictEqual(a.buildValues, b.buildValues);
+ for (const phase of ['deserialized', 'main']) {
+ for (const api of Object.keys(a[phase])) {
+ assert.notDeepStrictEqual(a[phase][api], b[phase][api], `${mode}: ${phase} ${api}`);
+ }
+ }
+}