crypto: reset random caches across snapshots · nodejs/node@4d6990b · GitHub
Skip to content

Commit 4d6990b

Browse files
panvaaduh95
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 935918e commit 4d6990b

4 files changed

Lines changed: 172 additions & 1 deletion

File tree

‎lib/internal/crypto/random.js‎

Lines changed: 30 additions & 0 deletions

‎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

0 commit comments

Comments
 (0)