0Pricing
DevOps Bootcamp · Lesson

Preventing Command and Argument Injection

Quote, validate, and array-pass untrusted input to eliminate word-splitting and eval-based injection.

Preventing Command and Argument Injection is a free DevOps Bootcamp lesson on CoddyKit — lesson 1 of 4. You can read the complete lesson below for free — then practise it hands-on in the browser with a built-in code editor and a 24/7 AI tutor. It is part of the DevOps Bootcamp learning path, one of 4 lessons in the course, and your progress syncs across the web and the CoddyKit app.

Why Injection Attacks Happen in Bash

Bash is a powerful glue language — it passes text directly to the kernel, other programs, and sub-shells. That power becomes a liability the moment untrusted input reaches a command without validation or quoting.

Two root causes drive nearly every Bash injection:

  • Word splitting: Unquoted variables are split on whitespace (IFS), turning one logical value into many shell tokens.
  • Glob expansion: Characters like *, ?, and [ are expanded by the shell before the command even runs.

An attacker who controls a filename, username, URL parameter, or environment variable can exploit both to run arbitrary commands, read files, or escalate privileges.

This lesson shows exactly how these vulnerabilities appear and — more importantly — how to eliminate them with correct quoting, input validation, and array-based argument passing.

Word Splitting: The Silent Threat

When Bash sees an unquoted variable, it splits its value on any character listed in $IFS (default: space, tab, newline). What looks like one argument becomes many.

Run the script below and observe how a filename with a space becomes two separate arguments to rm.

#!/usr/bin/env bash
# Dangerous: unquoted variable
FILE='important file.txt'

# Create the file so the demo is self-contained
touch "$FILE"

echo "Files before:"
ls

# BUG: rm sees TWO arguments: 'important' and 'file.txt'
# If 'important' does not exist, rm prints an error but continues.
rm $FILE   # <-- unquoted, word-split happens here

echo "Files after (unquoted rm):"
ls

Always Quote: The First Rule of Defensive Bash

The simplest and most effective defense against word splitting is to always double-quote variable expansions.

  • "$var" — expands to exactly one token, preserving spaces, tabs, and newlines.
  • 'literal' — single quotes: no expansions at all, useful for fixed strings.
  • Never use $var unquoted unless you explicitly need word splitting and glob expansion.

The script below shows the safe version of the previous example.

#!/usr/bin/env bash
set -euo pipefail

FILE='important file.txt'
touch "$FILE"

echo 'Files before:'
ls

# SAFE: double-quotes keep the filename as one token
rm "$FILE"

echo 'Files after (quoted rm):'
ls

Glob Injection: When * Becomes a Weapon

Unquoted variables are also subject to pathname expansion (globbing). If user-controlled input contains * or ?, Bash expands it against the filesystem before the command runs.

A classic attack vector: a web form that sets PATTERN=* and the script runs cp $PATTERN /tmp/leak/ — copying every file in the current directory.

The fix is identical: double-quote the variable. A quoted "$PATTERN" is passed literally; the shell never performs glob expansion on it.

#!/usr/bin/env bash
set -euo pipefail

# Simulate attacker-supplied input
PATTERN='*'

mkdir -p /tmp/safe_demo_src /tmp/safe_demo_dst
touch /tmp/safe_demo_src/secret1.txt /tmp/safe_demo_src/secret2.txt

cd /tmp/safe_demo_src

# UNSAFE: glob expands, copies every file
# cp $PATTERN /tmp/safe_demo_dst/

# SAFE: pattern is treated as a literal filename
cp "$PATTERN" /tmp/safe_demo_dst/ 2>&1 || echo 'No file named literally "*" — attack neutralised'

rm -rf /tmp/safe_demo_src /tmp/safe_demo_dst

Argument Injection via Unquoted Positional Parameters

Scripts that accept arguments from the caller are prime injection targets. Each positional parameter ($1, $2, ...) must be quoted wherever it is used.

A particularly dangerous pattern is passing $@ or $* unquoted to another command:

  • "$@" — expands each positional parameter as a separate, individually quoted word. Always use this form.
  • $@ or $* unquoted — subject to word splitting and globbing.
  • "$*" — joins all parameters into one word (rarely what you want).
#!/usr/bin/env bash
set -euo pipefail

# Safe wrapper: forward all arguments quoted
grep_wrapper() {
    local pattern="$1"
    shift
    # "$@" preserves each file argument as one token
    grep -rn "$pattern" "$@"
}

# Usage: ./script 'error msg' /var/log/syslog '/path with spaces/app.log'
echo 'Searching current script for "safe":'
grep_wrapper 'safe' "$0"

Command Injection via eval and Unvalidated Input

eval re-parses its argument as shell code. Any untrusted data reaching eval can execute arbitrary commands.

Common dangerous patterns:

  • eval "$user_input"
  • eval echo \$$var (indirect variable lookup)
  • Passing user data through bash -c "$input"

Rule: never pass untrusted input to eval or bash -c. Use safe Bash alternatives:

  • Indirect expansion: ${!varname} instead of eval echo \$$varname
  • Associative arrays for dynamic key-value lookup
  • Functions instead of generated command strings
#!/usr/bin/env bash
set -euo pipefail

# Simulated attacker-supplied variable name
VARNAME='PATH; echo INJECTED'

# UNSAFE: eval lets attacker run 'echo INJECTED'
# eval "echo \$$VARNAME"

# SAFE: indirect expansion only resolves valid variable names
# First validate that VARNAME is a legal identifier
if [[ "$VARNAME" =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]]; then
    echo "Value: ${!VARNAME}"
else
    echo "ERROR: invalid variable name: '$VARNAME'" >&2
    exit 1
fi

Input Validation: Allowlists Over Denylists

Rejecting known-bad characters (a denylist) is fragile — attackers find encodings or characters you forgot. Instead, allowlist: accept only characters you know are safe.

Allowlist strategies in Bash:

  • Regex match: [[ "$input" =~ ^[A-Za-z0-9_-]+$ ]]
  • Pattern match: case "$input" in [A-Za-z0-9]*) ... ;; esac
  • Enum check: compare against a fixed set of valid values

Validate at the boundary — as soon as input enters the script — before it touches any command.

#!/usr/bin/env bash
set -euo pipefail

validate_username() {
    local name="$1"
    # Allowlist: only lowercase letters, digits, underscore, hyphen; 1-32 chars
    if [[ ! "$name" =~ ^[a-z0-9_-]{1,32}$ ]]; then
        echo "ERROR: invalid username '${name}'" >&2
        return 1
    fi
    echo "Username accepted: $name"
}

validate_username 'alice'          # OK
validate_username 'bob_smith-2'    # OK
validate_username 'root; rm -rf /' # REJECTED
validate_username '../etc/passwd'   # REJECTED

Using Arrays to Pass Arguments Safely

When you need to build a command dynamically — conditionally adding flags, looping over inputs — use a Bash array instead of concatenating strings.

String concatenation collapses all structure; a Bash array preserves each argument as a discrete element with no shell re-parsing.

  • Declare: args=()
  • Append: args+=(--flag "$value")
  • Execute: command "${args[@]}"

"${args[@]}" expands every element as a separate, individually quoted word — exactly like "$@".

#!/usr/bin/env bash
set -euo pipefail

# Build a find command safely with an array
BASEDIR='/tmp'
USER_PATTERN='*.log'   # could come from user input (validate first!)
MAX_DAYS=7

cmd=(find "$BASEDIR" -type f -name "$USER_PATTERN" -mtime "+${MAX_DAYS}")

# Optionally add -delete only when requested
DELETE=false
if [[ "$DELETE" == 'true' ]]; then
    cmd+=(-delete)
fi

echo "Running: ${cmd[*]}"
"${cmd[@]}"

The -- Separator: Protecting Against Flag Injection

Even a correctly quoted argument can be misinterpreted as an option flag if it begins with -. Consider rm "$file" where file='-rf .': the quoting protects word splitting, but rm still interprets -rf as flags.

The POSIX convention -- signals end of options to most GNU/BSD utilities. Everything after -- is treated as a positional argument, never as a flag.

#!/usr/bin/env bash
set -euo pipefail

# Simulate a dangerous filename supplied by the user
FILENAME='-rf /tmp/safe_demo_target'

mkdir -p /tmp/safe_demo_target
touch /tmp/safe_demo_target/keep_me.txt

echo 'Files before:'
ls /tmp/safe_demo_target/

# UNSAFE (flag injection — do NOT uncomment in real systems):
# rm "$FILENAME"

# SAFE: -- ends option processing; filename is treated literally
rm -- "$FILENAME" 2>&1 || echo "No such file (attack neutralised): $FILENAME"

echo 'Files after:'
ls /tmp/safe_demo_target/
rm -rf /tmp/safe_demo_target

Sanitising Input for SQL and External Tools

When Bash scripts invoke database CLIs (psql, mysql), curl with user-supplied URLs, or similar tools, two additional layers apply:

  • Parameterised queries: Never interpolate user data into SQL strings. Pass values via -v in psql or --data-urlencode in curl.
  • Separate data from code: Use printf with a literal format string; never let user input be the format string.

The example below queries PostgreSQL safely, keeping the user-supplied value completely out of the SQL text.

#!/usr/bin/env bash
set -euo pipefail

# Validate first: only allow alphanumeric usernames
USERNAME="${1:-alice}"
if [[ ! "$USERNAME" =~ ^[a-z0-9_]{1,32}$ ]]; then
    echo 'ERROR: invalid username' >&2
    exit 1
fi

# UNSAFE:
# psql -c "SELECT * FROM users WHERE name = '$USERNAME';"

# SAFE: pass value as a psql variable, never inside the SQL text
# psql -v username="$USERNAME" -c 'SELECT * FROM users WHERE name = :username;'

# Demonstrate the principle with printf (safe format string usage)
printf 'Query would use username: %s\n' "$USERNAME"

Hardening Checklist: Putting It All Together

A production-grade secure Bash script combines every technique from this lesson into a consistent, layered defence. Here is a minimal but complete hardened template:

  • set -euo pipefail — exit on error, treat unset variables as errors, propagate pipe failures.
  • Validate at entry — allowlist every external input before it touches any command.
  • Quote everything"$var", "$@", "${array[@]}" — no exceptions unless you need splitting.
  • Use arrays for dynamic command construction.
  • Prefix arguments with -- when passing user-supplied filenames or strings.
  • Never use eval with untrusted data; prefer ${!var} for indirect lookup.
  • Restrict permissions — run scripts with the minimum required privileges; avoid sudo inside scripts that accept user input.
#!/usr/bin/env bash
# Hardened template — safe argument injection prevention
set -euo pipefail
IFS=$'\n\t'

#--- 1. Validate inputs at the boundary ---
SEARCH_DIR="${1:-}"
PATTERN="${2:-}"

[[ -z "$SEARCH_DIR" || -z "$PATTERN" ]] && { echo 'Usage: script <dir> <pattern>' >&2; exit 1; }
[[ ! "$SEARCH_DIR" =~ ^[A-Za-z0-9/_.-]+$ ]] && { echo 'ERROR: unsafe directory path' >&2; exit 1; }
[[ ! "$PATTERN" =~ ^[A-Za-z0-9._-]+$ ]]    && { echo 'ERROR: unsafe pattern'        >&2; exit 1; }

#--- 2. Build command with an array ---
cmd=(find -- "$SEARCH_DIR" -type f -name "$PATTERN")

#--- 3. Execute — no string interpolation, no eval ---
echo "Executing: ${cmd[*]}"
"${cmd[@]}"

Knowledge Check: Quoting and Injection Prevention

Test your understanding of the key concepts in this lesson.

Lesson Recap: Preventing Command and Argument Injection

You have covered the complete defensive toolkit for safe Bash input handling:

  • Word splitting and glob expansion are the root mechanisms that turn unsafe variables into injection vectors.
  • Double-quote every variable ("$var", "$@", "${arr[@]}") to suppress both threats.
  • Use "$@" — never $@ or $* unquoted — when forwarding arguments.
  • Prefix user-supplied filenames with -- to prevent flag injection.
  • Allowlist all external input with a regex guard ([[ $v =~ ^pattern$ ]]) before it reaches any command.
  • Build dynamic commands with arrays (cmd+=()"${cmd[@]}"), never string concatenation.
  • Eliminate eval and bash -c "$input"; use ${!varname} for safe indirect expansion.
  • Always open scripts with set -euo pipefail and IFS=$'\n\t' for a hardened baseline.

These practices, applied consistently from the first line of every script, reduce the attack surface of Bash to near zero for injection-class vulnerabilities.

Frequently asked questions

Is the “Preventing Command and Argument Injection” lesson free?

Yes — the full text of “Preventing Command and Argument Injection” is free to read here on the web, and the DevOps Bootcamp course includes 4 lessons in total. To practise it interactively (a built-in code editor and a 24/7 AI tutor) and unlock the rest of the DevOps Bootcamp course, upgrade to CoddyKit PRO.

What will I learn in “Preventing Command and Argument Injection”?

Quote, validate, and array-pass untrusted input to eliminate word-splitting and eval-based injection. You practise DevOps Bootcamp with hands-on code you run directly in the browser, and a 24/7 AI tutor answers your questions as you work through the lesson.

Do I need any experience to start DevOps Bootcamp?

No prior experience is required. DevOps Bootcamp on CoddyKit is structured for beginners through advanced learners; this is — lesson 1 of 4, so you can start here or from the beginning and move at your own pace.

How long does the “Preventing Command and Argument Injection” lesson take?

Most CoddyKit lessons take about 5–10 minutes. Each one is bite-sized and interactive, so you make steady progress and pick up exactly where you left off across the web and the app.

Can I write and run code in this DevOps Bootcamp lesson?

Yes. Every DevOps Bootcamp lesson includes a built-in code editor, so you write and run real code right in your browser and get instant AI feedback — no local setup required.

All lessons in this course

  1. Preventing Command and Argument Injection
  2. Secure Secret Handling and Environment Hygiene
  3. Least-Privilege Execution and sudo Discipline
  4. Static Analysis and Auditing with ShellCheck
← Back to DevOps Bootcamp