Security: Writing Safe Scripts
Chapter 7 — Security: Writing Safe Scripts
Shell scripts are glue code that runs close to the operating system, often with elevated privileges, and routinely handles data from untrusted sources — user input, environment variables, filenames, network responses. A single unquoted variable or careless eval can turn a maintenance script into a privilege-escalation vector. This chapter covers the specific vulnerabilities that affect Bash scripts and the patterns that reliably prevent them.
1 — Injection Vulnerabilities
Shell injection happens when attacker-controlled data is interpreted as shell syntax rather than plain text. The root cause is almost always unquoted variable expansion or dynamic command construction.
Argument injection via unquoted variables
# VULNERABLE: unquoted $filename undergoes word-splitting and globbing filename="report 2024.pdf" rm $filename # runs: rm report 2024.pdf — two args, not one filename="-rf /" rm $filename # runs: rm -rf / ← catastrophic # SAFE: always double-quote variable expansions rm "$filename" # SAFE: use -- to end option processing before filenames rm -- "$filename" # even "-rf /" is treated as a literal filename
Command injection via eval and dynamic commands
# VULNERABLE: user input passed to eval read -r user_input eval "echo Hello, $user_input" # Input: "; rm -rf ~" → runs: echo Hello, ; rm -rf ~ # VULNERABLE: constructing commands by concatenation cmd="convert $input_file -resize 800x $output_file" eval "$cmd" # input_file="x.png -write /etc/passwd x.png" → writes /etc/passwd # SAFE: pass arguments as separate words — never concatenate into one string convert "$input_file" -resize 800x "$output_file" # SAFE: when eval is truly needed, quote with printf %q eval "myvar=$(printf '%q' "$user_input")" # printf %q shell-escapes the value so it can only ever be a string literal
Injection via $IFS manipulation
# If an attacker can set IFS, word-splitting changes everywhere IFS="/" path="/usr/bin/bash" for part in $path; do echo "$part"; done # splits on / not space # Defence: reset IFS at the top of every script that may run in a # manipulated environment, or always quote expansions (quotes suppress IFS) IFS=$' \t\n' # restore default at script start
2 — Safe Input Validation
# Allowlist approach — only accept known-good patterns validate_username() { local name="$1" if [[ "$name" =~ ^[a-zA-Z0-9_-]{1,32}$ ]]; then return 0 else printf 'Invalid username: %q\n' "$name" >&2 return 1 fi } validate_integer() { local n="$1" local min="${2:--2147483648}" local max="${3:-2147483647}" [[ "$n" =~ ^-?[0-9]+$ ]] || { echo "not an integer: $n" >&2; return 1; } (( n >= min && n <= max )) || { echo "out of range: $n" >&2; return 1; } } validate_path() { # Reject path traversal and null bytes local p="$1" local base="${2:-/safe/basedir}" # required prefix [[ "$p" != *".."* ]] || { echo "traversal attempt: $p" >&2; return 1; } [[ "$p" != *$'\0'* ]] || { echo "null byte in path" >&2; return 1; } # Resolve to canonical path then check prefix local real real=$(realpath -m "$p") # -m: don't require the path to exist [[ "$real" == "$base"/* || "$real" == "$base" ]] || { echo "path escapes base: $real" >&2; return 1 } } # Always validate before use validate_username "$1" || exit 1 validate_path "$2" /var/uploads || exit 1
3 — Safe eval Patterns
eval is dangerous but sometimes unavoidable — for example when dynamically constructing variable names or sourcing configuration that must be Bash syntax. These patterns make it as safe as possible.
# printf %q — shell-quote a value so it survives one round of eval declare -A config safe_set_var() { local varname="$1" local value="$2" # Validate that varname is a legal identifier [[ "$varname" =~ ^[a-zA-Z_][a-zA-Z0-9_]*$ ]] || { echo "invalid variable name: $varname" >&2; return 1 } # printf %q escapes the value; eval sees: varname='safe value' eval "$varname=$(printf '%q' "$value")" } safe_set_var greeting "hello; rm -rf ~" echo "$greeting" # hello; rm -rf ~ — stored as literal string, not executed # declare -p round-trip is safe eval (output is already shell-quoted) arr=( one two three ) serialised=$(declare -p arr) eval "$serialised" # safe: declare -p output is quoted by bash itself # Sourcing config files — risks and mitigations # Risk: source /user/provided/path.conf runs arbitrary code # Mitigation 1: check ownership and permissions first safe_source() { local file="$1" [[ -f "$file" ]] || { echo "not a file: $file" >&2; return 1; } [[ ! -L "$file" ]] || { echo "symlink refused: $file" >&2; return 1; } # Owned by root or current user, not world-writable local owner perms read -r perms _ owner _ <<< $(ls -la "$file") [[ "$owner" == "root" || "$owner" == "$USER" ]] || { echo "refusing to source file owned by $owner" >&2; return 1 } [[ "${perms: -1}" != "w" ]] || { echo "world-writable config refused" >&2; return 1 } source "$file" }
4 — Temporary Files: Doing It Right
# VULNERABLE: predictable temp filename → symlink attack tmpfile=/tmp/myapp_$$ # attacker creates symlink before we do echo "secret data" > "$tmpfile" # writes to attacker's target # SAFE: mktemp creates a unique file atomically tmpfile=$(mktemp) # /tmp/tmp.XXXXXXXXXX tmpdir=$(mktemp -d) # directory tmpfile=$(mktemp -t myapp.XXXXXXXXXX) # custom prefix/template # ALWAYS register cleanup in a trap so temp files are deleted on exit, # error, or signal — including SIGINT TMPDIR=$(mktemp -d) trap 'rm -rf "$TMPDIR"' EXIT # Stacking traps — preserve existing EXIT handler add_exit_trap() { local existing existing=$(trap -p EXIT | sed "s/trap -- '//;s/' EXIT//") trap "${existing:+${existing};} $1" EXIT } add_exit_trap 'rm -f "$tmpfile"' add_exit_trap 'rm -rf "$tmpdir"' # Use a private temp directory under $TMPDIR (respects user's temp preference) WORK=$(mktemp -d "${TMPDIR:-/tmp}/myapp.XXXXXXXXXX") trap 'rm -rf "$WORK"' EXIT # Create files inside the private dir — no race risk from /tmp secrets="${WORK}/secrets" touch "$secrets" chmod 600 "$secrets" printf 'API_KEY=%s\n' "$API_KEY" > "$secrets"
5 — Privilege Separation and sudo
# Principle of least privilege: drop privileges as soon as possible # Check if running as root — refuse if not expected if (( EUID == 0 )); then echo "Do not run this script as root" >&2; exit 1 fi # Or require root if (( EUID != 0 )); then exec sudo -- "$0" "$@" # re-exec ourselves under sudo fi # Privilege separation: do the privileged part in a minimal subshell, # then drop back to the original user for the rest run_as_root() { local user="${SUDO_USER:-$USER}" # Only allow specific whitelisted commands case "$1" in reload-nginx) systemctl reload nginx ;; clear-cache) find /var/cache/app -delete ;; *) echo "unknown privileged action: $1" >&2; return 1 ;; esac } # sudoers entry for script-specific commands (add via visudo): # deploy ALL=(root) NOPASSWD: /usr/local/sbin/deploy-helper reload-nginx # deploy ALL=(root) NOPASSWD: /usr/local/sbin/deploy-helper clear-cache # NEVER pass user-supplied strings to sudo — validate first # BAD: # sudo systemctl "$user_action" "$user_service" # GOOD: case "$user_action" in start|stop|restart|status) : ;; *) echo "invalid action" >&2; exit 1 ;; esac case "$user_service" in nginx|redis|postgres) : ;; *) echo "invalid service" >&2; exit 1 ;; esac sudo systemctl "$user_action" "$user_service"
6 — Secrets Handling
Credentials in scripts are a perennial source of breaches. The most common mistakes are storing secrets in environment variables, command-line arguments, or hardcoded in the script itself.
Why environment variables are risky
# Anyone who can read /proc/PID/environ sees your variables # ps aux shows command-line arguments — including passwords passed as args # BAD: hardcoded secret DB_PASS="hunter2" # BAD: secret in command-line argument mysql -u root -phunter2 # visible in ps aux output # BETTER: read from a file with restricted permissions DB_PASS_FILE=/etc/myapp/db.secret # chmod 400, owned by service user DB_PASS=$(< "$DB_PASS_FILE") # BETTER: use mysql option file instead of -p flag # ~/.my.cnf or --defaults-extra-file=/path/to/file mysql --defaults-extra-file=/etc/myapp/mysql.cnf -u myapp # BEST: use a secrets manager and only hold in memory briefly DB_PASS=$(vault kv get -field=password secret/myapp/db) # Use DB_PASS immediately, then unset it mysql -u myapp -p"$DB_PASS" -e "SELECT 1" unset DB_PASS # remove from environment as soon as possible
Passing secrets to child processes without the environment
# Use process substitution or a temp file to avoid the environment entirely # Pass a secret via stdin instead of an argument printf '%s\n' "$DB_PASS" | some-program --password-stdin # Many tools support reading secrets from a file descriptor exec 3<<<"$DB_PASS" some-program --password-fd=3 exec 3<&- # Scrubbing sensitive variables from the environment before exec'ing untrusted code unset AWS_SECRET_ACCESS_KEY DB_PASS GITHUB_TOKEN # Or use env -i to start with a clean environment env -i HOME="$HOME" PATH="$PATH" untrusted-program
Preventing secrets from appearing in logs and traces
# set -x traces every command — including ones with secrets in variables # Suppress tracing for a sensitive block set +x PASSWORD=$(< /run/secrets/db_password) set -x # re-enable if needed # Redirect xtrace away from the sensitive section { local old_fd=$BASH_XTRACEFD exec 20>/dev/null; BASH_XTRACEFD=20 SENSITIVE=$(read_secret) exec 20>&-; BASH_XTRACEFD="$old_fd" }
7 — Hardening Script Startup
#!/usr/bin/env bash # Recommended safety header for any script that handles untrusted data # or runs with elevated privileges set -euo pipefail # -e exit on first error # -u treat unset variables as errors (prevents silent empty expansions) # -o pipefail pipeline fails if any stage fails (not just the last) # Restrict PATH to known-good locations — prevents PATH hijacking PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin export PATH # Reset IFS to the default — env may have tampered with it IFS=$' \t\n' # Restrict file creation permissions (no group/world write on new files) umask 077 # Prevent core dumps (may contain secrets) ulimit -c 0 # Clear dangerous environment variables unset CDPATH # can redirect cd to unexpected directories unset BASH_ENV # sourced by bash when invoked as sh unset ENV # POSIX equivalent of BASH_ENV unset BASH_XTRACEFD # don't inherit someone else's trace fd # Absolute path for the script itself (guards against PATH tricks) SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)
8 — Common Vulnerability Checklist
| Vulnerability | Trigger | Fix |
|---|---|---|
| Argument injection | Unquoted $var in command | Always quote: "$var" |
| Option injection | cmd $var where var starts with - | Use cmd -- "$var" |
| Command injection via eval | eval "cmd $userdata" | Never eval user data; use printf %q |
| PATH hijacking | Script inherits user's PATH | Set explicit PATH at startup |
| IFS manipulation | Inherited IFS changes word splitting | Reset IFS at script start |
| Symlink attack on temp files | Predictable /tmp/name | Use mktemp; trap EXIT for cleanup |
| Path traversal | User-supplied path with .. | Validate with realpath and prefix check |
| Credential exposure | Secret in env var, CLI arg, or code | Use secret files or a secrets manager; unset after use |
| World-writable output | Default umask | umask 077 at startup |
| Unchecked exit codes | Continuing after a failed command | set -euo pipefail |
| Unset variable expansion | rm -rf "$dir/" when $dir is empty | set -u; use ${var:?error} |
| Glob in filenames | Untrusted filename containing *?[] | Quote all filenames; use -- |
| TOCTOU (check-then-use) | Testing file, then acting — race window | Use atomic operations; noclobber, flock |
Exercises
Exercise 1 — Vulnerability hunt
The following script contains at least six distinct security vulnerabilities. Identify each one, name the vulnerability class, and provide the corrected line. Then rewrite the complete function safely.
deploy_release() { version=$1 deploy_dir="/var/www/$version" archive="/tmp/$version.tar.gz" curl -so $archive "https://releases.example.com/$version.tar.gz" tar -xzf $archive -C $deploy_dir config=$(cat /etc/myapp/deploy.conf) eval "$config" chmod -R 777 "$deploy_dir" echo "Deployed $version with DB_PASS=$DB_PASS" }
# Vulnerabilities:
# 1. $1 not quoted → word splitting/glob if version contains spaces or special chars
# 2. $archive unquoted in curl → argument injection (also predictable /tmp path)
# 3. $archive and $deploy_dir unquoted in tar → argument injection
# 4. eval "$config" → arbitrary code execution from config file
# 5. chmod -R 777 → world-writable deployed files
# 6. echo "... DB_PASS=$DB_PASS" → credential leakage in logs/terminal
deploy_release() {
local version="${1:?version required}"
# 1. Validate version is a safe identifier (no path traversal, no injection)
[[ "$version" =~ ^[a-zA-Z0-9._-]+$ ]] || {
printf 'Invalid version: %q\n' "$version" >&2; return 1
}
# 2. Use mktemp for the archive — not a predictable /tmp path
local archive
archive=$(mktemp -t deploy.XXXXXXXXXX.tar.gz)
trap 'rm -f "$archive"' RETURN
# 3. Quote all variables used in commands
local deploy_dir="/var/www/$version"
# Validate deploy_dir stays within /var/www/
local real_dir
real_dir=$(realpath -m "$deploy_dir")
[[ "$real_dir" == "/var/www/"* ]] || {
echo "deploy_dir escapes /var/www/" >&2; return 1
}
curl -so "$archive" "https://releases.example.com/$version.tar.gz"
tar -xzf "$archive" -C "$deploy_dir"
# 4. Source config only after safety checks — never eval raw file content
safe_source /etc/myapp/deploy.conf
# 5. Least-privilege permissions
chmod -R 755 "$deploy_dir"
find "$deploy_dir" -name '*.conf' -exec chmod 640 {} +
# 6. Never log credentials
printf 'Deployed %s successfully\n' "$version"
}
Exercise 2 — Safe argument parser
Write a function safe_cli_parser that processes these arguments:
--user NAME, --days N, --output FILE, and
an optional --verbose flag. Enforce:
NAMEmatches^[a-zA-Z0-9_-]{1,32}$Nis an integer between 1 and 365FILEresolves inside/var/reports/— reject traversal- Unknown arguments cause an error with a usage message
- All three required arguments must be provided
Demonstrate it correctly rejecting: --user "admin; rm -rf /",
--days 999, and --output "../../etc/passwd".
safe_cli_parser() { local user="" days="" output="" verbose=0 while (( $# > 0 )); do case "$1" in --user) [[ $# -ge 2 ]] || { echo "--user requires a value" >&2; return 1; } [[ "$2" =~ ^[a-zA-Z0-9_-]{1,32}$ ]] || { printf 'Invalid --user value: %q\n' "$2" >&2; return 1 } user="$2"; shift 2 ;; --days) [[ $# -ge 2 ]] || { echo "--days requires a value" >&2; return 1; } [[ "$2" =~ ^[0-9]+$ ]] && (( 10#$2 >= 1 && 10#$2 <= 365 )) || { printf '--days must be 1-365, got: %q\n' "$2" >&2; return 1 } days="$2"; shift 2 ;; --output) [[ $# -ge 2 ]] || { echo "--output requires a value" >&2; return 1; } local real_out real_out=$(realpath -m "$2") [[ "$real_out" == "/var/reports/"* ]] || { printf '--output must be inside /var/reports/, got: %q\n' \ "$real_out" >&2; return 1 } output="$real_out"; shift 2 ;; --verbose) verbose=1; shift ;; *) printf 'Unknown argument: %q\n' "$1" >&2 printf 'Usage: cmd --user NAME --days N --output FILE [--verbose]\n' >&2 return 1 ;; esac done for req in user days output; do [[ -n "${!req}" ]] || { printf '--%s is required\n' "$req" >&2; return 1 } done printf 'OK: user=%s days=%s output=%s verbose=%d\n' \ "$user" "$days" "$output" "$verbose" } # Rejection tests safe_cli_parser --user "admin; rm -rf /" --days 7 --output /var/reports/r.txt safe_cli_parser --user alice --days 999 --output /var/reports/r.txt safe_cli_parser --user alice --days 7 --output ../../etc/passwd # All three should print errors and return 1 safe_cli_parser --user alice --days 30 --output /var/reports/alice.csv --verbose # Should print: OK: user=alice days=30 output=/var/reports/alice.csv verbose=1
Exercise 3 — Secure secret loader
Write a load_secrets FILE function that reads a
KEY=VALUE config file and exports only the listed variables.
Requirements:
- Reject the file if it is world-readable (
chmod o-rcheck) - Reject the file if it is a symlink
- Reject the file if it is not owned by the current user or root
- Parse lines manually — do not
sourceorevalthe file - Accept only keys matching
^[A-Z_][A-Z0-9_]*$and skip malformed lines with a warning - Strip single and double quotes from values
- After loading, overwrite the variable storing the file path with an empty string and unset it
load_secrets() { local file="$1" # Existence [[ -f "$file" ]] || { printf 'load_secrets: not a file: %s\n' "$file" >&2; return 1; } # Not a symlink [[ ! -L "$file" ]] || { printf 'load_secrets: symlinks refused: %s\n' "$file" >&2; return 1; } # Permissions: not world-readable local mode owner read -r mode _ owner <<< $(stat -c '%a %U' "$file") (( 10#$mode % 10 == 0 )) || { printf 'load_secrets: file is world-readable (mode %s): %s\n' \ "$mode" "$file" >&2; return 1 } # Ownership: current user or root [[ "$owner" == "$USER" || "$owner" == "root" ]] || { printf 'load_secrets: file owned by %s, expected %s or root\n' \ "$owner" "$USER" >&2; return 1 } # Parse manually — no eval/source local lineno=0 key value raw while IFS= read -r raw || [[ -n "$raw" ]]; do (( lineno++ )) # Skip blank lines and comments [[ $raw =~ ^[[:space:]]*(#|$) ]] && continue # Must be KEY=VALUE [[ $raw =~ ^([A-Z_][A-Z0-9_]*)=(.*) ]] || { printf 'load_secrets: skipping malformed line %d: %q\n' \ "$lineno" "$raw" >&2; continue } key="${BASH_REMATCH[1]}" value="${BASH_REMATCH[2]}" # Strip surrounding single or double quotes value="${value#\"}"; value="${value%\"}" value="${value#\'}"; value="${value%\'}" # Export without eval export "$key"="$value" done < "$file" # Scrub the file path variable file="" unset file } # Test touch /tmp/test_secrets.env chmod 600 /tmp/test_secrets.env printf 'DB_PASS="s3cr3t"\nAPI_KEY=abc123\nBAD LINE\n# comment\n' \ > /tmp/test_secrets.env load_secrets /tmp/test_secrets.env echo "DB_PASS=$DB_PASS API_KEY=$API_KEY" rm /tmp/test_secrets.env
Exercise 4 — Hardened script template
Write a complete, production-ready Bash script skeleton called
hardened_template.sh that incorporates every defence from this chapter.
It should:
- Set all recommended startup options (
set -euo pipefail, fixed PATH, IFS, umask, ulimit) - Unset dangerous environment variables
- Declare a
die CODE MESSAGEhelper and acleanupEXIT trap - Validate at least one argument using an allowlist regex
- Create a private temp directory via
mktemp -dregistered in the EXIT trap - Demonstrate safe secret loading (read from a file, never echo)
- Include a
--dry-runflag that suppresses all destructive operations - Be fully ShellCheck-clean (no SC warnings)
#!/usr/bin/env bash # hardened_template.sh — production-ready secure script skeleton set -euo pipefail # ── Security hardening ────────────────────────────────────────────── PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin export PATH IFS=$' \t\n' umask 077 ulimit -c 0 unset CDPATH BASH_ENV ENV # ── Script identity ───────────────────────────────────────────────── SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P) SCRIPT_NAME="${0##*/}" # ── Globals ───────────────────────────────────────────────────────── DRY_RUN=0 WORK="" # ── Helpers ───────────────────────────────────────────────────────── die() { local code="$1"; shift printf '%s: ERROR: %s\n' "$SCRIPT_NAME" "$*" >&2 exit "$code" } log() { printf '[%(%Y-%m-%d %H:%M:%S)T] %s\n' -1 "$*"; } cleanup() { [[ -n "$WORK" ]] && rm -rf "$WORK" } trap cleanup EXIT # ── Argument parsing ──────────────────────────────────────────────── usage() { printf 'Usage: %s [--dry-run] --env (prod|staging|dev)\n' "$SCRIPT_NAME" >&2 exit 1 } TARGET_ENV="" while (( $# > 0 )); do case "$1" in --dry-run) DRY_RUN=1; shift ;; --env) [[ $# -ge 2 ]] || die 1 "--env requires a value" [[ "$2" =~ ^(prod|staging|dev)$ ]] || die 1 "--env must be prod, staging, or dev; got: $(printf '%q' "$2")" TARGET_ENV="$2"; shift 2 ;; -h|--help) usage ;; *) die 1 "Unknown argument: $(printf '%q' "$1")" ;; esac done [[ -n "$TARGET_ENV" ]] || die 1 "--env is required" # ── Setup ─────────────────────────────────────────────────────────── WORK=$(mktemp -d "${TMPDIR:-/tmp}/${SCRIPT_NAME%.sh}.XXXXXXXXXX") log "Work directory: $WORK" # ── Secret loading (no eval, no echo) ─────────────────────────────── SECRET_FILE="${SCRIPT_DIR}/secrets/${TARGET_ENV}.env" if [[ -f "$SECRET_FILE" ]]; then while IFS='=' read -r k v || [[ -n "$k" ]]; do [[ $k =~ ^[A-Z_][A-Z0-9_]*$ ]] || continue v="${v#\"}"; v="${v%\"}" export "$k"="$v" done < "$SECRET_FILE" fi # ── Main work ─────────────────────────────────────────────────────── log "Starting deployment to $TARGET_ENV" if (( DRY_RUN )); then log "[DRY RUN] would deploy to $TARGET_ENV — skipping destructive steps" else log "Deploying..." # ... real work here ... fi log "Done"