Skip to content

Commit 846360e

Browse files
committed
fix: prevent cross-realm symbol extraction via Object.getOwnPropertySymbols
1 parent 645bb92 commit 846360e

File tree

2 files changed

+168
-1
lines changed

2 files changed

+168
-1
lines changed

lib/setup-sandbox.js

Lines changed: 99 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,9 +25,14 @@ const {
2525
has: localReflectHas,
2626
defineProperty: localReflectDefineProperty,
2727
setPrototypeOf: localReflectSetPrototypeOf,
28-
getOwnPropertyDescriptor: localReflectGetOwnPropertyDescriptor
28+
getOwnPropertyDescriptor: localReflectGetOwnPropertyDescriptor,
29+
ownKeys: localReflectOwnKeys
2930
} = localReflect;
3031

32+
const localObjectGetOwnPropertySymbols = localObject.getOwnPropertySymbols;
33+
const localObjectGetOwnPropertyDescriptors = localObject.getOwnPropertyDescriptors;
34+
const localObjectAssign = localObject.assign;
35+
3136
const speciesSymbol = Symbol.species;
3237
const globalPromise = global.Promise;
3338
class localPromise extends globalPromise {}
@@ -64,6 +69,99 @@ Symbol.for = function(key) {
6469
return originalSymbolFor(keyStr);
6570
};
6671

72+
/*
73+
* Cross-realm symbol extraction protection
74+
*
75+
* Even with Symbol.for overridden, cross-realm symbols can be extracted from
76+
* host objects exposed to the sandbox (e.g., Buffer.prototype) via:
77+
* Object.getOwnPropertySymbols(Buffer.prototype).find(s => s.description === 'nodejs.util.inspect.custom')
78+
*
79+
* Fix: Override Object.getOwnPropertySymbols and Reflect.ownKeys to replace
80+
* dangerous cross-realm symbols with sandbox-local equivalents in results.
81+
*/
82+
const realSymbolCustomInspect = originalSymbolFor('nodejs.util.inspect.custom');
83+
const realSymbolRejection = originalSymbolFor('nodejs.rejection');
84+
85+
function isDangerousSymbol(sym) {
86+
return sym === realSymbolCustomInspect || sym === realSymbolRejection;
87+
}
88+
89+
localObject.getOwnPropertySymbols = function getOwnPropertySymbols(obj) {
90+
const symbols = apply(localObjectGetOwnPropertySymbols, localObject, [obj]);
91+
const result = [];
92+
let j = 0;
93+
for (let i = 0; i < symbols.length; i++) {
94+
if (typeof symbols[i] !== 'symbol' || !isDangerousSymbol(symbols[i])) {
95+
localReflectDefineProperty(result, j++, {
96+
__proto__: null,
97+
value: symbols[i],
98+
writable: true,
99+
enumerable: true,
100+
configurable: true
101+
});
102+
}
103+
}
104+
return result;
105+
};
106+
107+
localReflect.ownKeys = function ownKeys(obj) {
108+
const keys = apply(localReflectOwnKeys, localReflect, [obj]);
109+
const result = [];
110+
let j = 0;
111+
for (let i = 0; i < keys.length; i++) {
112+
if (typeof keys[i] !== 'symbol' || !isDangerousSymbol(keys[i])) {
113+
localReflectDefineProperty(result, j++, {
114+
__proto__: null,
115+
value: keys[i],
116+
writable: true,
117+
enumerable: true,
118+
configurable: true
119+
});
120+
}
121+
}
122+
return result;
123+
};
124+
125+
/*
126+
* Object.getOwnPropertyDescriptors uses the internal [[OwnPropertyKeys]] which
127+
* bypasses our Reflect.ownKeys override. The result object has dangerous symbols
128+
* as property keys, which can then be leaked via Object.assign/Object.defineProperties
129+
* to a Proxy whose set/defineProperty trap captures the key.
130+
*/
131+
localObject.getOwnPropertyDescriptors = function getOwnPropertyDescriptors(obj) {
132+
const descs = apply(localObjectGetOwnPropertyDescriptors, localObject, [obj]);
133+
localReflectDeleteProperty(descs, realSymbolCustomInspect);
134+
localReflectDeleteProperty(descs, realSymbolRejection);
135+
return descs;
136+
};
137+
138+
/*
139+
* Object.assign uses internal [[OwnPropertyKeys]] on source objects, bypassing our
140+
* Reflect.ownKeys override. If a source (bridge proxy) has an enumerable dangerous-symbol
141+
* property, the symbol is passed to the target's [[Set]] which could be a user Proxy trap.
142+
*/
143+
localObject.assign = function assign(target) {
144+
if (target === null || target === undefined) {
145+
throw new LocalError('Cannot convert undefined or null to object');
146+
}
147+
const to = localObject(target);
148+
for (let s = 1; s < arguments.length; s++) {
149+
const source = arguments[s];
150+
if (source === null || source === undefined) continue;
151+
const from = localObject(source);
152+
const keys = apply(localReflectOwnKeys, localReflect, [from]);
153+
for (let i = 0; i < keys.length; i++) {
154+
const key = keys[i];
155+
if (typeof key === 'symbol' && isDangerousSymbol(key)) continue;
156+
const desc = apply(localReflectGetOwnPropertyDescriptor, localReflect, [from, key]);
157+
if (desc && desc.enumerable === true) {
158+
to[key] = from[key];
159+
}
160+
}
161+
}
162+
return to;
163+
};
164+
67165
const resetPromiseSpecies = (p) => {
68166
if (p instanceof globalPromise && ![globalPromise, localPromise].includes(p.constructor[speciesSymbol])) {
69167
Object.defineProperty(p.constructor, speciesSymbol, { value: localPromise });

test/vm.js

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1334,6 +1334,75 @@ describe('VM', () => {
13341334
}
13351335
});
13361336

1337+
it('Symbol extraction via Object.getOwnPropertySymbols on host objects', () => {
1338+
// The cross-realm symbol can be obtained from host objects like Buffer.prototype
1339+
// via Object.getOwnPropertySymbols, bypassing the Symbol.for override entirely.
1340+
const vm2 = new VM();
1341+
1342+
// Attempt to extract the real cross-realm symbol from Buffer.prototype
1343+
const extractedSymbol = vm2.run(`
1344+
const symbols = Object.getOwnPropertySymbols(Buffer.prototype);
1345+
symbols.find(s => s.description === 'nodejs.util.inspect.custom');
1346+
`);
1347+
1348+
assert.strictEqual(
1349+
extractedSymbol,
1350+
undefined,
1351+
'Dangerous cross-realm symbols should be filtered from Object.getOwnPropertySymbols results'
1352+
);
1353+
1354+
// Also verify Reflect.ownKeys doesn't leak the real symbol
1355+
const extractedViaReflect = vm2.run(`
1356+
const keys = Reflect.ownKeys(Buffer.prototype);
1357+
keys.find(k => typeof k === 'symbol' && k.description === 'nodejs.util.inspect.custom');
1358+
`);
1359+
1360+
assert.strictEqual(
1361+
extractedViaReflect,
1362+
undefined,
1363+
'Dangerous cross-realm symbols should be filtered from Reflect.ownKeys results'
1364+
);
1365+
1366+
// Verify Array.prototype monkey-patching can't bypass the filter
1367+
const vm3 = new VM();
1368+
const extractedWithSplicePatch = vm3.run(`
1369+
Array.prototype.splice = function() { /* no-op */ };
1370+
const symbols2 = Object.getOwnPropertySymbols(Buffer.prototype);
1371+
symbols2.find(s => typeof s === 'symbol' && s.description === 'nodejs.util.inspect.custom');
1372+
`);
1373+
1374+
assert.strictEqual(
1375+
extractedWithSplicePatch,
1376+
undefined,
1377+
'Array.prototype.splice override should not bypass symbol filtering'
1378+
);
1379+
1380+
// Verify Object.getOwnPropertyDescriptors filters dangerous symbols from result
1381+
const vm4 = new VM();
1382+
const descs = vm4.run(`Object.getOwnPropertyDescriptors(Buffer.prototype)`);
1383+
const hostInspectSymbol = Symbol.for('nodejs.util.inspect.custom');
1384+
1385+
assert.strictEqual(
1386+
hostInspectSymbol in descs,
1387+
false,
1388+
'Object.getOwnPropertyDescriptors should not include dangerous symbol keys in result'
1389+
);
1390+
1391+
// Verify Object.assign doesn't copy dangerous symbol-keyed properties
1392+
const vm5 = new VM();
1393+
const assigned = vm5.run(`
1394+
const target = {};
1395+
Object.assign(target, Buffer.prototype);
1396+
target;
1397+
`);
1398+
1399+
assert.strictEqual(
1400+
hostInspectSymbol in assigned,
1401+
false,
1402+
'Object.assign should not copy dangerous symbol-keyed properties'
1403+
);
1404+
});
1405+
13371406
it('Function.prototype.call attack via Promise', async () => {
13381407
const vm2 = new VM();
13391408
// This attack attempts to override Function.prototype.call to capture

0 commit comments

Comments
 (0)