-
-
Notifications
You must be signed in to change notification settings - Fork 33k
Description
Version
v24.0.1
Platform
Linux lunar-lake 6.6.87.2-microsoft-standard-WSL2
Subsystem
No response
What steps will reproduce the bug?
The issue.js
file contains defensive code so that if this is imported before anything else it should never allow malicious code to intercept any asynchronous operation around encryption (or decryption in the real world):
const {
Function: { prototype: { apply, call } },
Promise: { prototype: { then } },
String: { fromCharCode },
Uint8Array,
crypto: { subtle },
btoa,
} = globalThis;
const bound = (_, $) => _[$].bind(_);
const applier = call.bind(apply);
const caller = call.bind(call);
const encode = buffer => btoa(applier(fromCharCode, null, new Uint8Array(buffer)));
const encoder = bound(new TextEncoder, 'encode');
const withResolvers = bound(Promise, 'withResolvers');
const randomUUID = bound(crypto, 'randomUUID');
const importKey = bound(subtle, 'importKey');
const deriveKey = bound(subtle, 'deriveKey');
const encrypt = bound(subtle, 'encrypt');
const name = 'PBKDF2';
const method = 'AES-CBC';
const iterations = 8192;
const SHA = 256;
export default (
password = randomUUID(),
iv = new Uint8Array(16),
) => {
const salt = encoder(password);
const { resolve, promise } = withResolvers();
let key;
console.log('A');
caller(
then,
importKey(
'raw',
salt,
{ name },
false,
['deriveBits', 'deriveKey']
),
_ => {
console.log('B');
// the leak!
caller(
then,
deriveKey(
{
name,
salt,
iterations,
hash: `SHA-${SHA}`
},
_,
{ name: method, length: SHA },
true,
['encrypt', 'decrypt']
),
_ => {
console.log('C');
key = _;
resolve();
},
);
},
);
// return a function that will encrypt through that key
return async value => {
// no reason to be defensive, this is `undefined`
await promise;
// now use the key to encrypt without leaking it
return caller(
then,
encrypt(
{ name: method, iv },
key,
encoder(value),
),
encode,
)
};
};
Now the test that reveals BUSTED while Bun or any browser would never reach that point because that point is never possible to reach (accordingly with the written developer intent):
import password from './issue.js';
const encrypt = password('1234567890');
// enforce environment pollution
if (true) {
const { then } = Promise.prototype;
Promise.prototype.then = function (after, ...rest) {
return then.call(this, value => {
if (value instanceof CryptoKey) console.log('⚠️ BUSTED ⚠️', value);
return after(value);
}, ...rest);
};
}
console.log(await encrypt('Hello, world!'));
// 'zaIZGmACZxAh/zBwyYm7CA=='
How often does it reproduce? Is there a required condition?
Always.
What is the expected behavior? Why is that the expected behavior?
The expected behavior is A B C zaIZGmACZxAh/zBwyYm7CA==
as the only things outputted in shell/console, which is the case for both Web browsers and Bun but in NodeJS any malicious code that overrides the global Promise.prototype.then
could retrieve a reference to the key used to encrypt and, eventually, decrypt that value, diverging from web standards specs compliance:
What do you see instead?
A
B
⚠️ BUSTED ⚠️ CryptoKey {
type: 'secret',
extractable: true,
algorithm: { name: 'AES-CBC', length: 256 },
usages: [ 'encrypt', 'decrypt' ]
}
C
The key leaks through basic Promise.prototype.then
pollution but apparently only in the deriveKey
case, although that's good enough to retrieve something that no foreign code should ever be able to retrieve because:
- the key is not held or referenced or attached to anything ever
- the key does not pass through
await
or any explicit/implicitthen
invocation - the leak is not visible/possible in browsers or other runtimes so NodeJS here is inconsistent (and leaky)
Additional information
When code is carefully written to avoid lazy poisoned environments it's very hard to test that no leaks actually happened because internally NodeJS seems to return a Promise without enforcing a non-leaky trap for that derived key so that evil patch on top of Promise.prototype.then
becomes effective.