Skip to content

specs-compliance-issue: crypto.subtle.deriveKey implicit key leak on global Promise pollution #59699

@WebReflection

Description

@WebReflection

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/implicit then 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions