“Move fast and break things” works until you’re the thing being broken.
In early September 2025, a phishing email hijacked the npm account of prolific maintainer Qix (Josh Junon). The attackers used it to push malicious releases of core JavaScript utilities (chalk, debug, strip-ansi, color-convert, and more). Collectively, these packages see ~2.0–2.6 billion downloads each week. The injected payload was a browser-side cryptostealer that silently rewrote wallet transactions and approval calls to attacker-controlled addresses; no creds stolen, no vaults hacked, just front-end supply chain black magic.
This isn’t a “Node.js cryptominer in devDependencies” sideshow. It’s a mass-reach, end-user funds theft vector that activates whenever a site bundles the booby-trapped versions and a user opens it with a wallet.
Who’s Qix, and why should you care?
Qix maintains (or co-maintains) some of the most transitive packages in the npm ecosystem; tiny utilities that sit everywhere in your dependency tree. When that maintainer account got phished, attackers gained publish rights to dozens of packages used by CLIs, frameworks, and production web apps.
Timeline: malicious versions dropped on Sept 8; community detection/removal happened within hours. Qix publicly acknowledged the compromise.
Bottom line: a single human with a tired week and a convincingly fake npm email can move the goal posts for half the internet. That’s not drama, that’s design!
What Happened? (The Short Version)
- Initial Access: Spear-phish spoofing npm (domain: npmjs[.]help) lured Qix to “update 2FA.” An adversary-in-the-middle grabbed credentials and the TOTP, then immediately published malicious versions.
- Impact Scope: ~20 packages poisoned, including: chalk@5.6.1, debug@4.4.2, strip-ansi@7.1.1, color-convert@3.1.1, ansi-styles@6.2.2, wrap-ansi@9.0.1, supports-color@10.2.1, ansi-regex@6.2.1, color@5.0.1, color-string@2.1.1, color-name@2.0.1, is-arrayish@0.3.3, slice-ansi@7.1.1, has-ansi@6.0.1, chalk-template@1.1.1, supports-hyperlinks@4.1.1, simple-swizzle@0.2.3, backslash@0.2.1. (Yes, it’s the transitive nightmare list.)
- Payload Behavior: On the web, the code hooked window.fetch, XMLHttpRequest, and provider APIs like window.ethereum.request, then mutated outgoing tx data to replace recipients with near-lookalikes using Levenshtein similarity (so nothing looks “off” at a glance). It targeted ETH/Solana (and a buffet of BTC/LTC/TRON address formats).
- Node vs Browser: The malware checks typeof window !== 'undefined'. On Node-only paths, it’s inert. On web bundles, it’s live ordnance. (Developers browsing their own sites weren’t safe either.)
Deep Dissection: How The Stealer Actually Wins
1) Environment gating
The payload first ensures it runs only in browsers (typeof window !== 'undefined'). This dodges server-side alarms and focuses on where the money is: the user’s wallet session.
2) Hooking layer
It wraps/monkey-patches:
- Network: fetch, XMLHttpRequest to inspect and rewrite JSON bodies/HTTP responses for payment details.
- Wallet APIs: window.ethereum.request and other provider hooks, so when dapps call eth_sendTransaction, eth_sendRawTransaction, or sign typed data, the to/data fields are rewritten pre-flight.
3) Selector targeting (ERC-20 & friends)
IoCs include targeting function selectors:
- 0x095ea7b3 (approve),
- 0xa9059cbb (transfer),
- 0x23b872dd (transferFrom),
- 0xd505accf (common in token/spender flows on EVM L2s).
This is not random; approval poisoning is a goldmine. Change a spender or route, and the user signs away control.
4) Address substitution with “looks right” optics
The stealer computes a nearest-neighbor address (Levenshtein edit distance) so the swapped destination resembles the original. Fewer raised eyebrows, more drained wallets.
5) Chain coverage
Static arrays include destination templates and hard-coded addresses for ETH, BTC (1…, bc1…), TRON (T…), BCH, LTC, Solana (even a constant Solana pubkey pattern). This was built for scale, not just Metamask.
Net effect: Your front-end renders a normal UX. The signature prompt still displays what your app asked for. But the payload mutates the call right before it leaves the browser. The wallet signs the attacker’s data. There’s no “oops” screen, just a post-hoc chain explorer surprise.
Step-By-Step: How a User Gets Drained
- Your CI picks up a malicious patch of chalk/debug/etc. You deploy. (It’s “just console coloring,” right.)
- User opens your site with a wallet installed. Payload initializes in their browser.
- User triggers an on-chain action (swap, mint, claim). Your dapp composes a legit call.
- Hook intercepts window.ethereum.request({ method: 'eth_sendTransaction', params: [...] }).
- The to address or data (spender/recipient) is silently replaced.
- Wallet pops with a normal-looking prompt (still your UI). User signs.
- Funds land where math says they should: the attacker’s wallet.
- If you rely on off-chain APIs (RPC/relayers), the network hooks can also rewrite JSON payloads in transit.
Response So Far (Community + Vendors)
- Malicious versions yanked rapidly (many within an hour of first reports). Issues were raised across affected repos (debug/chalk colored bright red).
- Public IOCs and package lists published by multiple vendors (Semgrep, Checkmarx, Aikido, StepSecurity). Semgrep even shipped a detection rule for compromised versions.
- Qix acknowledged the compromise; media and security blogs documented the phishing and payload behavior.
Impact: Real, But Bounded, Thanks To Speed
Because the community reacted fast, download counts of the poisoned versions remained relatively low compared to the packages’ total weekly volume. But “low” at this scale is still huge, and if you built & deployed inside the window, your users were fair game. The target was end-users’ wallets, not developer machines.
Detection & Hunting (What To Do Right Now?)
Search your repos / bundles for IOCs (especially if you built between Sept 8–9 UTC):
- Packages & versions listed above; scan your package-lock.json/pnpm-lock.yaml for exact matches. (Semgrep has a ready rule.)
- Strings/vars seen in samples: stealthProxyControl, runmask, newdlocal, checkethereumw, plus long arrays of BTC/LTC/ETH/SOL addresses. Presence anywhere in your built JS is a raging red flag.
- Behavioral clues in prod: unexpected overrides of window.fetch, XMLHttpRequest, window.ethereum.request. Instrument your app at runtime to assert those are native before enabling wallet flows. (If you disable when patched, you save users.)
- Wallet telemetry: sudden spikes in failed swaps, weird approvals, or incoming support tickets about “funds went to a similar address.” That’s your smoke.
Preventing The Next One (Real Fixes, Not Vibes)
Build-time controls
- Lockfiles + npm ci in CI. Never “float” at build time. Pin everything, always.
- Block unknown publishes: Require package provenance/attestations in CI (npm’s provenance via GitHub actions) and fail builds if they’re missing. (If a human’s laptop can push prod without provenance, your supply chain is cosplay.)
- Fail on surprise versions: Add policy checks that forbid major/minor jumps in critical transitive deps without approval.
- Mirror npm: Pull through a private registry/cache with curated allow-lists and time-delayed updates (quarantine new versions for 24–48h).
Publish-side controls for maintainers/orgs
- WebAuthn hardware keys only for npm; no TOTP. TOTP is phishable; hardware-bound passkeys are not (in any practical way). This attack literally rode an AitM past TOTP.
- Publish tokens with narrow scopes & expirations; rotate on every release.
- Org-enforced 2-person publish for high-blast packages. You don’t run prod with one SRE pager; don’t run the internet with one maintainer either.
Runtime/app-side tripwires (this saved users this time)
- Freeze sensitive APIs at startup (e.g., capture native fetch/XMLHttpRequest/ethereum.request and hard-assert identity before any wallet UX). If mutated, brick the flow and tell the user why.
- Client-side transaction linting: Before sending, validate to addresses and spender against an allowlist derived from your config (chainlist, verified contracts, per-feature allowlists).
- Simulate first: Run eth_call/simulation on the composed tx and diff the result after the wallet returns the signed data. Mismatch? Block and alert.
- Split origins: Serve wallet flows from a minimal, pinned, immutable origin with sub-resource integrity and no dynamic deps. Keep your marketing SPA circus on a different domain.
Org-level
- SBOM + diff gates on front-end bundles. Track exact transitive deps and fail the pipeline on new code you didn’t review.
- Attested builds: Reproducible builds + attestation → only deploy what CI produced. If it didn’t come from CI, it doesn’t exist.
- Chaos drills for supply chain: Run regular “malicious dep” game days. Measure MTTD on weird JS and MTTR to yank.
Could This Have Been Avoided?
Yes. Several ways:
- npm should make WebAuthn mandatory for high-blast maintainers and enforce publish provenance by default. TOTP is table stakes for phishers now.
- Ecosystem needs tiered controls: if your packages exceed X million weekly downloads, you don’t publish alone and you don’t publish without attestation.
- Dev teams must stop floating dependencies. “Latest” is not a strategy; it’s delegating release management to strangers.
- Dapps need runtime integrity checks. If your wallet pipeline can be monkey-patched by any third-party code in the page, you’re not building finance, you’re building theater.
Uncomfortable Truths (You Wanted Spicy, Here’s The Heat)
- The modern web is held together by unpaid volunteers guarding single-factor keys to the kingdom. Pay them, staff them, or accept that your security posture is charityware.
- “2FA everywhere” was yesterday’s win. AitM-phishable 2FA is today’s paper shield.
- Crypto UX celebrates “one-click convenience.” Attackers celebrate it, too. Every abstraction you add is another hook point.
Practical Checklist (Copy/Paste To Your Runbook)
- Audit lockfiles for:
chalk@5.6.1, debug@4.4.2, strip-ansi@7.1.1, color-convert@3.1.1, ansi-styles@6.2.2, wrap-ansi@9.0.1, supports-color@10.2.1, ansi-regex@6.2.1, color@5.0.1, color-string@2.1.1, color-name@2.0.1, is-arrayish@0.3.3, slice-ansi@7.1.1, has-ansi@6.0.1, chalk-template@1.1.1, supports-hyperlinks@4.1.1, simple-swizzle@0.2.3, backslash@0.2.1. Purge and rebuild. - Rebuild and redeploy clean bundles, invalidate caches/CDN.
- Add runtime guards for mutated fetch/XMLHttpRequest/ethereum.request.
- Simulate → Diff → Block any tx whose to/spender deviates from your allowlist.
- Enforce provenance/attestation and npm ci with pinned lockfiles.
- For maintainers: migrate to WebAuthn, kill TOTP, rotate tokens, require two-person publishes for hot packages.
Conclusion
This wasn’t an exotic zero-day. It was governance and discipline failure at internet scale: one compromised maintainer, and suddenly half of web3 is side-loading a man-in-the-browser. The payload didn’t brute-force your wallets; it borrowed your trust, politely edited your transactions, and let your users do the rest.
“Security isn’t the absence of bugs; it’s the presence of controls that assume you’ll ship one anyway.”
Patch your pipeline. Prove your builds. Guard your wallet flows like money is at stake, because it is.
Finding it hard to do all this by yourself? Reach out to us at Resonance Security; we are already doing it for many projects and can do it for yours, too.