Back to all posts
Published on · by Renaud Deraison

It asked to be run

Sonatype is tracking 846 malicious npm packages published from hundreds of disposable accounts under AI-generated names. None of them carry an install script. The README tells you to load the package with require(), and the import runs the loader. Defenses that recognize a bad package by name, author, or declared hook are now racing a generator. Bromure Agentic Coding blocks on the one property a generator cannot forge: how old the package is.

The packages carry no install hook, so npm has nothing to catch. The README tells you to load them with require(), and the import runs the loader. More and more, the reader following that instruction is a coding agent.

Sonatype Research Labs disclosed a campaign it calls Flooding Dropper on August 5, counting 846 malicious npm components and still climbing. Paul McCarty at OpenSourceMalware tracks the payload as WEL1DROPPER. The broader coverage landed on August 7.

Two details set it apart from the npm supply-chain stories of the past month.

The first is how the packages get their names. The attacker generates them, running slopsquatting as a production line. Recurring stems like bigops and bnpl recombine with other words into names such as bigops-api and dolyame-boxy-desktop-bnpl-card-gallery, many sharing a 35.x.y version pattern. The publishing spreads across hundreds of disposable accounts carrying a handful of packages each, so there is no prolific author to ban.

No install script

The packages declare no lifecycle hooks at all, so npm install runs nothing. McCarty's write-up states it outright: "The NPM packages do not use a preinstall or postinstall script. It doesn't need one."

They ship a bundled _helpers.js that runs when the module is imported, alongside a README telling the reader to import it. Load the package with require(), says the documentation, and the documentation is correct. The first stage fingerprints the operating system and processor architecture, pulls a native second stage over HTTPS from one of three Cloudflare Workers hosts, and falls back to reassembling the binary out of base64 chunks in DNS TXT records under wel1.ru when the direct fetch fails.

The second stage varies by platform. Linux gets a UPX-packed ELF that deploys a Sliver C2 implant. macOS gets anti-debugging checks, a probe for VMware, a beacon binary, and LaunchAgent persistence. Windows gets ETW and AMSI patching, sandbox detection, reflective in-memory execution, and persistence through a Registry Run key and a scheduled task. McCarty and Sonatype both read the campaign as an evolution of "Moika," the dependency-confusion run from April that pushed 250-odd packages built around environment theft.

The attacker moved the trigger off the package manager and onto the reader.

Three ways to be unrecognizablethe name is generatedbigops-api, dolyame-boxy-desktop-bnpl-card-gallery, and ~844 morea deny list needs the name firstthe generator has more namesthe publisher is disposablehundreds of accounts, a fewpackages eachbanning the author bans fourpackages out of eight hundredthere is no install scriptno preinstall, no postinstallthe README says: require() it_helpers.js runs on importthe trigger is the readerimport runs the loaderfingerprint OS and arch, fetch a native stageCloudflare Workers, or DNS TXT under wel1.ru
The campaign is built to defeat recognition. The attacker generates names rather than choosing them, churns publishers across hundreds of disposable accounts, and moved the execution trigger off the lifecycle hook onto a README instruction, leaving no install script for a scanner to find and no stable author or name for a deny list to hold.

Automation beats the deny list

Sonatype's own summary is the sharpest line in the coverage:

Read that as a statement about defenses. Every layer that protects you by recognizing the bad package, whether by name, by publishing account, or by declared hook, now competes with a generator. Those layers still catch what they have already seen. They are graded on recognizing a specific string, and the supply of specific strings costs a script run.

The agent angle sharpens it. Slopsquatting pays in the first place because language models invent package names that sound right and do not exist, and an attacker who registers those names collects install traffic for free. Add the missing install hook and you get a package whose trigger is a written instruction in a README. A coding agent is the reader that will hallucinate the name, find a package sitting under it, read the documentation, and do what the documentation says. The campaign is shaped for the agent on both ends.

Pick a property the generator cannot forge.

Age is not a name

Every package in this campaign shares one property that no amount of automation changes: it is new. A name a model invented last Tuesday cannot have a package under it from 2019. Hundreds of disposable accounts publish hundreds of brand-new versions, because a disposable account has no history to publish from. The model depends on the package being there by the time someone reaches for it, so the attacker creates it days ahead at most.

Bromure Agentic Coding runs the agent inside a disposable Linux VM on Apple Silicon and routes every byte of its network traffic through a proxy on the host, outside the box the agent runs in. Package fetches for npm, PyPI, Cargo, RubyGems, Maven, NuGet, Go modules and Packagist all cross that proxy, and the first policy they meet is a clock.

The age gate, on by default

Bromure refuses packages younger than a minimum age, set to two days out of the box. Floating references such as latest or a semver range resolve to the newest version older than the cutoff, so ordinary installs keep working and land on yesterday's release. A pinned reference to a too-fresh version gets a 451 with a clear Bromure error naming what happened. Eight hundred packages cost as much to stop as eight, because Bromure recognizes none of them.

It runs before the argument starts

The clever part of this campaign, dropping the lifecycle hook and moving execution to require(), describes what happens after the tarball reaches disk. The age gate sits at the fetch, in the host proxy, upstream of all of it. Whether the payload would have run at install time or import time is a question about a package that never arrived. The in-VM .npmrc and pip.conf can tighten this policy and cannot loosen it.

Reputation, behind the clock

With a socket.dev key, Bromure vets each package against socket.dev's database before letting the fetch through and blocks the ones flagged as compromised, malware, or typosquats. OSV lookups block packages carrying known vulnerabilities at or above a severity you choose. Delpi re-routes every npm request to a filtering registry outright. These layers recognize things, which makes them useful and puts them second.

The README is scored as what it is

"Load this with require()" is an instruction written by a stranger, arriving in text the agent reads. Bromure scores the tool output the agent consumes, including file contents, web pages and fetched documentation, with a local prompt-injection model. It scores the instruction files the agent treats as authority, CLAUDE.md and AGENTS.md and their variants, with a dedicated classifier plus a scanner for invisible-Unicode payloads. Nothing leaves the Mac to do it.

The payload profiles the wrong machine

Suppose the fetch goes through anyway, because the package aged past the cutoff or an allowlist entry exempts it. Follow WEL1DROPPER through a Bromure profile and watch it do its job against a machine you were going to throw away.

The loader's first move is to fingerprint the host. In a Bromure profile the host it fingerprints is an Ubuntu VM on NAT, one hypervisor away from macOS, so it takes the Linux branch and plants a Sliver implant there. It never requests the macOS branch: the anti-debugging, the VMware probe, the beacon_mac.bin download, the LaunchAgent that would survive a reboot, the interest in Russian payment domains. All of it is code for a machine the dropper never saw. Erase home discards the layer it landed in and resets the home directory to its post-clone state.

Then the infostealer stage goes looking for what its Moika ancestor went looking for: the environment. In a Bromure profile the environment holds brm_… placeholders. None of the real Anthropic, OpenAI and xAI keys, the GitHub and GitLab tokens, the AWS material or the registry credentials sit in the VM's memory or its dotfiles. Bromure keeps them on the host and substitutes them into the request at the proxy, on the wire, for the one destination host each belongs to. The theft runs to completion and ships strings that resolve on one Mac, which is not the machine that ran the code.

On the developer's Macit fingerprints the hostmacOS, arm64: take the mac branchbeacon_mac.bin, LaunchAgent, reboot-safeit reads the environmentAPI keys, git credentials, cloud materialall of it live, all of it usableand it staysthe machine it owns is the one you useInside a Bromure profilethe fetch meets a clock firstpublished today, cutoff two days: 451no name matched, no signature neededand if it ever arrivesLinux in a VM, mac branch unusedthe environment holds brm_ placeholdersand it does not stayErase home resets the layer it landed in
The dropper picks its payload from the host it finds. Inside a Bromure profile that host is a disposable Linux VM, so it never requests the macOS branch with its LaunchAgent persistence, its VMware probe and its beacon binary, and the infostealer stage reads placeholder credentials whose real values never entered the VM.

The hunt they recommend, already running

McCarty closes the WEL1DROPPER write-up with defensive advice: hunt for DNS TXT queries to wel1.ru, review proxy logs for traffic to the Cloudflare Workers hosts, and look for stray temporary files and LaunchAgents.

Read that second item again. Your laptop does not keep a proxy log. A Bromure profile keeps one by construction: every request the VM makes crosses the host proxy, and the Security Log and Trace Inspector record the host, status, latency and swap report for each. Three unfamiliar *.workers.dev destinations appearing during a build are three rows on a list you can read, on the machine the build ran on, without deploying anything to get them.

Registries will keep taking these packages down, and takedowns work: 846 of them are gone or going. But the campaign is already past a thousand, published from accounts that cost nothing to make under names that cost nothing to invent, and the next wave is a script run away. Recognition is the wrong contest to enter.

A two-day-old package earns no deep trust that a two-hour-old one lacks. It sits outside the window this economy operates in, and you can move your dependency graph behind that window with a setting that is already on.

Install Bromure Agentic Coding, and let the fetch meet a clock before it meets your machine.