Skip to content

Commit acffa61

Browse files
panvaRafaelGSS
authored andcommitted
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: nodejs-private/node-private#987 Reviewed-By: Anna Henningsen <anna@addaleax.net> Reviewed-By: Ruy Adorno <ruy@vlt.sh> CVE-ID: CVE-2026-106074
1 parent cc24413 commit acffa61

4 files changed

Lines changed: 172 additions & 1 deletion

File tree

‎lib/internal/crypto/random.js‎

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ const {
2121
NumberPrototypeToString,
2222
StringFromCharCodeApply,
2323
StringPrototypePadStart,
24+
TypedArrayPrototypeFill,
2425
TypedArrayPrototypeGetBuffer,
2526
TypedArrayPrototypeGetByteLength,
2627
TypedArrayPrototypeGetLength,
@@ -74,6 +75,14 @@ const {
7475

7576
const { FastBuffer } = require('internal/buffer');
7677

78+
const {
79+
namespace: {
80+
isBuildingSnapshot,
81+
},
82+
addAfterUserSerializeCallback,
83+
addBeforeUserDeserializeCallback,
84+
} = require('internal/v8/startup_snapshot');
85+
7786
const kMaxInt32 = 2 ** 31 - 1;
7887
const kMaxPossibleLength = MathMin(kMaxLength, kMaxInt32);
7988

@@ -379,6 +388,27 @@ let uuidData;
379388
let uuidNotBuffered;
380389
let uuidBatch = 0;
381390

391+
if (isBuildingSnapshot()) {
392+
const resetRandomCaches = () => {
393+
TypedArrayPrototypeFill(randomCache, 0);
394+
randomCacheOffset = randomCache.length;
395+
if (uuidData !== undefined) {
396+
TypedArrayPrototypeFill(uuidData, 0);
397+
uuidData = undefined;
398+
}
399+
if (uuidNotBuffered !== undefined) {
400+
TypedArrayPrototypeFill(uuidNotBuffered, 0);
401+
uuidNotBuffered = undefined;
402+
}
403+
uuidBatch = 0;
404+
};
405+
// Clear cached random data after user serialization callbacks.
406+
addAfterUserSerializeCallback(resetRandomCaches);
407+
// Microtasks and exit handlers can refill the caches after serialization
408+
// callbacks, so also reset them before any user deserialization callbacks.
409+
addBeforeUserDeserializeCallback(resetRandomCaches);
410+
}
411+
382412
let hexBytesCache;
383413
function getHexBytes() {
384414
if (hexBytesCache === undefined) {

‎lib/internal/v8/startup_snapshot.js‎

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,8 +35,14 @@ function throwIfBuildingSnapshot(reason) {
3535
}
3636

3737
const deserializeCallbacks = [];
38+
const beforeUserDeserializeCallbacks = [];
3839
let deserializeCallbackIsSet = false;
3940
function runDeserializeCallbacks() {
41+
for (let i = 0; i < beforeUserDeserializeCallbacks.length; i++) {
42+
const { 0: callback, 1: data } = beforeUserDeserializeCallbacks[i];
43+
callback(data);
44+
}
45+
beforeUserDeserializeCallbacks.length = 0;
4046
for (let i = 0; i < deserializeCallbacks.length; i++) {
4147
const { 0: callback, 1: data } = deserializeCallbacks[i];
4248
callback(data);
@@ -45,6 +51,14 @@ function runDeserializeCallbacks() {
4551
}
4652

4753
function addDeserializeCallback(callback, data) {
54+
addDeserializeCallbackImpl(deserializeCallbacks, callback, data);
55+
}
56+
57+
function addBeforeUserDeserializeCallback(callback, data) {
58+
addDeserializeCallbackImpl(beforeUserDeserializeCallbacks, callback, data);
59+
}
60+
61+
function addDeserializeCallbackImpl(callbacks, callback, data) {
4862
throwIfNotBuildingSnapshot();
4963
validateFunction(callback, 'callback');
5064
if (!deserializeCallbackIsSet) {
@@ -55,7 +69,7 @@ function addDeserializeCallback(callback, data) {
5569
setDeserializeCallback(runDeserializeCallbacks);
5670
deserializeCallbackIsSet = true;
5771
}
58-
deserializeCallbacks.push([callback, data]);
72+
callbacks.push([callback, data]);
5973
}
6074

6175
const serializeCallbacks = [];
@@ -130,4 +144,5 @@ module.exports = {
130144
isBuildingSnapshot,
131145
},
132146
addAfterUserSerializeCallback,
147+
addBeforeUserDeserializeCallback,
133148
};
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
'use strict';
2+
3+
const assert = require('node:assert');
4+
const {
5+
addDeserializeCallback,
6+
addSerializeCallback,
7+
setDeserializeMainFunction,
8+
} = require('node:v8').startupSnapshot;
9+
10+
// Register before loading crypto to ensure its cleanup takes precedence even
11+
// over user callbacks that were registered earlier.
12+
let deserialized;
13+
addDeserializeCallback(() => {
14+
deserialized = sample();
15+
});
16+
17+
const {
18+
randomInt,
19+
randomUUID,
20+
randomUUIDv7,
21+
} = require('node:crypto');
22+
23+
const buildValues = [];
24+
const max = 2 ** 48 - 1;
25+
26+
function primeCaches() {
27+
buildValues.push(
28+
randomUUID(),
29+
randomInt(max),
30+
randomUUID({ disableEntropyCache: true }),
31+
);
32+
}
33+
34+
switch (process.env.NODE_TEST_CRYPTO_RANDOM_MODE) {
35+
case 'uuid':
36+
primeCaches();
37+
break;
38+
case 'uuidv7':
39+
buildValues.push(
40+
randomUUIDv7(),
41+
randomInt(max),
42+
randomUUIDv7({ disableEntropyCache: true }),
43+
);
44+
break;
45+
case 'object-url':
46+
URL.revokeObjectURL(URL.createObjectURL(new Blob(['snapshot'])));
47+
break;
48+
case 'async':
49+
randomInt(max, (err, value) => {
50+
assert.ifError(err);
51+
buildValues.push(value);
52+
});
53+
break;
54+
case 'serialize':
55+
primeCaches();
56+
// A user callback can refill the caches after an earlier cleanup callback.
57+
addSerializeCallback(primeCaches);
58+
break;
59+
case 'microtask':
60+
primeCaches();
61+
addSerializeCallback(() => queueMicrotask(primeCaches));
62+
break;
63+
case 'next-tick':
64+
primeCaches();
65+
addSerializeCallback(() => process.nextTick(primeCaches));
66+
break;
67+
case 'exit':
68+
primeCaches();
69+
process.once('exit', primeCaches);
70+
break;
71+
case 'unprimed':
72+
break;
73+
default:
74+
assert.fail('Unknown cache priming mode');
75+
}
76+
77+
function sample() {
78+
return {
79+
uuid: Array.from({ length: 4 }, () => randomUUID()),
80+
// Exclude the timestamp and version so clock changes cannot hide replay.
81+
uuidv7: Array.from({ length: 4 }, () => randomUUIDv7().slice(15)),
82+
webcrypto: Array.from({ length: 4 }, () => globalThis.crypto.randomUUID()),
83+
int: Array.from({ length: 4 }, () => randomInt(max)),
84+
};
85+
}
86+
87+
setDeserializeMainFunction(async () => {
88+
const main = sample();
89+
main.asyncInt = await Promise.all(Array.from({ length: 4 }, () => new Promise((resolve, reject) => {
90+
randomInt(max, (err, value) => {
91+
if (err) reject(err);
92+
else resolve(value);
93+
});
94+
})));
95+
console.log(JSON.stringify({ buildValues, deserialized, main }));
96+
});
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
'use strict';
2+
3+
const common = require('../common');
4+
if (!common.hasCrypto)
5+
common.skip('missing crypto');
6+
7+
const assert = require('node:assert');
8+
const fixtures = require('../common/fixtures');
9+
const tmpdir = require('../common/tmpdir');
10+
const { buildSnapshot, runWithSnapshot } = require('../common/snapshot');
11+
12+
const entry = fixtures.path('snapshot', 'crypto-random-cache.js');
13+
14+
tmpdir.refresh();
15+
for (const mode of [
16+
'uuid', 'uuidv7', 'object-url', 'async', 'serialize',
17+
'microtask', 'next-tick', 'exit', 'unprimed',
18+
]) {
19+
buildSnapshot(entry, { NODE_TEST_CRYPTO_RANDOM_MODE: mode });
20+
const a = JSON.parse(runWithSnapshot().stdout);
21+
const b = JSON.parse(runWithSnapshot().stdout);
22+
23+
// Values retained by the application at build time are intentionally shared.
24+
assert.deepStrictEqual(a.buildValues, b.buildValues);
25+
for (const phase of ['deserialized', 'main']) {
26+
for (const api of Object.keys(a[phase])) {
27+
assert.notDeepStrictEqual(a[phase][api], b[phase][api], `${mode}: ${phase} ${api}`);
28+
}
29+
}
30+
}

0 commit comments

Comments
 (0)