Shell Lesson 25 of 42

Shell Security: Command Injection, Quoting Hardening, IFS Attacks, set -f & Input Validation — Treating Shell as an Attack Surface

Shell is uniquely vulnerable. Every other major language has a clear separation between code and data — strings stay strings unless you specifically run them as code. In shell, the rules are inverted: every variable expansion is potentially executable code, and putting a value into a command is implicitly running it through a tokenizer, glob expander, and word splitter.

This means the same defensive habits that prevent SQL injection in your web app — parameterized queries, escape functions, whitelisting — must be applied with even more discipline in shell, because shell has no built-in safe interface. There is no “prepared statement” in bash. The only safety is your quoting discipline.

This lesson is the security side of shell:

If you write any shell that handles user input, processes filenames from untrusted sources, runs as root, or runs in a container that crosses trust boundaries — this is required reading.


1. The model — why shell is so injection-prone

Consider the difference between Python and shell:

# Python: explicit data → code conversion
import subprocess
user_input = "; rm -rf /"
subprocess.run(["echo", user_input])     # Safe: argv list, no shell
# Shell: implicit data → code conversion
user_input="; rm -rf /"
echo $user_input                          # Word-splits and globs $user_input
                                          # Quoting saves you:
echo "$user_input"                        # Treated as one argument

In shell, the boundary between data and code is the quote character. Inside "$var", $var is data. Outside quotes, $var becomes a sequence of arguments that may include glob patterns. Every unquoted variable is a potential injection vector.

That’s the model. Most shell security boils down to: did you remember to quote? did you validate the input? did you use the right primitive?


2. Command injection — the canonical vulnerability

2.1 Through eval

# DANGEROUS:
read -p "What's your name? " name
eval "echo Hello, $name"

If the user types ; rm -rf /; echo, the eval runs echo Hello, ; rm -rf /; echo. Game over.

Rule: never eval user input. If you must do dynamic evaluation, validate the input against a strict whitelist first:

read -r -p "What's your name? " name
case $name in
  [A-Za-z][A-Za-z0-9_-]*)
    eval "echo Hello, $name"
    ;;
  *)
    echo "Invalid name" >&2
    exit 1
    ;;
esac

Better yet: don’t use eval at all. The same effect:

read -r name
printf 'Hello, %s\n' "$name"

2.2 Through unquoted command substitution

# DANGEROUS — file contents fed unquoted to a command:
filenames=$(cat user-supplied-list.txt)
rm $filenames                             # Word-splits, globs

If user-supplied-list.txt contains:

file1.txt
* /important

Then rm $filenames becomes rm file1.txt * /important and removes everything in the current directory. The * glob-expands.

Fix: never use for x in $(...). Use while read:

while IFS= read -r filename; do
  rm -- "$filename"        # Quoted, with -- to stop flag-parsing
done < user-supplied-list.txt

while read reads one line at a time and quoting "$filename" keeps it as a single value. -- stops rm from interpreting filenames starting with - as flags.

2.3 Through bash -c "..."

# DANGEROUS:
ssh remote "rm -rf $TARGET"

If $TARGET is /tmp; reboot, the remote runs rm -rf /tmp; reboot.

Fix: parameterize via the remote shell’s positional args:

ssh remote 'rm -rf -- "$1"' bash "$TARGET"

That sends a literal command (no $TARGET expansion locally) and passes $TARGET as the first positional arg to the remote bash, which is then quoted on the remote side. The injection vector is closed.

2.4 Through cron and at

# DANGEROUS — same eval pattern in a different wrapper:
echo "rm -rf $(cat target.txt)" | at now + 1 hour

at accepts a shell command. Anything in target.txt is now code.

Fix: prepare the file path with validation, then pass via env var the at job reads:

target=$(cat target.txt)
case $target in
  /tmp/*) ;;            # Whitelist: only paths under /tmp
  *) exit 1 ;;
esac
TARGET=$target at now + 1 hour <<'EOF'
[ -n "${TARGET:-}" ] && rm -rf -- "$TARGET"
EOF

The <<'EOF' (single-quoted heredoc) prevents expansion in the script body — $TARGET remains literal until at executes.

2.5 Through filenames

This is the most insidious. Filenames can contain any character except / and NUL. That includes spaces, newlines, semicolons, tabs, even unicode.

# Attacker creates a file:
touch -- '$(rm -rf /tmp/important)'

# Your script:
for f in *; do
  echo "Processing $f..."          # OK, $f is quoted
  cmd $f                           # DANGEROUS, unquoted
done

When $f is $(rm -rf /tmp/important) and unquoted, it’s command-substituted. A file with a malicious name becomes RCE.

Fix: always quote $f:

for f in *; do
  cmd -- "$f"
done

The -- and quoting protect against both glob-expansion and command substitution.

2.6 The -e flag injection on echo / printf

Some commands interpret data as flags if it starts with -:

# Attacker creates "-rf important_dir/":
filename="-rf important_dir/"
rm $filename                       # rm interprets -rf as flags!

Even rm "$filename" is unsafe because rm sees -rf important_dir/ as a flag set.

Fix: use -- to terminate flag-parsing:

rm -- "$filename"

After --, rm treats every remaining argument as a filename. Always use -- when filenames could come from untrusted sources. rm, mv, cp, chmod, chown all support it. Some programs (dd, older find) don’t — for those, prefix the filename with ./ to disambiguate:

rm "./$filename"                   # path starts with ./, can't be a flag

3. Quoting — the day-to-day discipline

The single most important security habit: quote every variable expansion, every command substitution, in every context.

3.1 The five rules

# 1. Quote every $var:
echo "$var"
[[ "$var" == "expected" ]]
cmd "$var"

# 2. Quote every $(command):
result="$(curl -s "$URL")"

# 3. Quote inside heredocs (or use 'EOF' to disable expansion):
cat <<'EOF'                        # No expansion
$var stays literal here
EOF

# 4. Use [[ ]] over [ ] in bash (no word-splitting issues):
[[ -f $file ]]                     # OK in [[ ]]
[ -f "$file" ]                     # Required in [ ]

# 5. Use -- to separate flags from values:
rm -- "$file"
git checkout -- "$path"
mv -- "$old" "$new"

3.2 The contexts where quotes are mandatory

Context Why
cmd $var Word-splits and globs. Always: cmd "$var"
[ "$x" = "$y" ] [ ] requires quoting; unquoted = syntax errors with empty/special values
for x in $list Word-splits. Use array: for x in "${list[@]}"
case $var in ... esac Word-splits. Use: case "$var" in ... esac (or just case $var in — case is special)
IFS= read -r x The -r is required to prevent backslash interpretation
${var} Quote anyway: "${var}"

case $var in is a special exception — it doesn’t word-split — but for consistency and grep-ability, quote it anyway: case "$var" in.

3.3 The places quotes don’t help

# Quoting doesn't save you from -e injection:
filename="-rf /"
echo "$filename"                   # Echos "-rf /" — fine
rm "$filename"                     # rm sees -rf /, deletes everything

The fix is --, not quoting. Quoting protects against word-splitting and glob expansion; -- protects against flag-injection. You need both.

# Quoting doesn't save you from eval:
input='"; rm -rf /"'
eval "echo $input"                 # Even with quotes around the eval arg, the inner is interpreted

Eval is a different problem; no amount of outer-quoting saves you. Validate or avoid.

3.4 The -- rule for every dangerous command

Memorize this list — these commands accept paths and have flags that take action:

rm -- "$path"
mv -- "$src" "$dst"
cp -- "$src" "$dst"
chmod -- "$mode" "$path"
chown -- "$user" "$path"
git checkout -- "$path"
ln -- "$src" "$dst"
ls -- "$path"
cat -- "$path"

Some commands don’t support -- (looking at you, dd). For those, prefix with ./:

file="./$untrusted_name"
dd if="$file" of=output

3.5 Finding unquoted variables — shellcheck rules

shellcheck is your first line of defense. Specifically, these rules:

Run shellcheck in CI and treat all SC2xxx codes as errors. Use # shellcheck disable=SC2086 only with a justifying comment.


4. IFS attacks — when the field separator is the vulnerability

IFS (Internal Field Separator) controls how shell word-splits unquoted variables. If you don’t control IFS, an attacker who can set it (via environment variables or via piped input) can influence how your script parses things.

4.1 The basic IFS exploit

#!/usr/bin/env bash
# Naïve script that reads /etc/passwd-like format:
while read -r line; do
  fields=($line)                   # ← word-splits on $IFS
  echo "User: ${fields[0]}, Shell: ${fields[6]}"
done < /etc/passwd

If your environment has been polluted with IFS=: (which is correct for /etc/passwd), it works. If not, it breaks.

But the security issue: the script behaviour depends on outside IFS. If an attacker can run your script with a custom IFS, they can change parsing.

4.2 The defense: pin IFS at the top of every script

#!/usr/bin/env bash
set -Eeuo pipefail
IFS=$'\n\t'                        # Newline and tab only

This is the strict-mode line from L13. It’s a security control, not just a bug fix.

4.3 IFS for parsing fields safely

When you legitimately need to split on a specific character, set IFS for that one read:

while IFS=':' read -r username password uid gid name home shell; do
  echo "$username -> $shell"
done < /etc/passwd

IFS=':' is set only for the duration of the read builtin, then reverts. No global state pollution.

4.4 The IFS injection through env vars

# DANGEROUS: scripts that don't pin IFS inherit attacker's IFS.
$ IFS='/' ./vulnerable-script.sh /etc/passwd

If the script does for x in $path, it’ll split on / instead of whitespace. With a clever payload, an attacker can sometimes get arbitrary command execution.

Fix: IFS=$'\n\t' at the top, every script, every time.


5. TOCTOU — time-of-check, time-of-use races

Suppose you check a file’s properties, then act on it. Between check and action, the file changes. This is the canonical race-condition class.

5.1 The vulnerable pattern

# Check that file is owned by current user:
if [[ $(stat -c %U "$f") = $USER ]]; then
  cat "$f"                         # ← race: file could be replaced between check and read
fi

An attacker (or another process) can replace "$f" with a symlink to /etc/shadow between the stat and the cat. The check passes for the file the user owns, but the read goes to whatever "$f" points to now.

5.2 The defense: open the file once

Use file descriptors. Once the file is open, the descriptor refers to that exact inode regardless of what happens to the path:

exec 3<"$f"                        # Open file descriptor 3 for reading
# Now check via /proc/self/fd/3 (Linux) or use stat -L /dev/fd/3:
if [[ $(stat -L -c %U /dev/fd/3) = $USER ]]; then
  cat <&3                          # Read from the descriptor, not the path
fi
exec 3<&-                          # Close fd 3

This pattern is uglier but eliminates the race: the file is referenced by inode, not by path.

5.3 The “use a tempdir owned by you” defense

For staging operations:

# Create a tempdir under /tmp that ONLY you can write to:
TMPDIR=$(mktemp -d -t myscript.XXXXXX)
chmod 700 "$TMPDIR"
trap 'rm -rf "$TMPDIR"' EXIT

# Now do all work inside $TMPDIR. Other users can't replace files there.
cp -- "$input" "$TMPDIR/staged"
process "$TMPDIR/staged"

mktemp -d creates a dir with a random name (unguessable) and 700 perms (only you). Symlink attacks against it are infeasible because attackers can’t even read the dir.

5.4 Symlink races — the historic /tmp bug

# DON'T:
echo "data" > /tmp/myscript.tmp
mv /tmp/myscript.tmp /var/lib/myscript/data

If /tmp/myscript.tmp is a fixed name in a world-writable dir, an attacker can pre-create a symlink: ln -s /etc/shadow /tmp/myscript.tmp. Your script then writes to /etc/shadow. (Modern Linux has the protected_symlinks sysctl, which mitigates this in /tmp specifically — but rely on it at your peril; not all distros enable it.)

Always use mktemp for temp files:

tmpfile=$(mktemp -t myscript.XXXXXX)
trap 'rm -f "$tmpfile"' EXIT
echo "data" > "$tmpfile"
mv -- "$tmpfile" /var/lib/myscript/data

mktemp creates a file with O_EXCL (atomic; fails if it already exists) and a random name. Race-free.


6. Input validation — whitelist, never blacklist

The cardinal rule: list what’s allowed; reject everything else. Trying to filter out bad characters always misses some.

6.1 The pattern

validate_username() {
  local input=$1
  case $input in
    [a-z][a-z0-9_-]*) return 0 ;;        # Allowlist: lowercase letter then [a-z0-9_-]
    *) return 1 ;;
  esac
}

if ! validate_username "$user"; then
  echo "Invalid username: $user" >&2
  exit 1
fi

The case statement uses POSIX glob patterns to whitelist. Anything not matching is rejected.

6.2 Common validations

# Numeric:
case $n in
  ''|*[!0-9]*) echo "not a number"; exit 1 ;;
esac
# OR (shorter, bash):
[[ $n =~ ^[0-9]+$ ]] || { echo "not a number"; exit 1; }

# Hostname (RFC 1123):
[[ $host =~ ^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$ ]]

# IPv4:
ipv4_octets='25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?'
[[ $ip =~ ^($ipv4_octets)\.($ipv4_octets)\.($ipv4_octets)\.($ipv4_octets)$ ]]

# Email (simplified — full RFC 5322 is ~6KB regex):
[[ $email =~ ^[A-Za-z0-9._-]+@[A-Za-z0-9-]+(\.[A-Za-z0-9-]+)+$ ]]

# Path under a base dir (no traversal):
case $path in
  /var/lib/myapp/*)
    case $path in
      *..*) echo "no traversal"; exit 1 ;;     # Reject .. anywhere
    esac
    ;;
  *) echo "outside allowed dir"; exit 1 ;;
esac

# Filename (no shell-special chars):
case $filename in
  *[\;\&\|\`\$\<\>\(\)\\\'\"]*)
    echo "invalid chars"; exit 1 ;;
  ../*|*/..|*/../*)
    echo "traversal"; exit 1 ;;
esac

6.3 Path canonicalization

When you accept a path from a user, canonicalize it before validating, otherwise tricks like ../etc/passwd slip through:

# Canonicalize:
canon=$(realpath -- "$path" 2>/dev/null) || { echo "bad path"; exit 1; }

# Now check that canon is under your allowed prefix:
case $canon in
  /var/lib/myapp/*) ;;
  *) echo "outside allowed dir"; exit 1 ;;
esac

# Use $canon, not $path, for further operations:
process "$canon"

realpath -f resolves all .., symlinks, and weird path forms. Always canonicalize before authorization checks.

6.4 Null bytes — the embedded surprise

POSIX paths can’t contain NUL (\0), but environment variables and stdin can. Some validators that look for .. miss \0-padded versions:

"file\0../etc/passwd"

In bash, read truncates at \0 by default, so this rarely bites. But if you handle binary input via IFS= read -r -d '', validate against null bytes explicitly.


7. Environment-variable attacks

7.1 The Shellshock class

CVE-2014-6271: bash parsed function definitions out of environment variables. An attacker who could set env vars (via HTTP headers in CGI, for example) could execute arbitrary code:

HTTP_HEADER="() { :;}; rm -rf /"

When bash inherited that env, it parsed the function definition and ran the trailing command. Modern bash patches this, but the concept — env vars influence shell behaviour — is general.

7.2 The defense: pin your environment

At the top of every script:

#!/usr/bin/env bash
set -Eeuo pipefail
IFS=$'\n\t'

# Pin PATH to known directories:
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
export PATH

# Unset variables that could change behaviour:
unset BASH_ENV ENV CDPATH GLOBIGNORE
unset LD_PRELOAD LD_LIBRARY_PATH
unset IFS                          # Reset to default (then we set it again above)

IFS=$'\n\t'                        # Re-set after unset

# Pin locale:
LC_ALL=C
export LC_ALL

The unset section removes attacker-controlled variables that could influence:

For scripts that run setuid or in a privilege boundary, this hardening is critical.

7.3 The bash -p flag — privileged mode

#!/usr/bin/env bash -p

bash -p (privileged mode) is automatically enabled when bash detects setuid/setgid execution. It:

If your script runs setuid (rarely a good idea) or is invoked via sudo, ensure -p is in effect.


8. The set -f (noglob) defense

Globbing is a feature, but it’s also an attack vector. If a variable’s contents are inserted into a command and contain *, the shell expands them:

$ args="* /etc/passwd"
$ ls $args             # ← lists everything in cwd, then /etc/passwd

If you don’t want globbing for a section of your script:

set -f                 # Disable globbing
process_user_input "$arg1" "$arg2"
set +f                 # Re-enable

Or as a discipline: disable globbing for the whole script and only enable it where you want it:

#!/usr/bin/env bash
set -Eeuo pipefail -f             # Note the -f added
IFS=$'\n\t'

# In a controlled section where you want globs:
matching_files() (
  set +f                          # Subshell: doesn't affect outer
  for f in /var/log/*.log; do
    printf '%s\n' "$f"
  done
)

set -f with IFS=$'\n\t' is the maximally-defensive baseline. Combined with quoting, it eliminates virtually all “data became code” risks.


9. Locking down eval — when you really need dynamic execution

Sometimes you legitimately need eval (dynamic variable names, computed function calls). The discipline:

9.1 Validate the input before eval

# Set a variable named ATTR_$key to $value:
set_attr() {
  local key=$1 value=$2
  case $key in
    [A-Za-z_][A-Za-z0-9_]*) ;;       # Whitelist: identifier characters only
    *) echo "Invalid key: $key" >&2; return 1 ;;
  esac
  eval "ATTR_$key=\$value"           # $value is left as a literal expansion
}

The whitelist on $key prevents injection. The \$value (escaped $) means the eval’s bash parses it as a variable reference, not interpolating its content into the command string. So even if $value contains ; rm -rf /, it’s stored as a literal string in ATTR_foo.

9.2 Prefer printf -v

For setting variables dynamically, printf -v is safer than eval:

varname="ATTR_$key"
printf -v "$varname" '%s' "$value"   # No code injection

printf -v writes to a variable named by $varname. It doesn’t re-parse content. Use it whenever you can.

9.3 Prefer associative arrays

In bash 4+, just use an associative array — no eval needed:

declare -A attrs
attrs[$key]=$value
echo "${attrs[$key]}"

Validate $key if it comes from user input (to prevent the key being something weird), but no eval.


10. Other defensive patterns

10.1 read -r always, never plain read

# DANGEROUS:
read line                        # Backslash-escapes are interpreted

# CORRECT:
read -r line                     # Treats backslashes literally

-r is not a perf flag — it’s a correctness flag. Without it, a value of Hello\nWorld becomes “HelloWorld” (the \n is interpreted as a newline-escape, then… it’s complicated). Always -r.

10.2 IFS= read -r when reading lines

# DANGEROUS — IFS strips leading/trailing whitespace from $line:
read -r line

# CORRECT — line preserves all whitespace:
IFS= read -r line

IFS= for the duration of one read disables word-splitting. Together with -r, the line is captured exactly as-is.

10.3 Safe find

# DANGEROUS — output of find is space/newline split:
for f in $(find . -name '*.tmp'); do rm "$f"; done

# CORRECT — null-delimited:
find . -name '*.tmp' -print0 | xargs -0 rm --
# OR:
find . -name '*.tmp' -delete         # find deletes directly, no shell parsing

-print0 and -0 use NUL as the separator, immune to filename weirdness. -delete does the deletion in find itself, no shell loop needed.

10.4 git config is a hidden code-execution vector

Some git invocations execute code from git config:

# In a malicious repo's .git/config:
[core]
    sshCommand = "rm -rf ~"

When you git pull such a repo, sshCommand is invoked. Don’t run git operations on untrusted repositories without safe.directory configured.

git -c protocol.file.allow=user and safe.directory settings (modern git) mitigate the worst of this. Audit your CI for git operations on untrusted refs.


11. CI rules — locking security in

11.1 shellcheck severity policy

Add a .shellcheckrc to enforce strictness:

# .shellcheckrc
disable=SC2153             # Variable names that look similar (annoying false-positives)
enable=all                  # Enable optional rules including security ones
external-sources=true
shell=bash
severity=warning

Treat all SC20xx and SC10xx codes as errors in CI.

11.2 The “no eval” lint

If your codebase doesn’t need eval, ban it:

# Pre-commit hook:
if grep -nP '\beval\s' bin/* lib/*.sh; then
  echo "ERROR: eval is forbidden. See SECURITY.md."
  exit 1
fi

Same for bash -c "$VAR" patterns where the variable could contain user input.

11.3 Static taint analysis

For high-stakes shell (anything with privileged execution, secrets, or user input), tools like shellharden automatically rewrite shell to add quotes:

shellharden --transform script.sh

It’s not foolproof (a tool can’t fully analyse intent), but it catches most of the SC2086 class automatically.

11.4 Run as least privilege

# In systemd unit or Dockerfile:
User=myapp
NoNewPrivileges=true
PrivateTmp=true
ReadOnlyPaths=/etc /usr
ReadWritePaths=/var/lib/myapp

If your script doesn’t need root, don’t run it as root. If it does need privilege, use the smallest possible set of capabilities (AmbientCapabilities=CAP_NET_BIND_SERVICE).


12. A vulnerability walk-through — fixing a real script

12.1 The vulnerable script

#!/bin/bash
# update-user.sh USER NEW_SHELL
# Updates a user's login shell.

USER=$1
SHELL=$2

if id $USER > /dev/null; then
  echo "Updating $USER's shell to $SHELL"
  usermod -s $SHELL $USER
fi

Vulnerabilities:

  1. $USER and $SHELL are unquoted everywhere.
  2. No input validation.
  3. id $USER is exploitable: USER='alice; reboot'.
  4. usermod -s $SHELL $USER — same problem.
  5. $SHELL happens to be a reserved name (the login shell of the running user). The script clobbers it.

12.2 The hardened script

#!/usr/bin/env bash
set -Eeuo pipefail -f
IFS=$'\n\t'

PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
export PATH

# Argument validation:
[[ $# -eq 2 ]] || { echo "usage: $0 USER SHELL_PATH" >&2; exit 1; }

target_user=$1
new_shell=$2

# Whitelist user (POSIX usernames):
case $target_user in
  [a-z_][a-z0-9_-]*) ;;
  *) echo "Invalid username: $target_user" >&2; exit 1 ;;
esac

# Whitelist shell — must be in /etc/shells:
if ! grep -Fxq -- "$new_shell" /etc/shells; then
  echo "Shell not allowed: $new_shell" >&2
  exit 1
fi

# Verify user exists:
if ! id -- "$target_user" >/dev/null 2>&1; then
  echo "User does not exist: $target_user" >&2
  exit 1
fi

echo "Updating $target_user's shell to $new_shell"
usermod -s "$new_shell" -- "$target_user"

What changed:

The vulnerable version is a CVE waiting to happen. The hardened version validates everything and quotes everything. Same logic, ~3x more lines, eliminated injection.


13. Quick reference card

The checklist

☐ #!/usr/bin/env bash, then `set -Eeuo pipefail -f`
☐ IFS=$'\n\t' immediately after
☐ Pin PATH at the top
☐ unset BASH_ENV ENV CDPATH GLOBIGNORE LD_PRELOAD LD_LIBRARY_PATH
☐ Quote every $var: "$var", "$@", "${arr[@]}"
☐ Use [[ ]] in bash scripts; [ "$x" = "$y" ] in POSIX
☐ Use -- to separate flags from values: rm -- "$file"
☐ Never `eval` user input. If unavoidable, whitelist first.
☐ Use printf -v instead of eval for dynamic vars
☐ Use mktemp for temp files; mktemp -d for temp dirs
☐ Use -print0 / -d '' for filename iteration
☐ Validate input with case allowlists, never blacklists
☐ Canonicalize paths with realpath before authorization checks
☐ shellcheck in CI; treat all SC2xxx as errors

The 7 commandments of shell security

  1. Quote everything. Every variable expansion, every command substitution, every parameter.
  2. Validate input with whitelists. Reject anything not matching expected patterns.
  3. Use -- to terminate flag-parsing in every command that accepts paths from variables.
  4. Pin the environment: PATH, IFS, LC_ALL, unset attacker-controlled vars.
  5. Don’t eval user input. Use parameter expansion, printf -v, or associative arrays.
  6. Use mktemp for temp files. Never use predictable names in /tmp.
  7. Run shellcheck in CI. It catches the most common 80% of vulnerabilities for free.

Memorize these patterns

# Pin environment:
set -Eeuo pipefail -f
IFS=$'\n\t'
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
export PATH
unset BASH_ENV ENV CDPATH GLOBIGNORE LD_PRELOAD LD_LIBRARY_PATH

# Validate:
case $input in
  [A-Za-z][A-Za-z0-9_-]*) ;;
  *) exit 1 ;;
esac

# Safe filename iteration:
find /path -type f -print0 | while IFS= read -r -d '' f; do
  process -- "$f"
done

# Safe temp file:
tmp=$(mktemp -t myscript.XXXXXX)
trap 'rm -f -- "$tmp"' EXIT

# Safe dynamic variable:
printf -v "VAR_$validated_key" '%s' "$value"

14. Wrap-up

Shell scripts are uniquely exposed because shell’s core abstraction is text-as-code. Every variable expansion is a parse, every external command is an interpretation, every pipe is a context shift. There is no parameterized-query equivalent — only quoting discipline.

The defense is simple in concept, exhausting in practice:

Combined with shellcheck in CI and the strict-mode preamble we’ve reinforced throughout this course, these habits make your shell scripts as secure as they can be. They’re not bulletproof — shell will never be a memory-safe language — but they reduce the attack surface to the same level as any well-written CGI script: small but non-zero.

For high-stakes scripts (running as root, processing untrusted input, in containers crossing trust boundaries), apply the full hardening: set -f, unset of attacker-controlled vars, canonicalization before auth, whitelist regex on every input, -- everywhere. The cost is a longer preamble; the benefit is closing virtually every shell-injection class.

Next: L26 — secrets handling. We’ll cover env-vars-vs-files, vault integration, ephemeral credentials, and the no_log discipline that keeps secrets out of journalctl, ps output, and shell history.

shellbashsecurityinjectionquotingifsvalidationhardening
Need this built for real?

Vinod is a Senior Cloud Architect (22+ yrs) — available for Azure / AWS / GCP architecture, landing zones, and migrations.

Work with me

Comments