How Two Malicious NPM Packages Targeted & Sabotaged Others.
An attacker allegedly gained access to an npm user account and published modules that broke dependents. But why?
On July 12th Harry Garrood posted a personal blog entry outlining deliberate sabotage aimed at the PureScript installer. Two separate dependencies, both owned by a user who goes by @shinnn, targeted the npm package
purescript-installer with malicious code using techniques that I've seen in exploits by other attackers. Shinnn claims his account was compromised and that these packages were published without his knowledge.
What actually happened?
@shinnn is a developer who has contributed to hundreds of packages and is part of many notable GitHub organizations like babel, node.js, and postcss. Most of these have been set to private since June.
In early June there was a falling out between the PureScript core team and @shinnn due to differing styles of communication and collaboration.
Some timeline details from Harry’s account reproduced here for continuity.
5 June — @hdgarrood emails @shinnn thanking him for the work on the
install-purescript-cli package but cites conflict and @shinnn's sole ownership as being a concern for the future. In the email @hdgarrood requests that @shinnn transfer the project to the PureScript core team. After a tense back-and-forth on a since-deleted GitHub issue, the PureScript core team adopts the source code in a new project and @shinnn deletes or makes private the original repository.
8 June — The PureScript team publishes
purescript-installer, a rebranded version of shinn’s
install-purescript-cli. The difference in names will become important later.
5 July, around 1300 UTC — @hdgarrood publishes version
purescript which depends on
8 hours later an attacker published
3.0.2 with code that causes
purescript-installer to hang by returning an object that performs no action but is designed not to throw an error. Npm has since removed version
9 July, around 0100 UTC — @doolse identifies
email@example.com as the cause.
@doolse opens an issue on
load-from-cwd-or-npm describing the problem, assumed it is a legitimate bug. @shinnn deletes the issue. He explains this here saying it was a knee-jerk reaction stemming from being upset about the original discussion on the project transfer.
9 July, around 0500 UTC — @shinnn publishes
3.0.4which does not contain any malicious code.
9 July, around 0800 UTC — An attacker publishes
1.0.3 which contains the same targeting logic but a different payload. This payload modifies a separate dependency,
dl-tar, to produce unintended behavior and then rewrites itself locally to remove traces of the malicious code. NPM has since deleted this package.
9 July, around 1100 UTC — @hdgarrood, assuming the issue is due to a legitimate bug, publishes a new version of the PureScript installer that bypasses
load-from-cwd-or-npm. This does not fix new issues due to the now compromised
9 July, around 1130 UTC — @hdgarrood discovers the malicious code in
rate-mapand reports it to npm's support email. @hdgarrood then publishes a new version of the PureScript installer with all of @shinnn’s dependencies removed.
15 July — When asked for comments, Npm responded that they consider the issue resolved with the removal of those packages despite issuing no security advisories at the time of request. Npm states that @shinnn’s account had been compromised and subsequently restored but declined to comment with any details about what, if anything, was actually restored.
Visit Harry Garrood’s post for a line by line walkthrough on the malicious code. Here I’ll go over significant points.
purescript-installer package depended on
load-from-cwd-or-npm by way of
└── load-from-cwd-or-npm <<<<<<< compromised package
load-from-cwd-or-npm to load the
request module either locally or from the npm registry.
request in a streaming API so any substituted stream would adhere to the same API. These relationships will be important.
The targeting code recursively looks at the parent module's name or directory structure to determine if it should activate its payload. If
load-from-cwd-or-npm encounters a parent whose package name ends in
"cli" or if the parent includes a directory named
.git then the library will work as normal. This allows @shinnn's own
install-purescript-cli to continue working and also prevents this from being reproducible in a common development environment. If a package other than @shinnn's own installer is using
load-from-cwd-or-npm will return a PassThrough stream instead.
Stream.PassThrough is an implementation of a node.js stream that does nothing but pass bytes through unchanged.
The end result is that the PureScript core team’s installer downloads nothing because what should have been a
request object ends up being a stream that does nothing. No errors are surfaced and the process does not continue.
firstname.lastname@example.org contains the exact same mechanism of payload activation. The payload searches for a local dependency,
dl-tar again, by way of the obfuscated code on line 26 and then rewrites
dl-tar's source to remove callbacks with a specific signature. This causes similar behavior in the previous attack and results in a broken installer.
This version also goes out of its way to delete itself locally after it has run which makes the malicious intent far more clear.
So what happened?
There is no direct evidence that @shinnn did this himself. Motive exists, @shinnn felt slighted by the PureScript team trying to take over his tool which resulted in hard truths surfacing about their relationships. The malicious payloads were both designed to protect @shinnn’s packages while sabotaging the PureScript team’s installer. @shinnn also requested the PureScript team enable 2FA as a condition of transfer yet claims he did not have 2FA enabled himself which is a critical part of the account compromise story.
This malicious code was also designed to do minimal damage and extract nothing of value. This is not representative of the vast majority of attacks. There is zero point to these attacks unless you have a personal problem with PureScript.
Why it matters.
@shinnn is a prolific developer maintaining hundreds of packages. He is a member of respected organizations (nodejs, babel, postcss and others), and submits code to high-profile projects. At best this is a user who has poor account security practices, at worst this is a user who crafted malicious code to explicitly sabotage a project out of spite. Either way trust was abused.
@shinnn’s repositories are his and any use of them is subject to his whim. The packages are open source under a very permissive license and when someone puts packages out in the wild it means they might be used by people they don’t like. There are much better ways at handling conflict than resorting to attacker tactics to sabotage others.
Npm inc’s response is disappointing. They did not issue security advisories until I asked them if there would be a post on this. They did not offer any transparency around the resolution and were not interested in exposing information that would help the community get a better understanding of what happened. I understand that Npm can’t spend time investigating every malicious package but there are basic capabilities that would enable the public to perform a better job on their own. As of this post the npm website doesn’t even list that a malicious version ever existed when you view the version details of a package. This is true for most packages that have been seen such problems.
The npm registry should start exposing public metadata on whether a package was published through an MFA flow and this should be reported by
npm audit. Depending on packages that have maintainers without MFA set up is liability. MFA is not a silver bullet but it would be a data point towards accountability. It would enable systems to ensure they are built only from packages that have been published with such safeguards in place.
Security surrounding node.js and the million plus npm packages in the registry is improving but trust is waning and much more still needs to be done.