Between approximately 12:15 UTC and 16:20 UTC on October 22, 2021, three malicious versions of ua-parser-js — 0.7.29, 0.8.0, and 1.0.0 — were published to the npm registry from a compromised maintainer account. The package had roughly 8 million weekly downloads at the time and was a transitive dependency of Facebook's React Native, Microsoft Azure SDK packages, and thousands of enterprise applications. The malicious versions shipped two payloads: XMRig cryptomining binaries for Linux, macOS, and Windows, and (on Windows) a DanaBot-adjacent credential stealer named jsextension.exe that exfiltrated browser credentials, system identifiers, and cryptowallets to citationsherbe[.]at. CISA issued Alert AA21-295A within 24 hours. The maintainer, Faisal Salman, had reported unusual password reset emails to his personal account the same morning. This post walks through the attack chain, the npm primitives that made it feasible, and what the ecosystem changed in response.
How was the maintainer account compromised?
The maintainer account was compromised through credential reuse combined with the absence of npm MFA enforcement at the time. Faisal Salman's postmortem described that attackers gained access to his npm account shortly after a wave of password-reset email attempts and published trojanized versions within minutes. npm did not require MFA for publishing in October 2021 — it was opt-in, and Salman had not enabled it, matching the industry profile at the time where fewer than 10% of top-100 package maintainers had MFA enabled. The attacker did not need to bypass 2FA because there was none to bypass. Once authenticated, publishing a new version requires only npm publish with the current credentials. Three versions (spanning the 0.7.x, 0.8.x, and 1.0.0 lines) were pushed to maximize the chance of matching any version range pinned by downstream consumers.
What did the malicious payloads actually do?
The malicious payloads did two things: mined Monero using XMRig and, on Windows, stole credentials via a DanaBot variant. The preinstall.js script added to the package fetched architecture-appropriate binaries from https://citationsherbe[.]at and executed them silently. On Linux, it wrote jsextension (the XMRig miner) to /tmp and ran it. On Windows, it additionally dropped jsextension.exe which queried browser login databases (Chrome, Edge, Firefox), enumerated cryptocurrency wallet files (wallet.dat, Exodus, Electrum), and POSTed the collected data to the C2. The miner was throttled so CPU impact was noticeable but not immediately alarming on shared servers. The credential stealer was the payload with real consequence, and anyone who installed a malicious version on a developer machine between 12:15 and 16:20 UTC that day had to treat every browser-stored credential and local wallet as compromised.
{
"scripts": {
"preinstall": "start /B node preinstall.js & node preinstall.js"
}
}
That one-line diff from the legitimate package.json was the entire malware delivery mechanism. npm executes preinstall scripts by default during npm install, without prompting, without sandboxing, and without any content review.
Why was a package with 8 million weekly downloads unsigned and unreviewed?
The package was unsigned and unreviewed because the npm registry, like most package registries in 2021, operated on a trust-the-maintainer model with no content review for existing packages. npm had no native signing of published tarballs — a package version was what the maintainer's authenticated session pushed, nothing more. There was no reproducibility check, no binary-to-source correlation, and no mandatory code review for packages above a download threshold. Package ecosystems simply did not scale human review, and no automated signing infrastructure existed for them. This was not a ua-parser-js-specific failure; it was the baseline state of every major registry. event-stream (2018), eslint-scope (2018), and coa/rc (November 2021, three weeks later) were the same defect expressing itself.
How did the ecosystem detect and respond in four hours?
Detection came from a Reddit post by a user noticing unexpected CPU activity after an npm install, cross-referenced by an npm security engineer to the package's recent versions. npm pulled 0.7.29, 0.8.0, and 1.0.0 from the registry within 30 minutes of confirmed detection and published replacement versions 0.7.30, 0.8.1, and 1.0.1 that reverted the payload. Salman rotated his credentials and enabled MFA. CISA Alert AA21-295A went live the same day with indicators: citationsherbe[.]at, SHA-256 hashes for the payloads, and remediation guidance. Cloud providers and EDR vendors updated detections. For organizations that had pulled a malicious version into a build cache or runtime during the window, the recommended response was credential rotation plus full endpoint reimaging, which is expensive and rare — most teams settled for targeted credential rotation and hoped.
What structural npm changes followed?
npm accelerated three structural changes following this and the related coa/rc hijacks weeks later. First, mandatory 2FA for top-100 maintainers was rolled out in 2022 and expanded in 2023 to any package with over 500 weekly downloads by default. Second, npm introduced provenance — a GitHub Actions-integrated signing workflow that attests a package was built from a specific commit in a specific public repo, verifiable at install time with npm install --provenance. Third, npm audit was updated to correlate maintainer account hygiene as a risk signal, and sigstore integration became the standard path for package attestation. None of these were mandatory in October 2021; all became defaults or near-defaults afterward. The industry also responded with tools that scan install scripts for outbound network calls, binary downloads, and other preinstall anomalies.
What should teams do today to prevent repeat exposure?
Teams should pin exact versions, use npm ci with a committed lockfile, require provenance where available, and deny install scripts by default. A concrete policy:
# Reproducible install; no install scripts unless explicitly allowed
npm ci --ignore-scripts
# Verify provenance for packages that publish it
npm audit signatures
# CI: allow scripts only for known packages
npm config set ignore-scripts true
Beyond tooling, mirror critical dependencies through a private registry that applies a quarantine window (commonly 72 hours) before promoting new versions into the internal feed. Quarantine would have caught ua-parser-js before 99% of downstream pulls happened. Combine that with SBOM generation at build time and a runtime allowlist for outbound network destinations from CI, and an attack of this exact shape becomes contained to a small development blast radius rather than a global event.
How Safeguard Helps
Safeguard catches ua-parser-js-class events as they happen by continuously diffing your declared dependencies against compromised package feeds and anomalous maintainer-account signals. Reachability analysis tells you whether the affected version is actually loaded by running code — most React Native projects pulled ua-parser-js transitively but never exercised it — so response scope shrinks from "everything" to a precise target list. Griffin AI drafts incident timelines, IOC correlations, and customer communications in minutes rather than hours. SBOM comparisons between pre- and post-incident builds surface exactly which artifacts shipped with the trojanized version. Policy gates can enforce a 72-hour quarantine on any new npm version and block merges that would pull unquarantined releases into production.