Bash eval: Keep Data Out of the Second Parse
Understand how Bash eval reparses text as shell code, then replace dynamic command strings with arrays, positional parameters, and explicit dispatch.
eval is not a quoting repair tool. It concatenates its arguments into shell input and asks the shell to parse and execute that text again. The second parse means characters that arrived as data during the first parse can become syntax during the next one. If any portion of the string is influenced by a user, file, environment variable, pull request, or remote response, eval can turn that value into an unintended command.
Most dynamic command construction does not require eval. In Bash, use an array to keep each argument as an element and invoke the array with a quoted all-elements expansion. In POSIX shell, use positional parameters with set – and forward them with “$@”. For a small set of named operations, validate an identifier and dispatch through a case statement. Those designs preserve argument boundaries without serializing an argv vector into source code.
What the second parse actually does
Suppose a script stores an apparent command in a string:
target='report file.txt'
command_text="printf '%s\n' $target"
eval "$command_text"
The outer double quotes protect the contents of command_text from the current round of word splitting and pathname expansion. They do not make the string safe for eval. eval then concatenates its arguments with spaces and Bash parses the resulting text as a new command. That parse performs shell syntax recognition and expansion again. Quotes, command substitutions, redirections, semicolons, and other syntax embedded in the data can affect execution.
The bug is often introduced by a well-intentioned attempt to preserve a command line for later use. A string is a sequence of characters; it does not remember which characters were originally one argument, which spaces were separators, or which bytes came from untrusted input. Once arguments are flattened into text, they cannot be reconstructed reliably without a precisely defined serialization format and a corresponding parser. eval is that parser, but it parses the full shell language rather than a narrow data format.
Do not infer safety from printf %q, single quotes in a log, or a variable name that sounds internal. Encoded text is still text. Its exact interpretation depends on the shell, version, locale, and context. A quote-escaping representation can be appropriate for display or a documented shell-specific interchange format, but it should not become a reason to execute arbitrary serialized arguments.
Keep an executable and its arguments separate
For Bash, represent an executable and its arguments as an array:
tool=/usr/local/bin/report
output_path='report final.json'
verbose=yes
command_args=(--format json --output "$output_path")
if [[ $verbose == yes ]]; then
command_args+=(--verbose)
fi
"$tool" "${command_args[@]}"
Each array element becomes one argument. The array expansion inside double quotes preserves empty strings and spaces inside elements. Do not use a joined expansion as an invocation shortcut; joining array entries into one word discards the intended boundaries. Also validate tool itself if it can be influenced by input. Argument safety does not decide which executable a program should be allowed to run.
Portable shell has no Bash arrays, but positional parameters serve as a sequence:
set -- --format json --output "$output_path"
if [ "$verbose" = yes ]; then
set -- "$@" --verbose
fi
"$tool" "$@"
set – replaces the current shell’s positional parameters. Use this in a function when you do not want to alter the caller’s parameters; POSIX shell functions have their own positional parameters. Quote “$@” exactly as shown to pass each item as a separate argument. Unquoted $@ and $* are not safe substitutes.
If different modes use different commands, dispatch to fixed command words:
case $mode in
inspect) inspect_record "$record_id" ;;
export) export_record "$record_id" "$output_path" ;;
*) printf 'unsupported mode: %s\n' "$mode" >&2; exit 2 ;;
esac
This is an allowlist, not a shell fragment. A mode string can select only one of the operations the script defines. Validate the data passed to the selected operation at that operation’s boundary; a syntactically valid identifier may still refer to a resource the caller is not authorized to change.
Common reasons people reach for eval
One pattern uses a variable name supplied as text, such as “set variable named result.” For a finite mapping, use a case statement. In Bash, a nameref may be appropriate for a function whose contract explicitly accepts a variable name, but it has scope, compatibility, and trust implications and should be validated. It is not a general way to expose arbitrary variables to untrusted callers.
Another pattern stores flags in a string: flags=“–quiet –output $path” and later runs eval tool $flags. Keep flags as an array in Bash or as positional parameters in portable shell. If the flags come from configuration, parse a documented configuration format into fields and validate each option rather than treating the file as shell code. A JSON, TOML, or line-oriented configuration parser is preferable when the data needs structure.
A third pattern is generated source code. Code generation may be legitimate for a reviewed build step, but it should be treated as compilation: define a grammar, escape by that grammar, write to a controlled location, inspect output, and execute only trusted generated artifacts. It is not appropriate to interpolate runtime user text into an eval command. If a product truly offers user-supplied shell expressions, isolate them as executable code with a least-privilege process boundary; quoting alone is not isolation.
Some shells and tools provide eval-like features for a limited, explicit purpose. For example, Fish’s string escape –style=script documents output that can be passed back to eval to reconstruct an argument. That is a shell-specific serialization contract, not a reason to pass arbitrary user data to an interpreter. Prefer an argv interface whenever the called program provides one. When text parsing is unavoidable, use a parser for the narrow expected grammar, not the shell language.
Quote at the boundary, not after flattening
Quoting is most effective while values are still separate. Quote path variables and user-provided arguments when invoking commands. Use – to end option parsing when the target program supports it, especially for a filename that could begin with a dash. Validate enumerated values before dispatch. Keep patterns, regular expressions, SQL fragments, and shell commands in distinct types or variables because quoting for one grammar does not neutralize another grammar.
Avoid logging a reconstructed command by joining arguments with spaces and later feeding that log back to a shell. A human-readable trace can be ambiguous when arguments contain whitespace, newlines, quotes, or control characters. For diagnostics, print each argument with an unambiguous delimiter or use a format such as JSON with a real serializer. Do not log credentials or other secrets merely because a debug mode is enabled.
Environment variables are not a substitute for argv boundaries, and they are not inherently trusted. A program invoked as tool “$INPUT” receives one argument, but it may interpret that argument as a path, option, pattern, or script language. Use the target program’s safe API and validate authorization at the point of action. If invoking a child shell is required, make the script text constant and pass variable data as positional parameters after the -c string, rather than interpolating it into that string.
Audit and test an eval-free rewrite
Search a codebase for eval, bash -c, sh -c, and command strings assembled with concatenation. For each occurrence, identify who controls every piece of text and which parser will consume it. Replace ordinary argument construction with arrays or “$@”, and replace finite symbolic dispatch with explicit case arms. If an occurrence intentionally executes trusted generated code, document why an argv interface is impossible and how the source is protected.
Test argument boundaries with values containing spaces, an empty argument, wildcard characters, semicolons, quotes, dollar signs, newlines, and a leading dash. Use a harmless fake executable that prints one argument per line or a NUL-delimited representation; do not test injection by running destructive payloads. Include cases where the mode or executable name is invalid and verify that the program refuses the request before invoking anything.
The safest command string is usually no command string at all. Keep code in code, arguments in an array or positional-parameter list, and configuration in a parser for its documented format. eval makes sense only when executing shell source is the explicit feature, the source is trusted, and the interpreter boundary is intentional. For normal automation, the extra parse is an avoidable correctness and security hazard.
During review, treat every parser transition as a separate question: which process receives this value, which grammar will parse it next, and is that parser consuming data or executable syntax? This checklist catches risks beyond shell injection, including option confusion and values interpreted by downstream tools. It also helps explain why a direct, quoted invocation is easier to prove correct than a carefully escaped string passed through another parser.
Related:
- Bash Local Variables: Dynamic Scope, Shadowing, and Function Boundaries
- How to Write Robust, Portable POSIX Shell Scripts
Sources: