evil-pkg
v1.0.5
Published
PoC: PATH poisoning via node_modules/.bin - untrusted packages can shadow system commands in Bun's package manager
Readme
evil-pkg - Self-Triggering PATH Poisoning PoC for Bun
This is a proof of concept for a PATH poisoning vulnerability in bun install. Starting with v1.0.4, no victim scripts are needed. A bare bun i evil-pkg in an empty project achieves RCE.
What changed in v1.0.4
Previous versions (<=1.0.3) required the victim project to have its own postinstall script that calls node. That limited the attack to projects with lifecycle scripts.
v1.0.4 brings its own trigger. It declares esbuild as a dependency. Bun maintains a default-trusted-dependencies list of ~367 packages whose lifecycle scripts run automatically without --trust or trustedDependencies. esbuild is on that list, and its postinstall runs node install.js.
The attack chain:
- Victim runs
bun i evil-pkg(or evil-pkg is a transitive dependency) - Bun resolves evil-pkg + esbuild
- Bun creates
node_modules/.bin/node→../evil-pkg/shim.js(from thebinfield) - Bun sees esbuild is default-trusted, so it runs esbuild's
postinstall: "node install.js" - PATH has
node_modules/.binprepended →noderesolves to the attacker's shim - Attacker code executes with the victim's full privileges
No victim scripts. No trustedDependencies. No --trust flag. Just: bun i evil-pkg.
Why it happens
Bun creates node_modules/.bin/<name> symlinks for every package that declares a bin field. These symlinks are created during extraction, before any scripts run. The trust system never looks at them.
Later, when any lifecycle script executes, Bun walks up from the package directory and prepends every node_modules/.bin it finds to PATH. If an attacker's package declared "bin": {"node": "./payload"}, then node_modules/.bin/node points to attacker code. When a trusted package's postinstall calls node, the shell resolves it from .bin/ first.
Four design gaps chain together:
normalized_bin_name()insrc/install/bin.rsstrips path separators and rejects.and... But it passesnode,sh,bash,make,gcc,curl,git, and every other system command through without question..bin/symlinks are created for all packages regardless of trust. Blocking a package's own lifecycle scripts does nothing to stop itsbinentries from landing in the shared.bin/directory.PATH construction in
PackageManagerLifecycle.rsprependsnode_modules/.binglobally. Every package that runs scripts sees every other package's bin entries.367 packages are default-trusted (
src/install/default-trusted-dependencies.txt). An attacker can depend on any of them to provide a self-contained trigger. The victim never needs to configure anything.
Proof of concept
mkdir /tmp/test-bunnyhijack && cd /tmp/test-bunnyhijack
echo '{"name":"app","version":"1.0.0"}' > package.json
bun i evil-pkg
cat /tmp/.bun-npm-pwnedThat's it. An empty package.json with no scripts, no trustedDependencies, nothing.
Result:
bun add v1.3.14
[!] PATH POISONED - [email protected] just hijacked your 'node' command.
What just happened?
-> You ran: bun i evil-pkg
-> evil-pkg declared esbuild as a dependency.
-> esbuild is on Bun's default-trusted list, so its postinstall runs automatically.
-> esbuild's postinstall calls `node install.js`.
-> But evil-pkg's bin field shadows `node` with this shim.
-> node_modules/.bin/node → evil-pkg/shim.js
-> So esbuild's postinstall executed ATTACKER code instead of real node.
No victim scripts. No trustedDependencies. No --trust flag.
Just: bun i evil-pkg
installed [email protected] with binaries:
- node
PWNEDDefault-trusted packages that can be abused as triggers
Any of these 367 packages can replace esbuild as the trigger. High-value targets that call node in postinstall:
| Package | postinstall calls |
|---------|------------------|
| esbuild | node install.js |
| sharp | node install/check.js |
| bcrypt | node-pre-gyp install (calls node) |
| sqlite3 | node-pre-gyp install |
| node-sass | node scripts/build.js |
| puppeteer | node install.mjs |
| cypress | node index.js --exec install |
| canvas | node-pre-gyp install |
| argon2 | node-pre-gyp install |
An attacker can shadow node, sh, make, gcc, python, curl — all at once:
{
"bin": {
"node": "./p", "sh": "./p", "make": "./p",
"gcc": "./p", "curl": "./p"
},
"dependencies": { "esbuild": ">=0.17.0" }
}Attack complexity
Attack complexity is low. The attacker publishes one package. The victim runs bun i <package>. That's it.
The payload runs with the victim's full user privileges. It can read .env, SSH keys, .npmrc tokens, cloud credentials. It can write to shell startup files, Git hooks, desktop autostart entries.
What bun should do
normalized_bin_name()needs to reject bin names that match system commands. At minimum:node,sh,bash,make,gcc,g++,cc,python,python3,curl,wget,git,npm,npx,bun.Scope
.bin/entries per-package instead of sharing a global directory. Only add a package's own bin entries to PATH when running its scripts.The default-trusted list needs to account for the fact that trusted packages' scripts run in a PATH that includes untrusted packages' bin entries. Trust is not transitive — a trusted script should not be forced to execute an untrusted binary.
Affected versions
All versions of Bun since the package manager was introduced. Confirmed on v1.3.14 (latest release).
Credits
Discovered during security research on Bun. GitHub: https://github.com/X3r0Day/BunnyHijack
