Never assume a file downloaded from the Internet is safe. That warning also applies to NPM, the default package manager for Node.js. A vulnerability in package install scripts would let an attacker create a self-replicating worm that can spread through NPM packages.
“It is possible for a single malicious NPM package to spread itself across most of the NPM ecosystem very quickly,” Sam Saccone, a software engineer at Google, wrote in his NPM hydra worm disclosure.
Like many other package managers, NPM supports lifecycle scripts, which can execute arbitrary commands on the system with the permissions of the current user. Though lifecycle scripts can be useful for cleaning up files after an installation, compiling binary dependencies, and automatically generating a configuration file, they can also be dangerous since the script can execute commands that modify the system.
“It is possible for a maliciously written NPM package, when installed, to execute a script that includes itself into a new package that it then publishes to the registry, and to other packages owned by that user,” according to a post on the official NPM blog. However, the team said the benefits of installation scripts outweighed the risks of a potential worm attack.
The blog post downplayed the risks, noting the implications for the package scripts were “clear from the start,” but not everyone was “fully aware” of them. Other than reiterating a handful of Saccone’s recommended workarounds, the post did not provide guidance for users concerned that the packages they are installing may not be what they are expecting.
“NPM cannot guarantee that packages available on the registry are safe,” the blog post said.
Worm spreads via package dependencies
The worm takes advantage of three NPM features: semantic versioning (semver), publishing to a centralized registry, and leaving users logged in by default. Because the user remains logged into NPM until they manually log out, any user who has logged in and is running an install, in effect, allows other modules to execute commands. Since install dependencies are not locked to a specific version, packages can push new versions, all with the ability to execute code. Finally, it is easy to ship packages to the central registry server to be installed by anyone.
The worm attack relies on social engineering to kick off the initial infection and the above-named features to continue spreading through the ecosystem.
First, the malicious author tricks an NPM module owner to install the infected package on to their system. This could be done by phishing or another malware attack. Once the package is installed, it creates a Trojanized version of the owner’s NPM module and sets a lifecycle hook to execute the worm whenever the module is installed. The modified module is published to the owner's NPM account, at which point the worm modifies all other packages in that account to call the Trojan module as a dependency. The worm publishes new versions of each package in the account with a “bugfix,” and the next time the modules are installed, the self-replicating code will be executed.
As an example, the PhoneGap project has 463 transitive dependencies, of which 276 individual NPM accounts can push new versions of those packages. It would take only one person out of the 276 to install a package containing the worm to infect everyone who’d ever installed the PhoneGap project, Saccone said.
NPM shrugs off the risks
While there is currently no fix for the vulnerability, the CERT Vulnerability Note from the United States Computer Emergency Readiness Team outlines several workarounds. They include using the
–ignore-scripts option when installing modules, locking down dependencies with NPM shrinkwrap, and encouraging users who own modules to regularly log out of NPM.
Organizations using NPM in their environments should run a local mirror of the NPM registry and prevent individual users from installing directly from the main registry. This way, organizations can regularly audit the local registry and make sure malicious files have not been inserted into the package’s dependency list.
Saccone recommended NPM expire the login tokens to force users to log in after a certain period. In the blog post, the team did not address the recommendation, but outlined other avenues they are exploring to mitigate the risk.
One such idea is to make it more difficult to publish without the module owner’s awareness, such as by requiring two-factor authentication. Another option is to work with security companies to offer vulnerability scanning for modules, but that is not available at the moment. The team currently monitors publish frequency, so a worm would be detected because it was publishing a lot of new versions, but for the most part, NPM relies on users to report suspicious packages.
“Ultimately, if a large number of users make a concerted effort to publish malicious packages to NPM, malicious packages will be available on NPM,” the blog post said.
Trust, but verify
It hasn’t been a good few days for NPM, as the vulnerability disclosure comes on the heels of the current debate on how the package manager should handle unpublished modules. A developer unpublished a small package from NPM last week and inadvertently caused many other projects who relied on that package to break. People also realized how easy it would be for someone else to register their own code with the name of the unpublished modules. Anyone who grabbed the package names would be able to install the code onto any user who had installed the original package.
There have been a number of discussions on Reddit and GitHub over the past few days discussing NPM's heavy reliance on trust -- that package maintainers will keep the packages they write, and no one is writing malicious code. In a global ecosystem, that is a dangerous assumption, because only one person needs to act against the interests of the community to break a lot of code. There must be a way to safely install NPM modules, such as using package signing or another method to verify that the code is safe and from the correct source. Until there is one, developers are left with the unsettling realization that they are taking a huge risk every time they run NPM install.