Defeating the npm worm
The threat
There is a security vulnerability in npm by default that enables writing a worm that can propagate to anyone doing an npm install
to a package that would contain an infected dependency (even if the dependency is deep).
This threat uses a combinaison of elements:
npm install
installs a package and all its dependencies (and deeply) by defaultnpm install --save
makes that by default, dependency versions accept any later patch version of the same packagenpm install
, by default, runs all lifecycle scripts of all dependencies and these can be arbitrary bash commandsnpm login
, by default, saves the authentication credentials in$HOME/.npmrc
.- By default, published packages are actually published without review
- Arbitrary npm lifecycle scripts bash commands have, by default, full authority over the installer computer. This includes scanning the person’s hardrive, find all their node projects, modify them to add worm propagation code as a lifecycle script and then run
npm version patch ; npm publish
.
Through the repeated annoying use of “by default”, the reader understands that if a worm is sent to npm, it will propagate because most people don’t change defaults. Point 5 listed an example of what the worm can do to propagate, but of course once you have arbitrary access to the machine, you can just encrypt their disk and ransom for a bitcoin against giving back the data.
This is serious.
NPM response and defense against a worm
NPM response has been considered weak by some. It is. It does not change any default settings, so the threat is not really being addressed.
Users should opt-in for security
npm recommands an opt-in defense which is running npm install --ignore-scripts
which disables lifecycle scripts. It is suggested elsewhere to do npm shrinkwrap
to lock down dependencies or to log out systematically after having published. These are also an opt-in.
Crowdsourced package inspection
npm cannot guarantee that packages available on the registry are safe. If you see malicious code on the registry, report it to support@npmjs.com and it will be taken down.
So, after the fact, when users machine have been infected by a worm npm takes the malicious package down? Too late, but thanks, lol!
As soon as a package is infected, people installing it will have their machines infected. npm cannot accept to be used to affect others machines. Even if npm maybe limits the propagation, malicious packages will be sent to npm.
Maybe the next person disatisfied with a name dispute resolution “owning” another heavily-dependend module will send malicious patch updates instead of just unpublishing them.
As packages will be more valuable, their author will be more likely to become targets of various attacks. Maybe someone will pay an author for the ownership of a module that’s heavily dependend on to distribute malware. This has happened with Chrome extensions, no reason this won’t happen with npm especially as long as there is no trust model for authors.
Defense against a “quick” worm
npm monitors publish frequency. A spreading worm would set off alarms within npm, and if a spreading worm were identified we could halt all publishing while infected packages were identified and taken down.
What about a patient worm? The publication frequency is exactly the same as the normal frequency and discrecy makes it hard to detect on users machines as well as hard to detect which packages are infected on npm. You can start playing the virus signature game but attack is always a step ahead of defense in this game and it’d be a massive amount of resources spent only on this problem. Blaaah…
Security by default
Software should be secure by default, not an opt-in.
People who are coming to npm today and tomorrow have missed the blogposts and tweets. They won’t opt-in, they’ll be infected.
People who reinstall node/npm will forget to opt-in. They’ll be infected.
I am sorry, but the current insecure-by-default state of npm is irresponsible. Some default needs to be changed.
But which default should be changed?
Let’s review the list above:
- Sort of the very point of
npm install
, let’s keep this default. - Accepting patch versions: Hey, super useful when a module has a security patch! Removing this default pretty much means choosing a threat against another; a choice no one should ever have to make. This default remains.
- It’s been suggested that removing lifecycle scripts would help security. Sure it does, but then you have to run the lifecycle scripts manually because they’re useful. Oh! And by the way, you’re infected by a worm if you don’t review all of them before you run them! No sure the security would be improved that much. This default remains.
- Removing this default means logging in every time. Arguably a Denial-Of-Service attack against the user (credit for the joke). There is no reason to give up usability for the sake of security.
- This one can be debated. Complicated topic. I’m on the side of keeping things as they are today. It’s like the web. Anyone can publish, no authorizations required and in any case, it’s not economically tenable to not pay for npm and expect them to review packages manually
- well… last element in the list, so I guess that’s the default I should address :-p
People I don’t necessarily trust a lot write scripts, post them as lifecycle scripts on npm and that runs on my machine. Why on Earth would these scripts have access to my entire filesystem, by default? This is an absurd amount of authority to give to random scripts downloaded from npm, who themselves tell us they “cannot guarantee that packages available on the registry are safe”.
This is a classic violation of POLA.
Quite often, we have enough context to know that things look really alarming from the outside are really not that big a deal or rather are no bigger deal than is already there and in an unfixable way in the CLI; the package script vulnerability is a good example of that. That’s just a cost of doing business with user-contributed content.
Forrest Norvell during a recent npm CLI team meeting
Red pill coming your way.
Aside on capability security
Like the joke above, I’m only parroting the words of others here.
- The Virus Safe Computing Initiative at HP Labs
- The Lazy Programmer’s Guide to Secure Computing
- Secure Distributed Programming with Object-capabilities in JavaScript (Mark S. Miller, Google)
(these talks are long, but they’re worth your time, I promise. I have others if you’re interested)
The folks in these videos have good metaphors for the state of software security. One of my favorite quote comes from Marc Stiegler:
Buffy Summers! In Season 3, her mother makes the criticism that every security person needs to pay attention to! Joyce says to Buffy: “but what’s your plan? you go out every day, you kill a bunch of bad guys and then the next day there is more bad guys. You can’t win this way. You need a plan.” And finally, Buffy in the last episode of the last season comes up with a plan: she changes the fundamental physics of her Universe to permanently favor the defender.
What could be lazier than forcing the other guy to play by your rules?
Why are we running commands that, by default, have the authority to publish npm packages on our behalf? We’re playing the attacker game. Any npm command and lifecycle script should only have the authority to do its job and no more.
But how?
Secure-by-default lifecycle scripts
The first step is defining the appropriate amount of authority that lifecycle scripts should have. What are legitimate usages of lifecycle scripts? We can start with the following list:
- build things (like compile coffeescript scripts or compile some C++ to make a C++ Node module) and put it somewhere in the project directory
(yes there is a single item, let’s have a discussion on what that list should be)
So the lifecycle script needs read-write authority over the project directory. Cool! Let’s give it only access to this specific directory and no other files!
…wait! Why does it have write authority over package.json
? Never heard of a build script that needs to modify package.json
, let’s only give read-only authority over this file and read-write over the rest.
Proof-of-concept of a how
I have a proof of concepts of this in the containednpm repo. It uses Docker because it was easy for me to write. Smarter people with more time on their hand will find more subtle solutions. The only point I’m trying to make is that it’s possible, not that my quick implementation should be used or even a reference.
In the end, what happens is that if you run npm install https://github.com/DavidBruant/harmless-worm/tarball/master --save
, what happens is:
- npm downloads the dependency
- it is saved under
node_modules
- the
postscript
script runs and modifiespackage.json
in a scary way - npm modifies
package.json
to addworm
in thedependencies
field
But when you run ./bin/containednpm install https://github.com/DavidBruant/harmless-worm/tarball/master --save
, what happens is:
- (same)
- (same)
- the
postscript
fails to editpackage.json
because it only has access to a read-only version It would also fail to read your$HOME
because it runs in a docker container and nobody gave access your$HOME
to this container - (same)
I have to mention that there is zero copy happening. The package.json
that the contained lifecycle scripts see is the actual one. The creation of the node_modules
directory happens in the right directory directly, no temporary directory, etc. None of this is magics. Docker does the heavy lifting and I’m just sitting on these shoulders.
What happens if the lifecycle encrypts the filesystem and wants to ransom for a bitcoin? It succeeds… inside the docker container which contains few valuable data (only the project you’re working on, hopefully, it’s versionned so you may not care too much losing it on this computer)… container that is discarded at the end of the ./bin/containednpm
command.
In any case, all your other projects, your browser history, your emails and your $HOME
are safe without having you to pay back for them. My pleasure.
That’s the way we can change the rules of the game permanently in favor of the defender. It’s possible to be a lot more secure when “doing business with user-contributed content”.
Aside
When I started this work and this blogpost, I was planning on only talking about the technical bits about how I smartly used a combination of docker, docker-in-docker, dynamic docker volume swap, dirty $PATH hacks based on the fragile assumption than lifecycle are started with sh -c
, etc.
But none of that matters. This is just a defense POC. It’s possible, I did it, let’s have the other more important discussion instead.
I’m happy to make another blog post to explain the technical details (they should be straightforward to anyone familiar with docker and unix command line) or maybe get in a call or answer emails.
Beyond a POC
Hey great work on the POC! I’m going to install your POC so my
npm
andnode
are safe by default!
(fictional quote)
My pleasure! This still needs more polishing but it’s good there is another secure opt-in against the worm and associated threats!
Regardless of the level of polishing, this defense will remain an opt-in and we need to change the defaults. This work needs to be merged into the official npm client to be any useful at scale. Security should not be an opt-in.
Plan of action
Security should not be an opt-in, so npm needs to be on-board otherwise, this is just yet another opt-in and does not really solve the problem.
npm needs to be on-board
- Get some form of acknowledgment from the npm folks that the current default is insecure
- Get some form of acknowledgment that npm should be secure by default
- Get some form of acknowledgment that what is being discussed in this blogpost (containing lifecycle scripts) is an option that is worth exploring
Exploration and path to becoming default
Assuming there is interest in the exploration:
- Make an initial list of legitimate authority that lifecycle scripts should have by default
- Figure out UX experience and what are the appropriate tools for implementation (currently,
bin/containednpm
uses docker and requiressudo
privilege which is absurd. There are probably other tools for containement that don’t require this and requiring everyone who wants node/npm to install Docker is a ridiculous requirement anyway) - Add that to the
npm cli
under a--safe
flag (opt-in) - Try lots of modules and notice the differences between
npm install
andnpm install --safe
. Each difference certainly means that the lifecycle scripts implicitely ask for authority that it hasn’t been granted. Decide then whether:- the lifecycle script uses legit authority => expand the default authority to cover the new use case
- the lifecycle script uses authority that it shouldn’t need => contact author for more info to figure out whether it’s legit, maybe send a PR to reduce the authority required, maybe it’s a malicious package and just remove it from the registry
- After a good year (or more? or less?) of the previous step,
npm install
becomesnpm install --unsafe
andnpm install --safe
becomes the default behavior ofnpm install
.
Bim! Secure default! And no major disruption for anyone. --unsafe
provides a fallback if you want to get wormed or ransomed, but at least, you’re opting-in for being attacked! That’s a much better news for everyone else.
I have to note that there is a cost related to having to maintain the list of default authority over time. I doubt it will be too much, but I know it cannot be zero.
Additionally, this default behavior for the CLI would act as negative incentive for anyone who’d want to publish malicious packages. If they know it won’t work for the majority of people by default, they’ll certainly try to attack something else.
Things can be better; npm, let’s talk!
ack
Thanks Romain for an early review and convincing me to pursue in the direction of a blogpost that would be less technical and more about the threat and context. Thanks Thomas for the help trying to make containednpm worm on Mac