execSync vs execFileSync: the shell is the security boundary
A semicolon is not dangerous.
It becomes dangerous when something decides that it means “the previous command ends here.”
That distinction explains most of the security difference between Node.js execSync() and execFileSync().
Imagine that a Node application needs to call an external program using some user-provided text.
This looks reasonable:
import { execSync } from "node:child_process";
const query = getUserInput();
execSync(`grep ${query} ./notes.txt`);The usual advice is: “be careful, this is vulnerable to command injection.”
Correct. But that skips the interesting part.
Why can a few characters inside query suddenly become another command?
Because grep is not actually the first program that gets to interpret that string.
The shell is.
What execSync() actually executes
Consider:
execSync('grep hello ./notes.txt');It is tempting to imagine Node doing something equivalent to:
executable = grep
arguments = ["hello", "./notes.txt"]That is not what execSync() does.
Node documents execSync() as executing a command through a shell.
On Unix, the default shell is /bin/sh. On Windows, Node uses process.env.ComSpec.
So the path is closer to this:
JavaScript
↓
"grep hello ./notes.txt"
↓
/bin/sh
↓
parse shell syntax
↓
grep
↓
["hello", "./notes.txt"]That extra parser is the important part.
The shell receives a piece of source code in the shell language.
It understands spaces, quoting, pipes, redirects, substitutions and control-flow operators.
For example:
echo hello; echo worldis not one command containing a strange semicolon.
To the shell, it is two commands:
echo hello
echo worldThe semicolon only has that power because a shell parser sees it.
Turning data into syntax
Now imagine this input:
hello; printf "INJECTED\n"and this code:
const input = 'hello; printf "INJECTED\\n"';
execSync(`printf "%s\\n" ${input}`);JavaScript first creates this string:
printf "%s\n" hello; printf "INJECTED\n"Then Node gives that string to the shell.
The shell parses it as two commands.
Running this with Node.js 22.16.0 produces:
hello
INJECTEDThe crucial transition is therefore:
untrusted data
↓
string interpolation
↓
shell source code
↓
shell parser
↓
attacker-controlled syntaxNothing about ; itself is malicious.
The problem is that data crossed a parser boundary and acquired grammatical meaning.
The same applies to other shell constructs.
Depending on the quoting context, characters such as these can matter:
;
|
&&
||
>
<
$()
`...`This is also why saying execSync() “invokes Bash” is slightly wrong.
On Unix, Node’s documented default is /bin/sh, not Bash specifically.
/bin/sh might ultimately be provided by Bash on one system, Dash on another, or another compatible shell elsewhere.
The vulnerability does not depend on Bash.
It depends on shell interpretation.
What changes with execFileSync()
Now write the same operation like this:
import { execFileSync } from "node:child_process";
const input = 'hello; printf "INJECTED\\n"';
execFileSync("printf", ["%s\\n", input]);The structure is fundamentally different.
Node already knows which value is the executable:
printfand which values are arguments:
[
"%s\n",
"hello; printf \"INJECTED\\n\""
]There is no need to reconstruct those boundaries from a string.
By default, execFileSync() does not spawn a shell.
Conceptually:
JavaScript
↓
executable = "printf"
arguments =
[
"%s\n",
"hello; printf \"INJECTED\\n\""
]
↓
process launch
↓
printfThere is no /bin/sh in the middle.
So what happens to our supposed payload?
The output is:
hello; printf "INJECTED\n"Exactly one string.
The semicolon survives, but it has lost its special power.
printf receives it as ordinary data because nobody parsed it as shell syntax.
That is the important distinction.
execFileSync() is not escaping the input
This is easy to misunderstand.
execFileSync() is not taking this:
hello; printf "INJECTED\n"and cleverly escaping the dangerous characters.
It does something better.
It never turns the arguments into shell source code in the first place.
Compare the two models.
With execSync():
executable + arguments
↓
flatten into one string
↓
shell parses the string
↓
reconstruct executable + argumentsWith execFileSync():
executable
+
argument array
↓
process launchexecSync() flattens structure into text and later asks another parser to recreate that structure.
execFileSync() preserves the structure.
That difference is much more important than the names of the two functions.
The argument array is a security property
This:
execFileSync("grep", [query, "./notes.txt"]);can look like a nicer API for writing this:
execSync(`grep "${query}" ./notes.txt`);But they are not two syntaxes for the same operation.
The first API maintains a distinction between:
program
argument 1
argument 2The second produces one piece of shell code:
grep "something" ./notes.txtand relies on a shell to discover those boundaries again.
Once that happens, quoting becomes part of your security model.
You now need to reason about shell grammar.
Did I use double quotes?
Can the input contain a double quote?
Can it contain command substitution?
Does the escaping function match this exact shell?
Will this run on Windows too?
Will somebody later modify the command template without understanding the escaping assumptions?
It is possible to escape shell arguments correctly.
But if you never needed a shell in the first place, that is solving a problem that the architecture created.
This is the same shape as other injection vulnerabilities
The interesting pattern is larger than Node.js.
Consider SQL.
This is dangerous:
const sql = `SELECT * FROM users WHERE name = '${name}'`;because data is inserted into SQL source code.
A parameterized query keeps the structure separate:
SQL structure
+
datainstead of:
string
↓
SQL parserShell command injection has the same shape.
Unsafe:
shell structure + data
↓
one string
↓
shell parserSafer:
executable
+
argumentsCross-site scripting has a similar failure mode when text reaches an HTML or JavaScript parser in a context where it can become syntax.
The general problem is not “special characters.”
It is data reaching a parser as code.
So is execFileSync() safe?
Not universally.
It removes the shell parser from this particular boundary.
That eliminates shell command injection through the argument array when execFileSync() is used without a shell.
But another parser still exists.
The target program itself.
Consider:
execFileSync("some-tool", [userInput]);If:
userInput = "--delete-everything"the shell does nothing special with it.
But some-tool might.
Many command-line programs interpret arguments beginning with - or -- as options.
So you can avoid shell injection and still expose dangerous program functionality.
For commands that support it, the conventional -- marker can sometimes make the boundary explicit:
execFileSync("grep", ["--", query, "./notes.txt"]);Here -- tells grep that subsequent values should be treated as positional arguments rather than options.
But this is a property of the target program, not of execFileSync().
Different executables have different grammars.
Removing the shell only removes one interpreter.
The executable itself can also be attacker-controlled
This would be a different problem:
execFileSync(userControlledExecutable, [arg]);There may be no shell injection, but the user can now decide which program you execute.
That is obviously dangerous in another way.
Executable lookup can matter too.
For example:
execFileSync("my-tool", [arg]);may rely on PATH to find my-tool.
If an attacker can influence the environment or executable search path, the security problem shifts from shell syntax to executable resolution.
Again, execFileSync() does not mean “safe process execution.”
It means that Node does not need a shell to interpret the command line.
That is narrower, and more useful.
You can also put the shell back
execFileSync() has a shell option.
If you explicitly enable it, you deliberately restore the parser that we just removed.
Conceptually:
execFileSync("some-command", args, {
shell: true,
});changes the model again:
JavaScript
↓
shell
↓
target programAt that point, you must reason about shell semantics again.
So the useful statement is not:
execFileSync()cannot have command injection.
It is:
execFileSync()does not invoke a shell by default, so its argument array is not interpreted as shell source.
That is the security property we care about.
Windows has an important exception
Unix makes this model relatively clean because normal executable files can be started directly.
Windows adds an important edge case.
Files such as:
.bat
.cmdare command scripts.
They require a command interpreter.
Node’s documentation specifically calls this out: .bat and .cmd files cannot be launched on Windows in exactly the same way as ordinary binaries without involving a shell.
So once again, the function name is not enough.
The useful question is:
Which parser sees this value before the target program gets it?
If the answer includes cmd.exe, /bin/sh, Bash, PowerShell or another command interpreter, characters can acquire that interpreter’s syntax.
When execSync() actually makes sense
None of this means that execSync() is a bad API.
Sometimes you explicitly want a shell.
For example:
execSync(
"cat access.log | grep ERROR | sort | uniq -c"
);Here the pipeline is the feature.
| must be interpreted.
The shell is useful because you are intentionally writing a shell program.
The same applies if you need things such as:
redirection
globbing
shell variables
pipelines
command substitution
conditional executionFor fixed internal scripts where every part of the command is trusted, this can be completely reasonable.
The problem starts when we use a programming-language interface even though we only wanted process execution.
Then we interpolate untrusted data into that language and spend effort trying to stop the parser from interpreting it.
A better decision rule
Instead of memorizing:
execSync bad
execFileSync goodask a different question.
Do I need a shell language?
If yes, use a shell deliberately and treat every untrusted value that reaches it as input to a code parser.
If no, keep the executable and its arguments separate.
In Node.js, that usually means preferring interfaces such as:
execFile()
execFileSync()
spawn()
spawnSync()with their normal argument-array form and without shell: true.
The deeper distinction is not really between execSync() and execFileSync().
It is between these two architectures:
data
↓
source code
↓
parserand:
structure
+
dataThe first asks a parser to decide where the code ends and the data begins.
The second never erases that boundary.
That is why the semicolon in our first test executed another command, while the exact same semicolon in the second test was just a semicolon.
The safest shell parser is often the one you never invoke.