Dependency confusion sounds theoretical until you watch the callbacks arrive.
The opening
You do not have to break into anything. You publish a package with a name an organisation already uses internally — a name they declared in public and forgot to reserve — and you wait for their build systems to come to you.
We did not have to wait long. The engagement was research into what one client’s public code footprint gave away.
How the substitution works
Package managers resolve a dependency by name. If an organisation uses an internal package and a package of the same name also exists on the public registry, the resolver can pull the public one — especially in misconfigured CI, or when the public version number is higher, or when the private source is not authoritatively pinned.
"name": "internal-tool" is declared in an open repository.
2 · Resolver — CI resolves by name; a higher public version or misconfiguration lets the public source win.
3 · Private registry — Holds the package the organisation meant to install.
4 · Public registry — The same name is unclaimed, so anyone can take it.
Whoever controls the public name controls code that runs during installation.
The precondition is simple, and it was met here: the internal package names were visible in public manifests, and nobody had claimed those names on the public registry.
What we found and did
The exposed names
Public repositories shipped manifests declaring unscoped internal dependencies — ordinary-looking names, with real dependency lists attached, describing tooling the organisation clearly used. None of them were reserved on the public registry. They were claimable by anyone who looked.
A benign, controlled proof
We published one of those names to the public registry with a recon-only payload: a beacon that reports that the package executed, with nothing destructive and nothing persistent. Three versions went out, mirroring a realistic publishing cadence, under engagement scope.
These were real consumers, not registry crawlers.
The callbacks
This is the part that turns a theoretical finding into a confirmed one.
- Public manifest — name declared.
- Unclaimed name — registry check.
- Benign package — published by Cylent under engagement scope.
- Build machines — resolve the public package.
- Install-time beacon — callbacks received.
- Tokens and source — reachable by the build process, while the proof payload stayed benign.
The only thing separating this from a real compromise was what we chose to put in the package. No malicious payload was delivered.
Reported hostname, username, platform, architecture, internal IP, and a working directory showing the package installed under a real dependency path.
A second, independent machine resolved the package in a different region from the first.
These are real machines belonging to real people. Identifying detail has been withheld.
Had the payload been malicious, every one of those machines would have run attacker code with the privileges of the build process — inside environments that hold tokens, secrets and proprietary source. For a company that ships software to its own customers, that is a backdoor into the product itself.
Naming an internal package in public is half of publishing it.
Root cause
The manifests were public; the corresponding registry names were not reserved.
No organisational scope, which makes the name trivially claimable and the substitution trivially possible.
Nothing existed to defensively register internal names before somebody else did.
The fix is unglamorous and cheap: reserve the names, scope them to the organisation, and pin the registry so the private source is authoritative. It costs an afternoon. Not doing it costs whatever the attacker decides.
What this teaches
Dependency confusion is not hypothetical. The download counts and machine callbacks are the empirical proof: build systems will pull the public package.
Reserve namespaces you only use privately. Publish placeholders, use scoped names, and lock the registry config so the public name can never be hijacked.
Public manifests are reconnaissance material. They hand an attacker a precise list of names to squat, with dependency lists attached.
Proof beats warning. The deliverable was not that this could happen. It was hostnames and timestamps showing that it was already happening.