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

  1. Always wrap $(...) in double-quotes: "$(command)".
  2. When you need the output split into an array, use mapfile or read -ra rather than relying on unquoted word splitting.
  3. Enable ShellCheck in your editor and CI pipeline to catch unquoted substitutions automatically.
  4. 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

References