Overview
The previous lesson showed that a shell parses a command string and can turn data into code. This lesson looks at the specific coding pattern that hands that risk to the shell: unsafe execution, where a program builds a single command string from untrusted input and asks the operating system to run it through a shell. Recognizing this pattern in code is how developers stop command injection before it exists, and how authorized testers reason about where it might live. All examples are illustrative and meant for isolated, self-owned lab practice.
Learning objectives
- Identify the "string command, run via shell" execution pattern across common languages.
- Explain why shell-invoking APIs are the risky ones.
- Distinguish invoking a shell from invoking a program directly.
- Spot unsafe execution in unfamiliar code.
Core concept
Operating systems and language runtimes offer two broad ways to run an external program:
- Through a shell — you hand a single string to a shell interpreter, which parses it (splitting words, expanding metacharacters, chaining commands) and then executes. This is convenient because it supports pipes, redirects, and globs, but it means the shell interprets your string as code.
- Directly, as a program plus a list of arguments — you name the executable and pass its arguments as separate, discrete values. No shell parses the line, so metacharacters in an argument stay literal data (covered in the next lesson).
Unsafe execution is choosing the first path with untrusted input inside the string. The vulnerability is not that the program runs external commands — that can be perfectly legitimate — but that it constructs a shell command string out of values it does not control.
How it works
Many runtimes have a "run this string in a shell" function, and it is often the easiest one to reach for. When such a function receives a string that was built by concatenating or interpolating user input, every metacharacter in that input is live. The runtime typically does this by launching /bin/sh -c "<your string>", so the string is a full shell program.
Illustrative patterns (names vary by language) that indicate the shell is involved:
- A "system"-style call or a "shell"-style exec that takes one command string.
- A "popen"/backtick style that runs a string in a shell to capture output.
- A subprocess API called with a single string and an explicit "use a shell" flag turned on.
Contrast that with the safe direction — passing the program name and an array of arguments with no shell flag — which the next lesson develops in detail.
Practical example
Consider a lab endpoint that converts an uploaded file, building a command from a user-supplied filename:
# UNSAFE (illustrative) — one string, run by a shell
cmd = "convert " + user_filename + " output.png"
run_in_shell(cmd) # e.g. sh -c "convert <user_filename> output.png"
If user_filename is photo.jpg, the shell runs one command. If it is photo.jpg; rm -rf /tmp/lab-data, the shell sees the ; and runs a second, destructive command. The bug is entirely in how the command was executed — a shell parsing a concatenated string.
The same task done safely names the program and passes arguments separately:
# SAFER (illustrative) — program + argument list, no shell
run_program("convert", [user_filename, "output.png"])
Here user_filename is delivered to convert as one literal argument. Even photo.jpg; rm -rf ... is just an odd (and invalid) filename — there is no shell to interpret the ;.
[!WARNING] Only run destructive-looking payloads against throwaway data in an isolated VM/container you own. The purpose is to see why the unsafe API is dangerous, then remove it.
Cybersecurity relevance
Most real command-injection findings trace back to this exact pattern: a shell-invoking API fed a concatenated string. For developers, the highest-value habit is to grep the codebase for shell-invoking calls and check whether any untrusted value reaches them. For authorized testers, mapping input that flows into such calls is the core of assessing command-injection risk. Because the pattern is recognizable across languages, learning to spot "single string + shell" is a skill that transfers everywhere and directly reduces attack surface.
Common mistakes
- Using a shell just for convenience (to get pipes or globbing) when a direct program call would do.
- Interpolating input into the command string and then quoting it, believing quotes are sufficient — escaping is fragile and easy to bypass.
- Not noticing a hidden shell — some subprocess APIs invoke a shell only when a
shell=true-style flag is set or when a single string is passed; the default matters. - Assuming a "trusted" internal value is safe — values from databases, config, or other services can be attacker-influenced.
Key takeaways
- There are two ways to run external programs: through a shell (one parsed string) or directly (program + argument list).
- Unsafe execution is building a shell command string from untrusted input; the shell then interprets any metacharacters as code.
- Shell-invoking APIs (system/popen/backtick/
shell=true) are the risky ones to audit. - The fix direction is to avoid the shell and pass arguments discretely — detailed next.
Check your understanding
- What is the difference between running a command "through a shell" and running a program "directly with an argument list"?
- Why is a subprocess call with a single command string and a
shell=true-style flag a red flag when the string contains user input?

