Same Playbook, Two Ecosystems: Dissecting a Malicious npm Package and a Hijacked Browser Extension
hxxps://evil[.]example), redact any personal data found in the samples, and remove the
noindex meta tag in the page head.
Introduction
On 22 October 2021, three new versions of ua-parser-js appeared on npm. The
library parses User-Agent strings, it was pulling in close to 8 million downloads a week,
and over a thousand other packages depended on it. For roughly four hours, anyone who installed it got a
cryptominer, and on Windows a credential stealer as well. The maintainer had not written a line of it:
someone had taken over his npm account.
On Christmas Eve 2024, an employee at the data-security company Cyberhaven approved what
looked like a routine Chrome Web Store policy request. A few hours later, version 24.10.4 of
the Cyberhaven browser extension, installed on around 400,000 corporate machines, started
rolling out through Chrome's auto-update. It was quietly collecting session cookies and access tokens
for business accounts. Again, the publisher had not written it: the attacker had been granted publishing
rights through a malicious OAuth app.
Different ecosystems, different payloads, three years apart. Yet look past the details and the moves are almost identical: take over a trusted publisher, ship through the official update channel, run automatically with the user's privileges, steal what lets you come back later, and be gone before anyone reads the diff.
This post tears both incidents apart side by side: how the attacker got in, where the code ran, how it hid, what it took, and the detection signals that would have caught both. It ends with a small, open-source checker you can drop into CI or an extension-review process.
Scope and safety. Everything here is based on public advisories and published
samples of incidents that were contained years or months ago. Indicators are defanged
(hxxps://, [.]), code excerpts are trimmed to what is needed to understand
the technique, and all analysis was done offline in an isolated lab.
TL;DR
- npm (ua-parser-js, 2021): hijacked maintainer account → three malicious versions whose script ran automatically at install time → XMRig cryptominer on Linux and Windows, plus a credential stealer on Windows → live for about 4 hours.
- Extension (Cyberhaven, 2024): phished developer approves a fake "Privacy Policy
Extensions" OAuth app → malicious update published to the Chrome Web Store → auto-update
pushes it to users → cookies and tokens for targeted services (notably Facebook Business accounts)
sent to
cyberhavenext[.]pro→ live for about a day. - Shared playbook: publisher takeover, official distribution, automatic execution, credential and session theft, a short dwell time.
- One control that would have blunted both: treat updates as untrusted code. Pin and review dependency versions, and allowlist extensions so a new version cannot quietly gain new reach.
The two samples
I picked these two because they rhyme: in both, the code came from the real publisher account, through the real distribution channel, signed and served by the platform itself. Nothing about the package name or extension ID looked off; there was no typosquat to spot. That is what makes publisher-takeover attacks harder to catch than impersonation.
| Sample A — npm | Sample B — Chrome extension | |
|---|---|---|
| Name / ID | ua-parser-js | Cyberhavenpajkjnmeojmbapicmbpliphjmcekeaac |
| What it does normally | Parses User-Agent strings (browser, OS, device) | Enterprise data-loss-prevention extension |
| Reach | ~8M weekly downloads, 1,200+ dependents | ~400,000 corporate users |
| Last clean version | 0.7.28 (10 Apr 2021); 0.8.0 and 1.0.0 were brand-new version lines | 24.10.x before 24.10.4 [verify] |
| Malicious version(s) | 0.7.29, 0.8.0, 1.0.0 | 24.10.4 |
| Fixed version(s) | 0.7.30, 0.8.1, 1.0.1 | 24.10.5 |
| Initial access | Maintainer's npm account hijacked | Developer phished into authorising a malicious OAuth app with Chrome Web Store publish rights |
| Live window | 22 Oct 2021, ~12:15 to ~16:20 GMT (about 4 hours) | Malicious update published 24 Dec 2024; exfil domain active 01:32 UTC 25 Dec to 02:50 UTC 26 Dec |
| Who caught it | Users on GitHub (issue #536), confirmed by the maintainer | Cyberhaven's own security team, 23:54 UTC 25 Dec |
| Advisory | GHSA-pjwm-rvh2-c87w / CVE-2021-4229; CISA alert | Cyberhaven incident notice; Sekoia, Obsidian, Darktrace write-ups |
| SHA-256 of sample | [fill in after intake] | [fill in after intake] |
Neither attack was a one-off. Within two weeks of ua-parser-js, the popular npm packages coa and
rc were hijacked the same way, and the Cyberhaven compromise turned out to be one of a cluster of Chrome
extensions hit by the same phishing campaign in December 2024. Researchers tied the campaign to lookalike
"Chrome Web Store" sender domains, a fake OAuth app called Privacy Policy Extensions, and
dedicated exfiltration domains for each compromised extension.
Lab setup and safe handling
Both samples steal credentials, so the ground rule is simple: nothing in the lab should be worth stealing, and nothing should be able to leave. My setup:
| Component | Choice | Why |
|---|---|---|
| Hypervisor | [VirtualBox / VMware / Proxmox] | Snapshots and an isolated virtual network |
| Analysis VM | [e.g. Ubuntu 24.04, REMnux] | Static analysis tooling; no personal accounts |
| Network | Host-only network + a sinkhole VM | All DNS resolves to the sinkhole, so callbacks are logged, not delivered |
| Tooling | Node.js, jq, js-beautify, diff/diffstat, tcpdump, CyberChef | Read and diff; never execute unless deliberately in the detonation snapshot |
Isolating the network
The sinkhole VM answers every DNS query with its own address and records what was asked. That turns any
attempted call home into a log line you can screenshot. With dnsmasq:
# /etc/dnsmasq.d/sinkhole.conf (on the sinkhole VM, 10.13.37.1)
interface=enp0s8 # the host-only adapter
no-resolv # never forward queries to a real upstream
address=/#/10.13.37.1 # every domain resolves to the sinkhole
log-queries
log-facility=/var/log/sinkhole-dns.log
# on the analysis VM: point DNS at the sinkhole and drop everything else outbound
sudo resolvectl dns enp0s8 10.13.37.1
sudo iptables -P OUTPUT DROP
sudo iptables -A OUTPUT -o lo -j ACCEPT
sudo iptables -A OUTPUT -d 10.13.37.1 -j ACCEPT
# capture whatever the sample tries, for the write-up
sudo tcpdump -i enp0s8 -w ~/cases/capture-$(date +%F).pcap
Before every risky step, take a snapshot so you can roll back to a known-clean state:
VBoxManage snapshot "analysis-vm" take "pre-intake-$(date +%F-%H%M)" --live
VBoxManage snapshot "analysis-vm" list
# after the session
VBoxManage snapshot "analysis-vm" restore "pre-intake-..."
Getting the samples
Both malicious versions were pulled from their registries long ago, so you cannot (and should not) fetch them from npm or the Chrome Web Store. There are three safe sources:
- Public malware repositories and research datasets, such as MalwareBazaar or Datadog's open malicious-packages dataset. Samples are distributed as password-protected archives, and you verify them against the SHA-256 published in advisories.
- Published diffs and excerpts in the original GitHub issue, vendor write-ups and incident reports. If you can't get a binary sample, analysing the published diff is still legitimate, as long as you say so.
- Clean baselines from the official channel. The fixed and earlier versions are still available, and they are what you diff against.
Start with the registry metadata. npm removes malicious tarballs, but it keeps the publish timestamps, and in a takeover the timeline alone tells a story:
npm view ua-parser-js time --json | jq 'with_entries(select(.key | IN("0.7.28","0.7.29","0.7.30","0.8.0","0.8.1","1.0.0","1.0.1","1.0.2")))'
{
"0.7.28": "2021-04-10T14:42:47.159Z",
"0.7.29": "2021-10-22T12:15:21.378Z",
"0.8.0": "2021-10-22T12:16:06.877Z",
"1.0.0": "2021-10-22T12:16:19.726Z",
"0.7.30": "2021-10-22T16:16:08.807Z",
"0.8.1": "2021-10-22T16:23:53.062Z",
"1.0.1": "2021-10-22T16:26:19.004Z",
"1.0.2": "2021-10-27T10:24:04.532Z"
}
Read it carefully and three things jump out:
- Six months of silence, then three releases in 58 seconds. Nobody hand-publishes three version lines in under a minute. That is a script.
- The attacker covered every way of upgrading.
0.7.29was a patch bump, so anyone on^0.7.xgot it automatically.0.8.0and1.0.0had no earlier releases at all: they were new version lines, so a freshnpm install ua-parser-js(which takeslatest) also got malware. - The fixes landed about four hours later (16:16–16:26 UTC). That gap is the exposure window.
Next, the clean baselines, downloaded as tarballs with nothing executed. These are the release before the attack and the fix after it:
mkdir -p baselines
for v in 0.7.28 0.7.30; do
npm pack "ua-parser-js@$v" --ignore-scripts --pack-destination ./baselines
done
ls baselines/
# ua-parser-js-0.7.28.tgz ua-parser-js-0.7.30.tgz
A clean copy of the extension, straight from Google's update service (this returns whatever version is live now, which will be a fixed release):
EXT_ID=pajkjnmeojmbapicmbpliphjmcekeaac
curl -sSL -o baselines/cyberhaven-current.crx \
"https://clients2.google.com/service/update2/crx?response=redirect&prodversion=130.0&acceptformat=crx2,crx3&x=id%3D${EXT_ID}%26uc"
file baselines/cyberhaven-current.crx # expect: Google Chrome extension, version 3
Case intake: hash, unpack, record
Every sample goes through the same intake script. It hashes the original file, unpacks a working copy (npm tarball or CRX), defangs nothing (that's done by hand in the write-up), and writes a small JSON record. Nothing in the sample is executed.
#!/usr/bin/env bash
# intake.sh - usage: ./intake.sh <case-name> <sample-file>
set -euo pipefail
CASE="$1"; SAMPLE="$2"
DIR="$HOME/cases/$CASE"
mkdir -p "$DIR"/{original,unpacked,notes}
cp --no-clobber "$SAMPLE" "$DIR/original/"
chmod a-x "$DIR/original/"* # samples are data, never executables
SHA256=$(sha256sum "$SAMPLE" | cut -d' ' -f1)
case "$SAMPLE" in
*.tgz)
tar -xzf "$SAMPLE" -C "$DIR/unpacked"
META="$DIR/unpacked/package/package.json"
NAME=$(jq -r .name "$META"); VERSION=$(jq -r .version "$META")
HOOKS=$(jq -c '.scripts // {} | with_entries(select(.key | test("install|prepare")))' "$META")
;;
*.crx|*.zip)
unzip -qo "$SAMPLE" -d "$DIR/unpacked" || true # CRX header triggers a harmless warning
META="$DIR/unpacked/manifest.json"
NAME=$(jq -r .name "$META"); VERSION=$(jq -r .version "$META")
HOOKS=$(jq -c '{permissions, host_permissions, background}' "$META")
;;
*) echo "unknown sample type: $SAMPLE" >&2; exit 1 ;;
esac
# file inventory with per-file hashes, for diffing later
( cd "$DIR/unpacked" && find . -type f -print0 | sort -z | xargs -0 sha256sum ) > "$DIR/notes/files.sha256"
jq -n --arg case "$CASE" --arg file "$(basename "$SAMPLE")" --arg sha256 "$SHA256" \
--arg name "$NAME" --arg version "$VERSION" --argjson exec "$HOOKS" \
--arg date "$(date -u +%FT%TZ)" \
'{case:$case, file:$file, sha256:$sha256, name:$name, version:$version, execution_surface:$exec, intake_utc:$date}' \
| tee "$DIR/notes/intake.json"
Running it against a clean baseline looks like this, and it's exactly what you'll later compare with the malicious version:
$ ./intake.sh ua-parser-0.7.28 baselines/ua-parser-js-0.7.28.tgz
{
"case": "ua-parser-0.7.28",
"file": "ua-parser-js-0.7.28.tgz",
"sha256": "416a7af001e40ea2430136873c638c8afe655a1485b62b3b9e0d7ed49535e46b",
"name": "ua-parser-js",
"version": "0.7.28",
"execution_surface": {},
"intake_utc": "2026-10-05T12:01:07Z"
}
execution_surface field is the first "aha": empty for the clean npm version, and not empty
for the malicious one. Screenshot the two side by side.
execution_surface
difference.
Handling rules I followed
- Samples stay in password-protected archives at rest, and are unpacked only inside the VM.
- No
npm install,nodeor browser load of a malicious sample outside a dedicated, disposable detonation snapshot. All static analysis is read-only. - Every URL, domain and IP is defanged in notes and in this post.
- Any personal data found in a sample or capture is redacted before screenshots.
- The VM is reverted to the clean snapshot after each session.
Teardown A: the npm package
How it got in
npm view ... time maintainers output.
[Your analysis]
Where it executes
The first thing to check in any suspicious package is whether it runs code at install time or only when imported:
# lifecycle hooks run automatically on install unless scripts are disabled
jq '.scripts | {preinstall, install, postinstall, prepare}' pkg-<version>/package/package.json
# what does the package expose when required?
jq '{main, exports, bin}' pkg-<version>/package/package.json
package.json excerpt from the malicious version and the clean
one next to it. Explain why install-time execution matters (CI runners and developer laptops hold
tokens).
// [paste package.json "scripts" excerpt from the malicious version here]
Diffing clean vs. malicious
# beautify both trees first so the diff is readable
npx js-beautify -r pkg-clean/package/**/*.js pkg-bad/package/**/*.js
diff -ruN pkg-clean/package pkg-bad/package > npm.diff
diffstat npm.diff # which files changed and by how much
Peeling the obfuscation
// Layer 1 (as shipped) - [paste a short defanged excerpt]
// Layer 1 (decoded) - [paste your cleaned-up version with comments]
What it was after and where data went
[Your analysis]
Teardown B: the browser extension
How the trust was abused
[Your analysis]
Manifest and permission changes
Comparing the manifest across versions is the fastest triage step:
diff <(jq -S . ext-clean/manifest.json) <(jq -S . ext-bad/manifest.json)
| Field | Clean | Malicious | Why it matters |
|---|---|---|---|
permissions | [...] | [...] | [...] |
host_permissions | [...] | [...] | [...] |
content_scripts.matches | [...] | [...] | [...] |
background.service_worker | [...] | [...] | [...] |
Finding the injected code
npx js-beautify -r ext-clean/**/*.js ext-bad/**/*.js
diff -ruN ext-clean ext-bad > ext.diff
# quick triage for network and dynamic-code primitives in the new version
grep -rnE "fetch\(|XMLHttpRequest|WebSocket|sendBeacon|chrome\.(cookies|webRequest|scripting)|atob\(|Function\(" ext-bad \
| grep -v node_modules
// [paste short defanged excerpt of the injected logic, with your comments]
What it was after and where data went
[Your analysis]
Side by side: one playbook
| Stage | npm package | Browser extension | Shared idea |
|---|---|---|---|
| Initial access | [...] | [...] | [...] |
| Delivery | [...] | [...] | Silent update / install |
| Trigger | [...] | [...] | [...] |
| Obfuscation | [...] | [...] | [...] |
| Targeting | [...] | [...] | [...] |
| Exfiltration | [...] | [...] | [...] |
| Time to detection | [...] | [...] | [...] |
| Who caught it | [...] | [...] | [...] |
Detection signals that catch both
- Ownership change, then a release. A new maintainer or publisher followed shortly by a new version.
- New execution surface in a minor/patch release. New install hooks in
package.json; new permissions, host patterns or content-script matches inmanifest.json. - Readable code becomes obfuscated. High-entropy strings, huge single-line files,
or
eval/Functionappearing where they never existed. - New outbound destinations. Domains in the code that no previous version contacted.
- Diff size that doesn't match the changelog. "Bug fixes" shipping thousands of new lines.
Building a version-diff checker
The signals above are mechanical enough to automate. This small Node script compares two unpacked versions (npm package or extension) and flags them. It only reads files; it never executes the sample.
// version-diff-check.js - usage: node version-diff-check.js <old-dir> <new-dir>
const fs = require("fs");
const path = require("path");
const [oldDir, newDir] = process.argv.slice(2);
const findings = [];
const flag = (signal, detail) => findings.push({ signal, detail });
const readJson = (dir, file) => {
const p = path.join(dir, file);
return fs.existsSync(p) ? JSON.parse(fs.readFileSync(p, "utf8")) : null;
};
function jsFiles(dir) {
return fs.readdirSync(dir, { withFileTypes: true }).flatMap((e) => {
const p = path.join(dir, e.name);
if (e.isDirectory()) return e.name === "node_modules" ? [] : jsFiles(p);
return /\.(c|m)?js$/.test(e.name) ? [p] : [];
});
}
// Shannon entropy of a string, in bits per character
function entropy(s) {
const counts = {};
for (const c of s) counts[c] = (counts[c] || 0) + 1;
return Object.values(counts).reduce((h, n) => h - (n / s.length) * Math.log2(n / s.length), 0);
}
// 1. npm: new lifecycle hooks
const HOOKS = ["preinstall", "install", "postinstall", "prepare"];
const oldPkg = readJson(oldDir, "package.json");
const newPkg = readJson(newDir, "package.json");
if (newPkg) {
for (const h of HOOKS) {
const before = oldPkg?.scripts?.[h];
const after = newPkg.scripts?.[h];
if (after && after !== before) flag("new-install-hook", `${h}: ${after}`);
}
}
// 2. extension: new permissions / host access / content script matches
const oldMan = readJson(oldDir, "manifest.json");
const newMan = readJson(newDir, "manifest.json");
if (newMan) {
const list = (m, key) => new Set((m?.[key] || []).flat());
const matches = (m) => new Set((m?.content_scripts || []).flatMap((c) => c.matches || []));
for (const key of ["permissions", "host_permissions", "optional_permissions"]) {
const before = list(oldMan, key);
for (const p of list(newMan, key)) if (!before.has(p)) flag("new-permission", `${key}: ${p}`);
}
const before = matches(oldMan);
for (const m of matches(newMan)) if (!before.has(m)) flag("new-content-script-match", m);
}
// 3 + 4. obfuscation markers and new URLs, per file
const urlRe = /https?:\/\/[^\s"'`)]+/g;
const oldUrls = new Set(jsFiles(oldDir).flatMap((f) => fs.readFileSync(f, "utf8").match(urlRe) || []));
for (const file of jsFiles(newDir)) {
const src = fs.readFileSync(file, "utf8");
const rel = path.relative(newDir, file);
const longest = Math.max(...src.split("\n").map((l) => l.length));
if (longest > 5000) flag("very-long-line", `${rel} (${longest} chars)`);
for (const lit of src.match(/(["'`])[A-Za-z0-9+/=]{200,}\1/g) || []) {
if (entropy(lit) > 5.2) flag("high-entropy-string", `${rel} (${lit.length} chars)`);
}
if (/\beval\s*\(|new\s+Function\s*\(/.test(src)) flag("dynamic-code", rel);
for (const u of src.match(urlRe) || []) if (!oldUrls.has(u)) flag("new-url", `${rel}: ${u}`);
}
// 5. diff size vs. old size
const size = (d) => jsFiles(d).reduce((n, f) => n + fs.statSync(f).size, 0);
const growth = size(newDir) / Math.max(1, size(oldDir));
if (growth > 1.5) flag("large-growth", `JS size grew ${growth.toFixed(1)}x`);
console.table(findings);
process.exitCode = findings.length ? 1 : 0;
Defender playbook
npm
# .npmrc - don't run third-party lifecycle scripts by default
ignore-scripts=true
# pin exact versions so updates only arrive through a reviewed lockfile change
save-exact=true
# CI: reproducible install from the lockfile, no scripts
npm ci --ignore-scripts
# verify registry signatures and provenance attestations
npm audit signatures
# known-vulnerable / known-malicious lookups
npx osv-scanner --lockfile=package-lock.json
- Commit lockfiles and review lockfile diffs in PRs like code.
- Route installs through a private registry or proxy that can block new or flagged versions.
- Keep long-lived tokens out of CI jobs that install dependencies.
- [Add anything specific your sample would have been stopped by]
Browser extensions
On managed machines, block everything and allow by ID. Chrome policy example
(/etc/opt/chrome/policies/managed/extensions.json on Linux; the same keys via GPO/Intune
on Windows):
{
"ExtensionSettings": {
"*": {
"installation_mode": "blocked",
"blocked_permissions": ["debugger", "proxy", "webRequestBlocking"]
},
"<allowed-extension-id>": {
"installation_mode": "allowed",
"runtime_blocked_hosts": ["*://*.your-bank.example"]
}
}
}
- Inventory installed extensions across the fleet and alert on new permissions after updates.
- Use
runtime_blocked_hoststo keep extensions off sensitive internal and SaaS domains. - [Add anything specific your sample would have been stopped by]
Both
- Egress monitoring: alert on developer machines or browsers contacting newly registered domains.
- An incident checklist for "a trusted dependency turned bad": identify affected versions, rotate exposed secrets, pin to a known-good version, hunt for the IOCs.
Indicators of compromise
| Type | Value | Sample | Source |
|---|---|---|---|
| SHA-256 | [...] | A | [link] |
| Package | [name@version] | A | [link] |
| Extension ID | [...] | B | [link] |
| Domain | [evil[.]example] | [A/B] | [link] |
Takeaways
- [Takeaway 1]
- [Takeaway 2]
- [Takeaway 3]
Sources
- [Source 1]
- [Source 2]