VNX-BASH-007 – Unquoted Command Substitution in Shell Script
Overview
This rule reports a command substitution, $(...) or a backtick pair, used outside double quotes as an argument of a command in a Bash script (.sh, .bash and extensionless files with a sh/bash/dash/ksh shebang): cd $(dirname "$f"), chmod 600 $(find ...), tar czf out.tgz $(cat list), export $(grep -v '^#' .env | xargs), the first word of a continued command line, and a for loop over a file or text listing ($(ls ...), $(find ...), $(cat ...), $(grep ...), $(git ls-files)). Not reported: substitutions inside double quotes (including nested ones and multi-line strings), assignments and ${VAR:-$(...)} defaults, [[ ]], (( )) and $(( )) arithmetic, case words, here-strings and here-document bodies, a substitution in command position (if ! $(grep -q ...)), commands whose output is a number or a single token (seq, nproc, jobs -p, id, date, wc, uname, whoami, hostname, git rev-parse), for loops over other tool output, comments, echo/printf text, test paths and .bats files.
When command output is captured without double-quotes, the shell applies word splitting (splitting on whitespace or the current IFS) and glob expansion (expanding *, ?, and [...] patterns) to the result before passing it to the enclosing command. A command that produces a filename with spaces, a path with glob characters, or newline-separated output will be split into multiple unexpected arguments.
If the output of the substituted command is at all influenced by attacker-controlled data — a filename from a directory listing, a value from a configuration file, or the output of a tool that processes user input — word splitting can turn that single value into multiple shell arguments, potentially injecting additional flags or operands.
Severity: Medium | CWE: CWE-88 – Improper Neutralisation of Argument Delimiters in a Command
Why This Matters
Argument injection via unquoted command substitution is a common source of bugs in shell scripts, and it becomes a security issue whenever the substituted command’s output can be influenced by an external party. Consider a script that passes the output of a basename or git rev-parse call into another command without quoting — an attacker who can create a file or branch with a specially crafted name containing spaces or shell metacharacters can inject extra arguments into the receiving command.
Real-world attacks of this form have been demonstrated in CI/CD pipelines where attacker-controlled branch names or tag names flow through shell scripts. GitHub Actions workflows that pass ${{ github.head_ref }} or similar values into shell scripts are particularly vulnerable when the script then uses unquoted command substitutions that incorporate those values. ATT&CK T1059.004 covers Unix shell interpreter abuse including this injection class.
The fix is mechanical and has zero performance cost: simply add double-quotes around every $(...). If the script genuinely needs to split the output of a command into an array, use mapfile -t arr < <(command) or read -ra arr <<< "$(command)" instead of relying on unquoted word splitting.
What Gets Flagged
chmod 600 $(find /etc/app -name '*.key') # FLAGGED: a name with a space becomes two
export $(grep -v '^#' .env | xargs) # FLAGGED: values split and globbed
cp `ls -t backups/*.sql | head -1` /restore/ # FLAGGED: backtick form
for f in $(ls /var/log/app/*.log); do gzip "$f"; done # FLAGGED
rsync -a \
$(sed 's/^/--exclude=/' excludes.txt) src/ dst/ # FLAGGED: continued line
Not flagged:
echo "size: $(wc -c < "$f") bytes"
CLI_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
chown $(id -u):$(id -g) /data
make -j$(nproc)
for i in $(seq 1 5); do echo "$i"; done
if (( $(wc -l < file.txt) > 10 )); then :; fi
Remediation
- Always wrap
$(...)in double-quotes:"$(command)". - When you need the output split into an array, use
mapfileorread -rarather than relying on unquoted word splitting. - Enable ShellCheck in your editor and CI pipeline to catch unquoted substitutions automatically.
- Prefer
[[ ]]over[ ]for conditional tests — while quoting is still recommended,[[ ]]does not perform word splitting.
# SAFE: command substitution double-quoted
files="$(find /tmp -maxdepth 1 -name '*.log')"
echo "Found: $files"
# SAFE: owner assignment with quoted substitutions
chown "$(id -u)":"$(id -g)" /some/path
# SAFE: comparison with quoted substitution
if [ "$(whoami)" = "root" ]; then echo ok; fi
# SAFE: split into array explicitly instead of relying on word splitting
mapfile -t log_files < <(find /var/log -name "*.log")
for f in "${log_files[@]}"; do
process "$f"
done