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
This commit is contained in:
Filip Skokan authored and Antoine du Hamel committed 2026-10-21 12:57:43 +02:00
1 parent 6b88896652
commit 8c44e38dc6
4 files changed
+172 -1

No files matched your search

+30
View File
@@ -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) {
+16 -1
View File
@@ -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,
};
+96
View File
@@ -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 }));
});
@@ -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}`);
}
}
}