18 minute read

DRAFT SCAFFOLD. Every box like this one is a writing prompt for you; delete it once the section is written. Dashed boxes are screenshot slots. Before publishing: defang every URL/IP (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 / IDua-parser-jsCyberhaven
pajkjnmeojmbapicmbpliphjmcekeaac
What it does normallyParses User-Agent strings (browser, OS, device)Enterprise data-loss-prevention extension
Reach~8M weekly downloads, 1,200+ dependents~400,000 corporate users
Last clean version0.7.28 (10 Apr 2021); 0.8.0 and 1.0.0 were brand-new version lines24.10.x before 24.10.4 [verify]
Malicious version(s)0.7.29, 0.8.0, 1.0.024.10.4
Fixed version(s)0.7.30, 0.8.1, 1.0.124.10.5
Initial accessMaintainer's npm account hijackedDeveloper phished into authorising a malicious OAuth app with Chrome Web Store publish rights
Live window22 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 itUsers on GitHub (issue #536), confirmed by the maintainerCyberhaven's own security team, 23:54 UTC 25 Dec
AdvisoryGHSA-pjwm-rvh2-c87w / CVE-2021-4229; CISA alertCyberhaven incident notice; Sekoia, Obsidian, Darktrace write-ups
SHA-256 of sample[fill in after intake][fill in after intake]
Cross-check every row against at least two of the sources at the bottom before publishing, especially the "last clean version" row. Add the hashes after you run the intake script below.

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.

Screenshot 1 Timeline: the npm version history around 22 Oct 2021 next to the Cyberhaven timeline (24–26 Dec 2024). A simple two-lane diagram you draw works best.

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:

ComponentChoiceWhy
Hypervisor[VirtualBox / VMware / Proxmox]Snapshots and an isolated virtual network
Analysis VM[e.g. Ubuntu 24.04, REMnux]Static analysis tooling; no personal accounts
NetworkHost-only network + a sinkhole VMAll DNS resolves to the sinkhole, so callbacks are logged, not delivered
ToolingNode.js, jq, js-beautify, diff/diffstat, tcpdump, CyberChefRead and diff; never execute unless deliberately in the detonation snapshot
Fill in your real setup and versions. Add a screenshot of the VM's network settings showing host-only networking, so readers can see there is no NAT or bridged adapter.

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-..."
Screenshot 2 VM network settings (host-only only) and the snapshot list.

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:

  1. 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.
  2. 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.
  3. Clean baselines from the official channel. The fixed and earlier versions are still available, and they are what you diff against.
Name the exact source you used for each sample and its SHA-256, so readers can reproduce your work. If a source requires an account or approval, say so.

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:

  1. Six months of silence, then three releases in 58 seconds. Nobody hand-publishes three version lines in under a minute. That is a script.
  2. The attacker covered every way of upgrading. 0.7.29 was a patch bump, so anyone on ^0.7.x got it automatically. 0.8.0 and 1.0.0 had no earlier releases at all: they were new version lines, so a fresh npm install ua-parser-js (which takes latest) also got malware.
  3. The fixes landed about four hours later (16:16–16:26 UTC). That gap is the exposure window.
Screenshot 3 Your terminal running the timeline query, or the npm "Versions" tab for ua-parser-js.

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
Run these on the host before you isolate the VM (they are clean files), then copy them in.

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"
}
Run it on all four inputs (two clean, two malicious) and show the results. The 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.
Screenshot 4 Intake output for the clean vs. malicious npm version, showing the 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, node or 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

Typosquat, hijacked maintainer account, or a dependency added in a minor release? Show the evidence: publish time vs. previous releases, maintainer change, name similarity. Use the 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
Paste the relevant 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
Screenshot 5 The diff in your editor, highlighting the injected file or block.

Peeling the obfuscation

Walk the layers the way you did in the phishing-kit post: encoding first, then string arrays / dynamic property access, then control flow. Show short defanged before/after excerpts from the public sample, not the whole payload. Name any obfuscator you identify.
// Layer 1 (as shipped) - [paste a short defanged excerpt]
// Layer 1 (decoded) - [paste your cleaned-up version with comments]
Screenshot 6 CyberChef recipe or debugger paused on the decode routine.

What it was after and where data went

Environment variables? Specific wallets or apps? Only on certain platforms or only in CI? Describe the target selection and the exfiltration channel at the indicator level (domain, protocol, encoding), all defanged. Compare with what the vendor write-up says and note anything you found that they didn't, or vice versa.

[Your analysis]

Screenshot 7 Packet capture / sinkhole log showing the attempted outbound request (defang the host).

Teardown B: the browser extension

How the trust was abused

Ownership sale, phished developer account, or compromised build pipeline? Show the store listing history, the update that introduced the change, and how fast it rolled out via auto-update.

[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)
FieldCleanMaliciousWhy it matters
permissions[...][...][...]
host_permissions[...][...][...]
content_scripts.matches[...][...][...]
background.service_worker[...][...][...]
Screenshot 8 The manifest diff, or the store "permissions" prompt before vs. after.

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
Explain which context the injected code runs in (service worker vs. content script), what triggers it (install, browser start, visiting specific sites), and how it talks to its server. Use short defanged excerpts only.
// [paste short defanged excerpt of the injected logic, with your comments]
Screenshot 9 DevTools on the extension's service worker (lab VM only), or the diff hunk with the injected code.

What it was after and where data went

[Your analysis]

Side by side: one playbook

This is the heart of the post. Fill the table, then write 2–3 paragraphs on the pattern: what attackers exploit is the update channel and the trust users already gave.
Stagenpm packageBrowser extensionShared idea
Initial access[...][...][...]
Delivery[...][...]Silent update / install
Trigger[...][...][...]
Obfuscation[...][...][...]
Targeting[...][...][...]
Exfiltration[...][...][...]
Time to detection[...][...][...]
Who caught it[...][...][...]
Screenshot 10 Optional diagram: both kill chains drawn as parallel lanes.

Detection signals that catch both

  1. Ownership change, then a release. A new maintainer or publisher followed shortly by a new version.
  2. New execution surface in a minor/patch release. New install hooks in package.json; new permissions, host patterns or content-script matches in manifest.json.
  3. Readable code becomes obfuscated. High-entropy strings, huge single-line files, or eval/Function appearing where they never existed.
  4. New outbound destinations. Domains in the code that no previous version contacted.
  5. Diff size that doesn't match the changelog. "Bug fixes" shipping thousands of new lines.
For each signal, say whether it fired for sample A, sample B, or both, and how early it would have caught them.

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;
Run it against both sample pairs and paste the output. Tune the thresholds (line length, entropy, growth) based on what you see and explain your choices. Run it on a few benign updates of popular packages too and report false positives — that honesty makes the post stronger. Push the script to your GitHub and link it here.
Screenshot 11 Terminal output of the checker on sample A and sample B.

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_hosts to 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

Defang everything. Include hashes, package/extension IDs and versions, and network indicators. Link to the original advisory for each so readers can verify.
TypeValueSampleSource
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

Cite every advisory, vendor write-up and registry/store page you used. Starting points: GitHub Advisory Database, npm security advisories, OSV.dev, Socket / Phylum / Snyk research blogs, Chrome Web Store policy docs, and the incident write-ups for whichever samples you choose.
  1. [Source 1]
  2. [Source 2]