Skip to content
System nominalUTC --:--:--Articles 080
Security 6 min read

A Malicious npm Package That Defeats `--ignore-scripts`

The indexed-btree npm package hid its loader inside BTree.prototype.set(), so it ran during normal use and no install-time control ever saw it.

Cover graphic for: A Malicious npm Package That Defeats `--ignore-scripts`

The hardening advice most JavaScript teams adopted after the 2025 and 2026 npm worms was to stop letting packages run code at install time. The indexed-btree package walked past that control by not using it. It declared no preinstall and no postinstall script, and put its loader inside BTree.prototype.set(), a method that ordinary application code calls every time it inserts a value. The malware ran when the library was used, not when it was installed, and the Cloud Security Alliance research note on the campaign makes the consequence plain: install-time controls cannot see a payload that never touches install time.

The package has been removed from npm, along with the GitHub organisation behind it. The reason to read the incident now is that the technique generalises.

The loader shipped inside a method your application calls

Checkmarx published its analysis of the campaign on 17 September 2026. The package impersonated sorted-btree, a legitimate B-tree and indexing utility, and the resemblance was close enough to be useful. Checkmarx's description of the design is direct: the package did not rely on preinstall or postinstall at all, and instead ran entirely from application code at runtime.

The trigger sat in BTree.prototype.set(). That is not an edge case or a rarely visited branch. It is the method a developer calls to put something into the data structure, which means the payload activated during expected use by a project that had done nothing unusual.

Once triggered, the loader reached for a second file, sharedLoad.min.js, which carried the obfuscated first stage of the malware. None of that involves a lifecycle hook, a Git-sourced dependency or a remote-URL tarball, which are the three things npm v12 moved to a deny-by-default posture in June 2026. A package that never uses those mechanisms is not touched by the control built to police them.

What it did once it ran

Reporting from The Hacker News and the CSA note describes the payload fingerprinting the host, collecting operating system details, hostname, CPU information, memory and system uptime. It beaconed that data to a hardcoded Slack channel and Telegram bot, which is an uncomfortable choice of infrastructure because both services are ordinary traffic on many networks.

For its second stage it used EtherHiding, storing encrypted payload components in a smart contract deployed on the Ethereum Sepolia testnet and retrieving them through the blockchain rather than through a conventional domain or IP address. The Hacker News publishes the contract address, which is useful as an indicator rather than as a curiosity. The technique was documented by Google's Mandiant and Threat Intelligence Group researchers in work on the financially motivated cluster UNC5142, and Google has since reported its use by the North Korea-linked group UNC5342. The attraction is straightforward: a public blockchain cannot be seized or taken down the way a hosting provider or registrar can act against a domain.

The malware also used an ECDH key exchange with a hardcoded X25519 public key to encrypt traffic to its second-stage infrastructure. After execution it deleted its own artifacts and removed the trigger from the package code. That cleanup step is the part with the longest consequences for anyone investigating after the fact.

Checkmarx tied indexed-btree to a set of related packages, all since removed from the registry: ordered-kv-index, btree-leaderboard, priority-slot-queue, btree-range-store, btree-core, btree-time-index, btree-lru-cache, neighbor-key-map, sliding-score-window and mutex-forge.

The download numbers in the reporting need to be read carefully, because two different figures get conflated. The CSA note records that the npm registry reported nearly two million weekly downloads for indexed-btree around the time it was pulled on approximately 3 September 2026, and states explicitly that this is a registry-reported snapshot rate rather than a cumulative total. The cumulative figure belongs to the wider family, which exceeded 5.3 million downloads over roughly eleven weeks, with btree-core alone at an estimated 1.95 million and btree-time-index above 425,000. Adding the weekly rate to the cumulative family total double counts, and the CSA note says so.

The package was first published to the registry on 18 June 2026 by an account using the handle charlessadler25. The CSA note records that the account's GitHub presence, including a commit history and profile image, looked credible rather than like a hastily assembled throwaway, and assesses that this was likely constructed to lend it an air of legitimacy. That assessment is CSA's, and no public attribution of the operators has been made. Blockchain analysis cited in the reporting puts the campaign's proceeds at approximately 109 ETH, around €231,000 at the time of reporting. The Hacker News gives the precise figure as €230,933.57.

How to check whether it ran in your tree

The packages are gone from the registry but they are not gone from lockfiles, build caches or container images, and the installation window to search is 18 June to 3 September 2026.

# Search manifests and lockfiles for the package family.
grep -rn -E "indexed-btree|btree-core|btree-time-index|btree-range-store|ordered-kv-index|btree-lru-cache|neighbor-key-map|sliding-score-window|mutex-forge|priority-slot-queue|btree-leaderboard" \
  package.json package-lock.json pnpm-lock.yaml yarn.lock 2>/dev/null

# A zero return here does not clear the host: the payload deletes its own artifacts.

Absence of evidence on disk is not evidence of absence, and this is the rare case where that phrase is a literal technical statement. The malware removed its own files after execution, so a host that was compromised in July can look clean today. The CSA note advises giving log-based and network-based indicators priority over filesystem inspection, and cross-referencing the published indicators, including the Sepolia contract address, the RPC endpoints and the Telegram bot token, against network and DNS logs.

The traffic to look for is outbound calls to public Ethereum RPC providers from build runners and developer workstations. Those hosts have little ordinary reason to talk to a blockchain node, and that traffic is what the second stage depends on.

For anything that did run the affected code, the CSA note's advice is to treat the environment as compromised and rotate the credentials reachable from it: cloud provider keys, CI/CD tokens, and any model provider or AI service API keys on the same host. The AI toolchain angle in that guidance is the same one that applies to the tensorlake compromise. Build runners aggregate cloud keys, registry tokens and model provider keys in one place, which makes them worth more than their own contents.

Why --ignore-scripts did not stop it

Blocking lifecycle scripts is still worth doing. Miasma and the earlier Shai-Hulud waves used preinstall hooks to harvest multi-cloud credentials the moment a package was installed, and npm v12's deny-by-default posture closes that path for installs that respect it. The mistake is treating the control as complete.

The CSA note's framing is that security controls change attacker behaviour rather than eliminate the underlying threat. Blocking lifecycle scripts removes one execution path; the operators moved to another. The note also records an earlier campaign that shifted its dropper out of the lifecycle script and into the package's main code when script inspection started catching it. The evidence trail points the same way twice.

The practical addition is runtime behaviour monitoring of dependency code, or at minimum a check on whether your existing tooling does any. Software composition analysis that scans for suspicious preinstall and postinstall hooks will not surface a trigger hidden in a widely called method, and static review of a package's declared scripts and manifest metadata will not either. If nothing in your stack watches what a dependency does when it runs, that is a documented gap rather than an assumed capability.

Two cheaper controls are worth pairing with it. Restricting outbound network access from build runners to known destinations would have flagged or blocked the beaconing to Slack, Telegram and the Sepolia RPC endpoints. And a minimum release age on new package versions buys the days that detection usually needs, since this campaign ran from June to September before it was identified.

The uncomfortable part of the story is that the package looked like a utility and behaved like one. It built a working B-tree. The malicious code simply travelled inside a method that did exactly what it claimed to do.

Primary sources

More from the wire