Your company has an internal package called @acme/auth-utils. It lives in your private registry, nowhere on the public internet — except its name leaked once, in a package.json committed to a public repo three years ago by someone who’s since left the company. That’s all an attacker needs. They publish @acme/auth-utils version 99.9.9 to the public npm registry today, and the next CI build that resolves dependencies the wrong way installs their code instead of yours.

TL;DR

  • Dependency confusion exploits how package managers choose between a private package and a public one with the identical name: many default to whichever has the higher version number, regardless of source.
  • The technique isn’t theoretical — Alex Birsan’s original 2021 research earned $130,000+ in bounties by executing code inside 35 companies total — including Apple, Microsoft, PayPal, Tesla, and Uber — this way, with zero social engineering involved.
  • It’s still active: Microsoft documented a 45-package campaign in May 2026 using organization-scoped names that mirror real internal namespaces at Russian financial and tech firms, deployed in a “reconnaissance-only” mode that can be flipped remotely to full exploitation.
  • Internal package names leak constantly — package.json, requirements.txt, error messages, CI logs, and public repos from ex-employees all expose them without anyone intending to.
  • The fix is largely free and configuration-only: claim your namespace on public registries, pin scopes to specific registries, and stop letting resolvers fall back to “whichever version is highest.”

Why This Matters to You

If your organization uses any private package registry — internal npm scope, private PyPI index, internal Maven repository — alongside public package managers, you likely have this exposure right now, and it costs nothing to check. This isn’t a sophisticated zero-day; it’s a resolution-order bug in how nearly every language ecosystem was designed, and it’s been public knowledge since 2021 without most organizations fixing it.

Table of Contents


How It Works

Most organizations run a hybrid setup: some packages come from a private, internal registry; most come from the public registry (npmjs.org, PyPI, RubyGems, Maven Central). The package manager needs a rule for what to do when a name could resolve from either source.

npm: if a project is configured to check multiple registries or scopes aren’t properly locked to a specific source, an attacker who registers a public package under your organization’s scope with a higher version number can win resolution.

pip: this is the more dangerous default. When both --index-url (your private index) and --extra-index-url (public PyPI) are configured, pip checks all configured indexes and installs whichever has the highest version number — not whichever index was listed first. A private package sitting at version 0.4.2 loses instantly to a public impostor published at 99.0.0.

1. Attacker finds an internal package name (leaked via
package.json, requirements.txt, error logs, ex-employee's
public repos, or job postings mentioning internal tools)
2. Attacker registers that exact name on the public registry
(npm, PyPI, RubyGems, Maven Central...)
3. Attacker sets an inflated version number (99.0.0, 100.100.100)
and attaches a postinstall/setup.py hook
4. Any developer laptop or CI runner that resolves dependencies
without strict source pinning pulls the attacker's package
5. The hook executes automatically at install time — no user
interaction, no click, no download of a "suspicious file"

No phishing, no credential theft, no exploit chain. Just a name collision and a version number.


The Original Research: $130K Across 35 Companies

Security researcher Alex Birsan formalized the technique in February 2021 by scanning package.json, requirements.txt, and error messages leaked in public repositories to harvest internal package names from major companies. He then registered identically-named packages on public npm, PyPI, and RubyGems with inflated version numbers and a preinstall/postinstall hook that phoned home with hostname, username, and working directory — proof of code execution, nothing destructive.

The result: confirmed code execution inside Apple, Microsoft, PayPal, Netflix, Tesla, Uber, and 29 others — 35 companies in total — for over $130,000 in bug bounty payouts. At Microsoft, internal npm packages were squatted successfully; at Apple, internal Python packages executed on Apple’s own CI infrastructure. No company in the study had a name-collision defense in place, despite this being a resolution-order problem baked into the package managers themselves rather than a bug specific to any one vendor.


Still Active in 2026: The May Campaign

This isn’t a solved problem from 2021 — it’s an active technique. Microsoft’s threat intelligence team documented a 45-package campaign across three publishing waves on May 28–29, 2026, registered under organization-scoped npm names deliberately chosen to mirror real internal corporate namespaces: @cloudplatform-single-spa, @wb-track, @payments-widget, @capibar.chat (spoofing an internal UI kit), and @sber-ecom-core (directly impersonating Sberbank’s SberPay payment widget) — indicating deliberate targeting of Russian financial and technology sector internal tooling.

What makes this campaign notable beyond scale: the postinstall payload shipped in a “reconnaissance-only” mode, gated by a hardcoded RECON_ONLY=1 flag that a remote server can toggle to enable full exploitation later. In the meantime, it silently fingerprints the host (OS, Node.js version, installed packages, CI/CD environment variables), harvests environment variables likely to contain tokens or secrets, and — critically — detects CI environment variables and aborts execution there, choosing to stay quiet on automated build infrastructure while collecting data from real developer workstations instead. That’s a deliberate trade-off: CI systems are more likely to have security monitoring; developer laptops are softer targets that still often hold cloud credentials, SSH keys, and access to the same internal systems.

Attribution evidence pointed to a single operator: all 45 packages beaconed to the same C2 domain, shared an identical hardcoded HTTP secret header, and were published from accounts using matching Node.js/npm tooling versions — with only a 12-minute gap between publishing bursts, consistent with one person switching accounts manually.


How Internal Package Names Leak

Dependency confusion only works if the attacker can guess or discover your internal package names, but that turns out to be easy far more often than it should be:

  • Public package.json / requirements.txt / pom.xml files in open-source projects that reference internal dependencies, sometimes accidentally left in after a repo went public
  • Error messages and stack traces posted to public forums (Stack Overflow, GitHub issues) that reveal module paths and import names
  • Ex-employee personal repositories that still contain old project files referencing former employers’ internal package names
  • Job postings and conference talks that name internal tools and frameworks by name
  • CI/CD logs accidentally made public (misconfigured GitHub Actions artifacts, exposed Jenkins instances) that show full dependency resolution output
  • npm/PyPI search itself — if a private scope name is guessable from your company name or product branding, an attacker doesn’t need a leak at all, just a reasonable guess

Testing If You’re Exposed

You can check for the most basic version of this exposure without any special tooling — just compare your internal manifest against what’s already public:

Terminal window
# For each internal package name in your manifest, check if a
# public package with the same name already exists.
# If any of these return a result, you have a name collision
# that dependency confusion can exploit today.
for pkg in $(jq -r '.dependencies | keys[]' package.json); do
npm view "$pkg" version 2>/dev/null && echo "COLLISION: $pkg exists publicly"
done
Terminal window
# Same idea for Python — checks each requirement name against PyPI
for pkg in $(cut -d= -f1 requirements.txt); do
pip index versions "$pkg" 2>/dev/null && echo "Check: $pkg is public"
done

A hit doesn’t automatically mean you’re compromised — it means the precondition for the attack exists. Whether it’s actually exploitable depends on your resolver configuration, which is the part worth fixing regardless of what these checks find.


What the Registries Themselves Have Done

The major public registries have shipped partial mitigations since Birsan’s 2021 research, but none of them fully close the gap on their own:

  • npm supports scoped packages (@yourorg/package) where only the verified owner of the scope can publish under it — the single most effective structural fix, but only if you’ve actually claimed the scope.
  • PyPI doesn’t have first-class namespace reservation the way npm’s scopes do, which is part of why pip’s “highest version wins” default remains the more commonly exploited path in 2026 campaigns.
  • Sonatype, Snyk, and Socket.dev offer namespace confusion detection as part of broader supply chain monitoring products, flagging suspicious new packages that closely match private naming patterns pulled from your SBOM.

None of these substitute for owning your namespace and pinning your resolver configuration — they reduce the odds an attacker gets there first, not the underlying resolution-order weakness itself.


MITRE ATT&CK Mapping

TacticTechniqueID
Initial AccessSupply Chain Compromise: Compromise Software Dependencies and Development ToolsT1195.001
DiscoverySystem Information DiscoveryT1082
Stealth / DiscoveryVirtualization/Sandbox EvasionT1497
Credential AccessUnsecured Credentials: Credentials In FilesT1552.001

What You Can Do Today

  1. Claim your namespace on every public registry you use, even if you never publish publicly. Registering @yourorg on npm and reserving your internal package names on PyPI costs nothing and closes the door outright — an attacker can’t squat a name you already own.
  2. Pin scopes to specific registries, don’t let resolvers fall back. In npm, configure .npmrc with explicit @yourorg:registry=https://your-private-registry so scoped packages can only resolve from your internal source. In pip, avoid combining --index-url and --extra-index-url for anything private — use --index-url exclusively pointed at your internal index for internal packages, or a tool that enforces source pinning per package.
  3. Never let version number alone decide the winner. If your tooling supports it, configure explicit source-of-truth per package rather than “highest version wins” resolution — this is the exact mechanism the entire attack class abuses.
  4. Audit what’s currently installed for name collisions. Compare your internal package list against what’s registered publicly under matching names right now — free and takes minutes with npm view <name> or pip index versions <name> for a scripted check across your dependency manifest.
  5. Treat CI/CD dependency resolution as a security boundary, not just a build step. Log and alert on any dependency install that resolves from an unexpected registry, and consider network egress restrictions on build runners so a malicious postinstall script can’t reach an external C2 even if it executes.
  6. Scrub internal package names from anything that goes public — sanitize example configs, redact real package names from public documentation and job postings when you can, and treat an ex-employee’s old public repos referencing your stack as a minor but real exposure worth being aware of.
  7. If you’re running your own private registry, verify it takes precedence unconditionally, not just by version comparison — some registry proxy configurations still silently defer to the public upstream for “convenience,” which quietly reintroduces the exact vulnerability you thought you’d fixed.


Sources