← Back

Dependency Confusion Attacks: How Attackers Hijack Your Package Manager

The Invisible Threat in Your requirements.txt

Every modern application is an iceberg. What you see — your own code — is the tip. Beneath the surface sits a sprawling graph of dependencies pulled from public registries at build time. This is where dependency confusion attacks live: in the gap between what you asked for and what your package manager actually fetches.

Dependency confusion (also called namespace confusion or substitution attacks) is a class of supply chain attack where an adversary publishes a malicious package to a public registry under the same name as a private package used internally by a target organization. If the package manager resolves public packages before private ones — or if no explicit source is configured — it installs the attacker's payload instead.

The consequences range from credential theft to persistent backdoors to full remote code execution across an organization's build fleet.


How Package Managers Resolve Dependencies

To understand the attack, you need to understand how resolution works.

When you run pip install acme-internal-sdk, pip doesn't know whether that package lives on your private Artifactory instance or PyPI. It checks wherever you've told it to look — and if you haven't been explicit, it defaults to PyPI. Most package managers follow a similar pattern:

  • pip checks PyPI by default; private indexes must be configured via --index-url or --extra-index-url
  • npm checks the public registry at registry.npmjs.org unless scoped packages or .npmrc overrides are set
  • poetry uses PyPI unless a custom source is declared in pyproject.toml

The critical vulnerability: --extra-index-url in pip does not mean "prefer this source." It means "also check this source." If a package exists at a higher version on PyPI than on your private index, pip installs the public one.


The Three Pillars of the Attack

1. Private Package Name Leakage

Attackers don't guess package names. They harvest them. Common sources of leakage include:

"Namespace Leakage Sources"

  • package.json or requirements.txt files in public GitHub repos — developers frequently commit these with internal package names
  • Error messages in CI/CD logs — a failed build that says ERROR: Could not find a version that satisfies the requirement acme-data-pipeline reveals the internal name
  • Job postings and engineering blogs — "we use an internal library called..." is more common than you'd think
  • npm audit output or lockfiles in public repositories — package-lock.json records the exact resolution path, including internal package names
  • Docker image layers — docker history on a public image can expose RUN pip install commands with internal package names

Once an attacker has a name, the attack is trivially easy to execute.

2. Higher Version Numbers in Public Registries

The version number is the exploit primitive. Package managers generally prefer the highest available version when no version pin is enforced.

"Package-Resolution-Problem"

If your internal registry serves acme-auth==1.4.2, an attacker publishes acme-auth==9.0.0 to PyPI. Next time your CI pipeline runs pip install acme-auth without a pinned version and with PyPI accessible, it installs version 9.0.0 — from the attacker.

The payload in a real attack is typically embedded in setup.py (for pip packages), install.js / preinstall hooks (for npm), or the package's __init__.py — code that runs automatically during installation, before any human reviews it.

3. Execution at Install Time

Unlike runtime vulnerabilities, the payload executes during the build — often in a highly privileged environment. Build servers routinely have access to:

  • Cloud provider credentials (AWS IAM roles, GCP service accounts)
  • Internal secrets managers
  • Source code repositories
  • Container registries
  • Production deployment keys

A single successful confusion attack can yield everything an attacker needs for lateral movement deep into your infrastructure.


Real Attack Examples

Alex Birsan (2021) — The Proof of Concept That Changed the Industry

Security researcher Alex Birsan published the seminal paper on this technique in February 2021. By identifying internal package names from public artifacts at Apple, Microsoft, Tesla, Shopify, and others, Birsan uploaded packages with matching names and versions higher than the internal ones to PyPI and npm.

The packages contained only a harmless DNS callback — a beacon that phoned home when installed. Within hours, callbacks arrived from machines inside Apple, Microsoft, PayPal, Yelp, Shopify, and 32 other companies. Many organizations had no detection for this vector whatsoever. Birsan collected over $130,000 in bug bounties.

The attack worked because none of these companies had explicitly configured their package managers to only use their private registry. They had added a private index but left the public registry accessible too.

PyTorch Supply Chain Attack (December 2022)

In late December 2022, attackers uploaded a malicious package to PyPI named torchtriton — a package that PyTorch's nightly build pipeline expected to come from their own private index. Users who installed PyTorch nightly via pip received the malicious torchtriton from PyPI instead.

The payload collected system information, SSH private keys, bash history, and AWS credentials, then exfiltrated them to an attacker-controlled server. Thousands of researchers and developers who ran the nightly build over the holiday period were exposed before PyTorch issued an emergency advisory.

IconBurst (npm, 2022)

While not a pure dependency confusion attack, IconBurst illustrates the broader package namespace problem. Attackers registered dozens of npm packages with names similar to popular packages (icon-package, ioniconsicons, fastsicons) and embedded form-hijacking JavaScript that exfiltrated user input from any application that installed them. Over 3,800 downstream applications were affected before takedowns began.


Demonstrating the Attack Surface

The following examples are for educational and defensive purposes — to help you audit your own configuration and understand what your build pipelines are actually doing.

pip

Vulnerable configuration

# This is the dangerous pattern
pip install acme-internal-sdk --extra-index-url https://your-private-registry.com/simple/

--extra-index-url tells pip to check both registries. If acme-internal-sdk exists on PyPI at version 9.0.0 and on your private registry at 1.2.0, pip installs 9.0.0.

Audit your exposure

# Check which index pip would resolve a package from
pip install --dry-run acme-internal-sdk --extra-index-url https://your-private-registry.com/simple/ -v
 
# Inspect your pip configuration
pip config list
 
# Look for extra-index-url settings that could allow substitution
cat ~/.pip/pip.conf
cat /etc/pip.conf

Safe configuration

# Use --index-url (not --extra-index-url) to replace PyPI entirely
pip install acme-internal-sdk --index-url https://your-private-registry.com/simple/
 
# Or lock to a hash in requirements.txt
# acme-internal-sdk==1.2.0 --hash=sha256:abc123...

In pip.conf or pyproject.toml, prefer:

[global]
index-url = https://your-private-registry.com/simple/
# Only add extra-index-url if you've verified namespace isolation

npm

Vulnerable configuration

npm scoped packages (@org/package) are the designed solution to this problem, but unscoped internal packages remain exposed.

# No .npmrc override — resolves against public registry
npm install acme-internal-sdk
 
# Check where npm would resolve a package
npm view acme-internal-sdk --registry https://registry.npmjs.org

Audit your exposure

# Inspect your effective npm configuration
npm config list
 
# Check .npmrc files in the project and home directory
cat .npmrc
cat ~/.npmrc
 
# List all packages in package.json that aren't scoped
node -e "
  const pkg = require('./package.json');
  const deps = {...pkg.dependencies, ...pkg.devDependencies};
  Object.keys(deps)
    .filter(name => !name.startsWith('@'))
    .forEach(name => console.log(name));
"

Safe configuration

# .npmrc — route specific unscoped packages to private registry
acme-internal-sdk:registry=https://your-private-registry.com/
# Or for all packages:
registry=https://your-private-registry.com/

Using scopes is more robust:

# Rename internal packages to use a scope
# @acme/internal-sdk instead of acme-internal-sdk
# Then in .npmrc:
@acme:registry=https://your-private-registry.com/

poetry

Vulnerable configuration

Poetry respects source priority, but the default behavior still checks PyPI unless you explicitly disable it.

# pyproject.toml — missing explicit source configuration
[tool.poetry.dependencies]
python = "^3.11"
acme-internal-sdk = "^1.2.0"
# No [[tool.poetry.source]] defined — resolves via PyPI

Audit your exposure

# Show resolved source for each package
poetry show --no-dev -v
 
# Check current source configuration
poetry config --list
 
# See what sources are declared
grep -A5 '^\[\[tool.poetry.source\]\]' pyproject.toml

Safe configuration

# pyproject.toml — explicit source with priority = "explicit"
[[tool.poetry.source]]
name = "private"
url = "https://your-private-registry.com/simple/"
priority = "explicit"  # Only check this source for packages that declare it
 
[tool.poetry.dependencies]
python = "^3.11"
acme-internal-sdk = {version = "^1.2.0", source = "private"}

The priority = "explicit" setting (introduced in Poetry 1.5) means the source is only used for packages that explicitly reference it — preventing PyPI from being checked for acme-internal-sdk at all. Use priority = "primary" if you want to replace PyPI as the default for all packages.


Detection Signals

Dependency confusion attacks are hard to detect reactively; the damage is often done before anyone notices. These signals help:

At the registry level:

  • A package on PyPI/npm that matches an internal package name but has no organizational history, no documentation, no GitHub repo, and a suspiciously high version number (≥9.0.0 is a common tell)
  • Newly published packages with install hooks (setup.py that calls subprocess, preinstall npm scripts that make network calls)

At the build level:

  • Unexpected outbound network connections from build agents during the package installation phase
  • DNS queries to unfamiliar domains originating from pip install or npm install processes
  • Package hashes that don't match your recorded lockfile — if you're not using lockfiles, start

At the credential/identity level:

  • Cloud credentials used from an unusual source IP (your build agents have known IP ranges)
  • Unusual IAM API calls immediately following a build

Mitigations

Short term:

  • Audit all internal package names against public registries right now — search PyPI and npm for any package that matches your internal naming conventions
  • Ensure every private package name is claimed (squatted) on the public registry with a benign placeholder that alerts you if someone installs it
  • Add version pins and hash verification to all dependency specifications

Medium term:

  • Configure package managers to use private registries exclusively; disable fallback to public registries for internal packages
  • Scope all internal npm packages under an organization scope (@your-org/)
  • Enforce lockfiles in all build pipelines and verify them on CI (pip-compile, poetry lock, npm ci instead of npm install)
  • Enable SBOM generation and compare resolved packages against an approved allow-list

Long term:

  • Implement a dependency proxy (Artifactory, Nexus, Google Artifact Registry) that acts as the single point of resolution for all packages, both public and private, with scanning and approval workflows
  • Monitor your private package names on public registries using registry webhooks or tools like pypi-observer
  • Establish a developer security training program that includes supply chain hygiene — specifically, never committing requirements.txt or package.json with internal package names to public repositories

Summary

Dependency confusion is elegantly simple: an attacker exploits the fact that package managers are designed to find the best version of a package, not verify its provenance. The attack surface is every internal package name that leaks into a public artifact — a commit, a log line, a job posting.

The fix is equally simple in principle: be explicit about where your packages come from. Use --index-url instead of --extra-index-url. Scope your npm packages. Declare explicit sources in poetry. Lock and hash your dependencies. Claim your internal package names on public registries before someone else does.

The organizations hit by Birsan's research in 2021 weren't negligent — they were using standard patterns that were never designed with this threat model in mind. The threat model has changed. Your configuration should too.


This article is intended for security professionals, developers, and DevOps engineers responsible for build pipeline security. All tool examples are for defensive auditing of your own infrastructure.