{"repro_id":"REPRO-2026-00224","version":6,"title":"Node.js task runner improperly escapes single quotes on Unix, enabling shell syntax breakouts and potential command injection via `node --run` arguments.","repro_type":"security","status":"published","severity":"high","description":"Node.js task runner EscapeShell routine incorrectly escapes single quotes on POSIX shells. It replaces ' with \\' inside single-quoted strings, but in POSIX shell syntax a backslash does not escape a single quote within single quotes. This allows crafted arguments passed to `node --run <script> -- <args>` to break out of the single-quoted context, causing shell syntax errors or command injection.","root_cause":"# Root Cause Analysis: Node.js Task Runner Single-Quote Escape Bug\n\n## Summary\n\nNode.js' task runner (`node --run <task> -- <args>`) uses an `EscapeShell()` routine in `src/node_task_runner.cc` to safely quote positional arguments before concatenating them into a `/bin/sh -c` command string. On POSIX systems, the vulnerable code escaped each single quote (`'`) by rewriting it to `\\'` and wrapping the result in single quotes. Because a backslash is **literal** inside POSIX single-quoted strings, the rewritten `'` actually **terminates** the single-quoted context early, leaving the remainder of the argument to be interpreted as raw shell syntax. An attacker-controlled argument containing a single quote followed by shell metacharacters can break out and inject arbitrary commands. The fix (commit `e76c573e4546ce9e89e0dd954f80aaba32148a48`) replaces `\\'` with the POSIX-safe sequence `'\"'\"'` (close single-quote, double-quote the literal quote, reopen single-quote).\n\n## Impact\n\n- **Package/component affected:** Node.js core — `src/node_task_runner.cc`, the `EscapeShell()` function used by `node --run`.\n- **Affected versions:** All Node.js builds that include the task runner (`--run` flag) and are prior to commit `e76c573e4546ce9e89e0dd954f80aaba32148a48`. Verified vulnerable on system Node.js v24.18.0.\n- **Risk level and consequences:** High. Any application that forwards user-controlled data as arguments to `node --run` (e.g., a CI runner, a build tool, or a web backend that shells out to `node --run`) is vulnerable to **command injection** — arbitrary command execution with the privileges of the Node.js process. Even without metacharacters, a benign single quote in an argument (e.g., a person's name like \"I'm\") causes a `/bin/sh` syntax error, breaking the script entirely (denial of service).\n\n## Impact Parity\n\n- **Disclosed/claimed maximum impact:** Command injection / code execution via `node --run` arguments on Unix-like systems.\n- **Reproduced impact from this run:** **Full command injection confirmed.** An argument `x';id > MARKER;echo INJECTION_PROVEN #` causes the `id` command to execute (writing its output to a marker file) and `echo INJECTION_PROVEN` to print to stdout — arbitrary attacker-controlled commands run via `/bin/sh -c`. Additionally, the benign argument `\"I think therefore I'm\"` causes a `/bin/sh` syntax error (DoS / broken argument passing).\n- **Parity:** `full` — the claimed code-execution impact was demonstrated through the real `node --run` CLI entrypoint.\n- **Not demonstrated:** N/A — code execution was achieved.\n\n## Root Cause\n\nIn POSIX shells, single-quoted strings preserve **every** character literally — a backslash has no special meaning inside single quotes. The vulnerable `EscapeShell()` code:\n\n```cpp\nstd::string escaped =\n    std::regex_replace(std::string(input), std::regex(\"'\"), \"\\\\'\");\nescaped = \"'\" + escaped + \"'\";\n```\n\nrewrites each `'` as `\\'` and wraps in `'...'`. For input `x';id > M #`:\n- After replace: `x\\';id > M #`\n- After wrap: `'x\\';id > M #'`\n\n`/bin/sh -c` parses `'x\\'` as a single-quoted string containing `x\\` (the backslash is literal). The `'` after `\\` **closes** the single-quote context. The remainder `;id > M #` is now **unquoted shell syntax**: `;` is a command separator, `id > M` executes `id` with output redirected to `M`, and `#` begins a comment that consumes the trailing wrap-quote `'` — avoiding any syntax error.\n\nThe correct POSIX-safe technique is `'\"'\"'` (close the single quote, insert a literal `'` via double quotes, reopen the single quote). The fix commit applies this:\n\n```cpp\nstd::regex_replace(std::string(input), std::regex(\"'\"), \"'\\\"'\\\"'\");\n```\n\nWith the fix, the same input becomes `'x'\"'\"';id > M #'`, which `/bin/sh` parses as a single literal argument `x';id > M #` with no shell interpretation.\n\n**Fix commit:** `e76c573e4546ce9e89e0dd954f80aaba32148a48` (\"src: fix escaping of single quotes in task runner\", PR #64089, ref. HackerOne #3817602).\n\n## Reproduction Steps\n\n1. **Script:** `bundle/repro/reproduction_steps.sh`\n2. **What the script does:**\n   - Reads `bundle/project_cache_context.json` to locate the prepared Node.js source cache.\n   - Builds (or reuses pre-built) Node.js binaries at the **vulnerable** commit (`e76c573^`) and the **fixed** commit (`e76c573`).\n   - Creates a test project with `package.json` containing an npm script `showargs` mapped to `echo`.\n   - Runs **two attempts** per test for both builds:\n     - **Test A (DoS):** `node --run showargs -- \"I think therefore I'm\"` — vulnerable build produces `/bin/sh: Syntax error: Unterminated quoted string`; fixed build prints `I think therefore I'm` cleanly.\n     - **Test B (Command injection):** `node --run showargs -- \"x';id > MARKER;echo INJECTION_PROVEN #\"` — vulnerable build creates a marker file containing `id` output and prints `INJECTION_PROVEN`; fixed build passes the argument literally with no marker file.\n     - **Test C (Ticket's exact payload):** `node --run showargs -- \"foo' ; id ; '\"` — vulnerable build produces a syntax error (DoS only, since the trailing unbalanced quote is not commented out); fixed build passes the argument safely.\n   - Writes `bundle/repro/runtime_manifest.json` with runtime evidence.\n3. **Expected evidence of reproduction:**\n   - Marker file (`marker_vuln_attempt*.txt`) containing `uid=...` output from the injected `id` command.\n   - Test logs showing `INJECTION_PROVEN` in stdout for the vulnerable build.\n   - Test logs showing `/bin/sh: Syntax error: Unterminated quoted string` for DoS tests.\n   - Absence of marker file and clean literal output for the fixed build (negative control).\n\n## Evidence\n\n- **Log file locations:**\n  - `bundle/logs/test_A_vuln_attempt{1,2}.log` — DoS test on vulnerable build\n  - `bundle/logs/test_A_fixed_attempt{1,2}.log` — DoS test on fixed build\n  - `bundle/logs/test_B_vuln_attempt{1,2}.log` — Injection test on vulnerable build\n  - `bundle/logs/test_B_fixed_attempt{1,2}.log` — Injection test on fixed build\n  - `bundle/logs/test_C_vuln_attempt{1,2}.log` — Ticket payload on vulnerable build\n  - `bundle/logs/test_C_fixed_attempt{1,2}.log` — Ticket payload on fixed build\n  - `bundle/logs/binary_info.txt` — Node.js binary versions/paths\n  - `bundle/logs/source_diff.txt` — Vulnerable vs. fixed EscapeShell source\n  - `bundle/repro/artifacts/marker_vuln_attempt*.txt` — Proof of injected `id` execution\n- **Key excerpts (pre-verified on system Node.js v24.18.0):**\n  - Injection: stdout shows `[\"x\\\\\"]` then `INJECTION_PROVEN`; marker file contains `uid=1000(vscode) gid=1000(vscode) groups=1000(vscode),969(969)`\n  - DoS: `/bin/sh: 1: Syntax error: Unterminated quoted string`\n- **Environment:** Linux x86_64, `/bin/sh` (dash), Node.js built from source at commits `e76c573^` (vulnerable) and `e76c573` (fixed). System Node.js v24.18.0 also confirmed vulnerable.\n\n## Recommendations / Next Steps\n\n- **Suggested fix approach:** Apply commit `e76c573e4546ce9e89e0dd954f80aaba32148a48` — replace `\\\\'` with `'\\\"'\\\"'` in the POSIX branch of `EscapeShell()`. This is the canonical POSIX-safe single-quote escaping technique.\n- **Upgrade guidance:** Upgrade to any Node.js release that includes commit `e76c573`. All versions prior to this commit that ship the `--run` task runner are affected.\n- **Testing recommendations:** Add regression tests that pass arguments containing single quotes, semicolons, and comment characters through `node --run` and verify the shell receives them as literal values. The existing `test/cctest/test_node_task_runner.cc` `EscapeShell` unit test should be expanded to cover the `'\"'\"'` output and end-to-end `--run` invocations with shell metacharacters.\n\n## Additional Notes\n\n- **Idempotency:** The script cleans up marker files before each injection test and recreates the test project from scratch, so repeated runs produce consistent results.\n- **Key insight:** The ticket's exact payload `foo' ; id ; '` only causes a syntax error (DoS) because the trailing wrap-quote `'` opens an unterminated single-quoted context, causing `/bin/sh` to reject the entire command before executing anything. The modified payload `x';id > MARKER;echo INJECTION_PROVEN #` uses a `#` comment to consume the trailing wrap-quote, producing a **syntactically valid** shell command in which the injected `id` and `echo` commands execute. This achieves the full code-execution impact claimed by the ticket.\n- **Why the `#` technique works:** After the broken `\\'` escaping closes the single-quote context, the `#` (preceded by whitespace) starts a shell comment that extends to end-of-line, swallowing the wrap's trailing `'`. The commands before `#` (`id`, `echo`) execute normally.\n- **Platform scope:** This vulnerability is POSIX-only. The Windows branch of `EscapeShell()` uses a different escaping strategy (double-quote based) and is not affected.\n","cwe_id":"CWE-77","source_url":"https://github.com/spaceraccoon/vulnerability-spoiler-alert/issues/307","package":{"name":"node","ecosystem":"nodejs","affected_versions":"v22.23.0, v24.17.0, v26.3.1 confirmed vulnerable on Linux x64. --run was added in v22.0.0, older versions without --run are not affected. Windows not tested.","fixed_version":"Commit e76c573e4546ce9e89e0dd954f80aaba32148a48"},"reproduced_at":"2026-07-04T19:51:49.940100+00:00","duration_secs":2832.0,"tool_calls":134,"handoffs":2,"total_cost_usd":3.2577453600000013,"agent_costs":{"hypothesis_generator":0.0210386,"judge":0.01218475,"repro":3.1681016400000006,"support":0.05642037},"cost_breakdown":{"hypothesis_generator":{"accounts/fireworks/models/glm-5p2":0.0210386},"judge":{"gpt-5.4-mini":0.01218475},"repro":{"accounts/fireworks/routers/glm-5p2-fast":3.1681016400000006},"support":{"accounts/fireworks/routers/glm-5p2-fast":0.05642037}},"quality":{"confidence":"high","idempotent_verified":false,"community_verifications":0},"environment":{"sandbox_image":"ghcr.io/n3mes1s/pruva-sandbox@sha256:8096b2518d6022e13d68f885c3b8ded6b4fe607098b1a1ccbfb99abc004d1dc1"},"published_at":"2026-07-04T19:52:12.881699+00:00","retracted":false,"artifacts":[{"path":"bundle/repro/reproduction_steps.sh","filename":"reproduction_steps.sh","size":14325,"category":"reproduction_script"},{"path":"bundle/repro/rca_report.md","filename":"rca_report.md","size":8905,"category":"analysis"},{"path":"bundle/artifact_promotion_manifest.json","filename":"artifact_promotion_manifest.json","size":6771,"category":"other"},{"path":"bundle/repro/artifacts/marker_vuln_preliminary.txt","filename":"marker_vuln_preliminary.txt","size":63,"category":"other"},{"path":"bundle/logs/preliminary_vuln_evidence.log","filename":"preliminary_vuln_evidence.log","size":699,"category":"log"},{"path":"bundle/repro/artifacts/marker_vuln_attempt1.txt","filename":"marker_vuln_attempt1.txt","size":63,"category":"other"},{"path":"bundle/repro/runtime_manifest.json","filename":"runtime_manifest.json","size":1098,"category":"other"},{"path":"bundle/logs/source_diff.txt","filename":"source_diff.txt","size":400,"category":"other"},{"path":"bundle/logs/binary_info.txt","filename":"binary_info.txt","size":241,"category":"other"},{"path":"bundle/logs/test_B_vuln_attempt1.log","filename":"test_B_vuln_attempt1.log","size":343,"category":"log"},{"path":"bundle/logs/test_B_fixed_attempt1.log","filename":"test_B_fixed_attempt1.log","size":409,"category":"log"},{"path":"bundle/logs/test_A_vuln_attempt1.log","filename":"test_A_vuln_attempt1.log","size":237,"category":"log"},{"path":"bundle/repro/validation_verdict.json","filename":"validation_verdict.json","size":1005,"category":"other"},{"path":"bundle/logs/test_B_vuln_attempt2.log","filename":"test_B_vuln_attempt2.log","size":343,"category":"log"},{"path":"bundle/logs/test_B_fixed_attempt2.log","filename":"test_B_fixed_attempt2.log","size":409,"category":"log"},{"path":"bundle/logs/test_A_fixed_attempt1.log","filename":"test_A_fixed_attempt1.log","size":208,"category":"log"},{"path":"bundle/repro/artifacts/marker_vuln_attempt2.txt","filename":"marker_vuln_attempt2.txt","size":63,"category":"other"}]}