color npm package compromised

On September 8 2025, around 13:00 UTC, someone compromised Josh Junon’s npm account (qix) and started publishing backdoored versions of his package.

Someone noticed and let Josh know:

Hey. Your npm account seems to have been compromised. 1 hour ago it started posting packages with backdoors to all your popular packages.
Charlie Eriksen on BlueSky

Josh confirmed he’d gotten pwned by a fake 2FA (two-factor authentication) reset e-mail:

Yep, I've been pwned. 2FA reset email, looked very legitimate.  Only NPM affected. I've sent an email off to @npmjs.bsky.social  to see if I can get access again.  Sorry everyone, I should have paid more attention. Not like me; have had a stressful week. Will work to get this cleaned up.
Josh Junon on BlueSky

The phishing e-mail came from npmsj.help (registered 3 days prior) and claimed users had to reset their 2FA:

Hi, gix! As part of our ongoing commitment to account security, we are requesting that all users update their Two-Factor Authentication (2FA) credentials. Our records indicate that it has been over 12 months since your last 2FA update. | To maintain the security and integrity of your account, we kindly ask that you complete this update at your earliest convenience. Please note that accounts with outdated 2FA credentials will be temporarily locked starting September 10, 2025, to prevent unauthorized access. Update 2FA Now

The 2FA phishing e-mail

junon on HN

The raw email is available for the curious — it looks like it was sent via mailtrap (I’ve sent them an e-mail about it).

Over on Mastodon, Kevin Beaumont provided a list of affected packages:

 Kevin Beaumont GossiTheDog@cyberplace.social Malicious javascript compromise on npmjs.com  These packages, about a billion downloads prior  supports-hyperlinks chalk-template simple-swizzle slice-ansi error-ex is-arrayish wrap-ansi backslash color-string color-convert color color-name  Thread follows.
Kevin Beaumont on Mastodon

And pointed out the scale of the attack: color alone has ~32 million weekly downloads:

31702112 weekly downloads

The payload

The complete payload is available on pastebin.

According to initial analysis, it appears it’s not meant to be running in a server environment, or on developers’ machines (in other words, not in nodejs/bun/etc.), but in the browser.

Which would mean that for the attack to be successful:

  • Someone maintaining a crypto website/web-powered app would have to upgrade to the backdoored dependencies
  • Those dependencies would have to be used on the front-end
  • The crypto website would have had to be built, packaged, deployed
  • Users of the website would’ve had to make transactions with the drainer active

In other terms, I think that if all people did was accept a PR that bumped some dependencies, and some tests ran in CI, then nothing bad has happened, yet. But people are still figuring out exactly what the payload is supposed to do, and all the affected packages.

De-obfuscating the payload through https://obf-io.deobfuscate.io/ yields good results, see this gist.

I went a step further and did a loose port to TypeScript to understand more of what’s going on.

In short, fetch and XMLHTTPRequest are hooked so that any crypto addresses found in the response body that look like Bitcoin, Solana, Litecoin v2 etc. are modified to be one of the many addresses controlled by the attacker.

Note that only the response body is modified, not the request body. Presumably… this targets API calls that would request which address to send funds to, and do the transfer through some other means?

Additionally, every 500ms up to 50 times, window.ethereum.request is called to see if any Ethereum accounts have been authorized for use with Metamask. If so, window.ethereum is monkey-patched to alter various transactions to go attacker-controlled addresses.

In particular, it looks for:

  • approve(address,uint256) (0x095ea7b3) — replacing the destination & maxing out the amount
    • this codepath also logs the DEX name if known: Uniswap, PancakeSwap, 1inch, SushiSwap
  • permit(address,address,uint256,uint256,uint8,bytes32,bytes32) (0xd505accf) — replacing the destination & maxing out the value
  • transfer(address,uint256) (0xa9059cbb) — replacing the destination but keeping the amount
  • transferFrom(address,address,uint256) (0x23b872dd) — replacing the destination but keeping the amount

There’s a Solana codepath as well, which changes various fields to 19111111111111111111111111111111, but it’s unclear to me whether that would do anything successfully.

Current situation

Sep 8, 17:19 UTC

NPM has contacted Josh and told him they are working to the remove the packages.

See Josh’s timestamped comment

Sep 8, 17:11 UTC

Screenshot of npm showing simple-swizzle source code

see the obfuscated code starting with const _0x112fa8

npm page for simple-swizzle, code tab

The npm team seems rather unresponsive given the urgency of the situation:

It’s been almost two hours without a single email back from npm. I am sitting here struggling to figure out what to do to fix any of this. The packages that have Sindre as a co-publisher have been published over but even he isn’t able to yank the malicious versions AFAIU. If there’s any ideas on what I should be doing, I’m all ears.

HN comment

The best place to stay informed is probably Kevin’s thread on Mastodon.

(JavaScript is required to see this. Or maybe my stuff broke)

Here's another article just for you:

Rust modules vs files

A while back, I asked on Twitter what people found confusing in Rust, and one of the top topics was “how the module system maps to files”.

I remember struggling with that a lot when I first started Rust, so I’ll try to explain it in a way that makes sense to me.

Important note

All that follows is written for Rust 2021 edition. I have no interest in learning (or teaching) the ins and outs of the previous version, especially because it was a lot more confusing to me.