Trust boundaries: arguments, environment and privilege
Option injection, remote quoting, PATH and BASH_ENV, races and dropping privilege.
tar -xzf scr-bash-secure.tar.gz, which creates scr-bash-secure/. SHA-256: 466b19f19bf655182693481f8a7fff0ef29cd5b55f466c89a93d605c25f8289dThis lesson is about the boundary where data from outside your script meets the tools your script runs with. You will see why ending options with -- does not stop every option injection, correct two environment claims that circulate in shell guides and put the real defence where it works, replace a planted symlink safely (and learn where no Bash write is safe), run a command on another host without letting a filename become a second command, and do one privileged step and then give up privilege for good. Every case runs here, with a throwaway sshd on 127.0.0.1 and setpriv.
Refresher: "Quoting, word splitting and globbing" in bash-ops owns quoting, "$@", -- and the path-anchored glob ./*; "Temp files, locks and timeouts" owns mktemp and the write-then-rename; "Strict mode, honestly" owns set -Eeuo pipefail. This lesson looks at the places where those habits are necessary but not sufficient. Unpack the lesson files (the box at the top of this page) in your home directory and work in ~/scr-bash-secure as your normal user; sudo appears only where a step needs root, and each such step says how to undo it.
Arguments and options are data too
Quoting keeps a value in one piece; it does not decide whether a tool reads that piece as an operand or as an option. bash-ops covered the everyday cases: "Quoting, word splitting and globbing" showed a file named -rf read as options by rm *, the two fixes (-- and the path-anchored ./*), and why find needs ./ even after --; "Arrays and parameter expansion" showed ${var:?} stopping an empty variable from turning a path into /*; "Command-line options" showed -- ending option parsing in your own scripts. Two details from that ground matter in production code. ShellCheck flags the recursive rm -r "$dir"/* as SC2115 but says nothing about a plain rm "$dir"/* (checked on 0.11.0), so write the :? yourself. And a value that goes into an option's slot needs the option spelled out: grep "$1" file reads a leading dash in $1 as a flag, while grep -e "$pattern" -- file keeps it a pattern.
-- stops a tool from reading an operand as an option. It does nothing when the operand itself is an instruction. Some tools accept arguments that are instructions to run something: git's ext:: transport runs a command named in the URL, and tar's --checkpoint-action=exec= runs a command at a checkpoint:
With ext enabled, a crafted URL ran touch on this machine. git blocks ext by default, which is why the plain git ls-remote refused. It does not block file:// for commands you run (only for submodules and other fetches git starts itself), so a URL taken from input can still point at a local path. Check the scheme yourself (https:// or ssh:// only) and put -- before the URL. tar has no such guard, so never build its option list from data you did not check.
eval is the extreme case: it turns a string into code, so anything wedged into the string runs. The safe replacement is a fixed dispatch table that never builds a command from the input:
#!/usr/bin/env bash# Run one named action with its arguments, chosen from a fixed table.# The action name comes from outside; the command it maps to does not. No eval.set -Eeuo pipefailrun_action() {local action=$1shiftcase $action indisk) df -h -- "$@" ;;procs) ps -o pid,comm -p "$@" ;;listen) ss -tlnp 2>/dev/null ;;*) printf 'unknown action: %s\n' "$action" >&2; return 2 ;;esac}run_action "$@"
eval "echo running $action" ran the touch the value carried. dispatch.sh maps a name to a command through case; a crafted name matches nothing and exits 2. When you think you need eval, you almost always want a function, a case, or an array of arguments.
The environment your script inherits
A script starts inside an environment the caller controls. IFS is often said to be inherited, so that a caller can hand you a strange word-splitting rule. Bash resets IFS to space, tab and newline at startup, whatever the environment says:
The variables that do change what runs are BASH_ENV and PATH, and the usual advice for them ("unset BASH_ENV and set PATH at the top of the script") comes too late. This script follows that advice:
#!/usr/bin/env bash# UNSAFE as a defence: by the time these lines run, bash has already sourced# $BASH_ENV, and env has already searched the caller's PATH for "bash".unset BASH_ENV ENVPATH=/usr/bin:/binprintf 'script body ran as %s, BASH_ENV is now %s\n' "$(id -un)" "${BASH_ENV:-unset}"
A non-interactive Bash sources the file named in BASH_ENV before the first line of your script, so the marker exists although the script unset the variable. And #!/usr/bin/env bash asks env to find bash in the caller's PATH, so a directory the caller put first chose the interpreter, and the script body never ran. The lines inside the script only protect the commands it starts later.
The defence belongs at the boundary. A privileged script names its interpreter by absolute path (#!/usr/bin/bash), and whatever starts it hands it a clean environment: env -i, sudo (its env_reset drops BASH_ENV), or a systemd unit, which starts with an empty environment. Here sed makes a copy with the absolute shebang:
The fake bash no longer matters, and neither run sourced inject.sh: env -i and sudo removed BASH_ENV before Bash started. Inside the script, still pin PATH and call sensitive tools by full path, because a poisoned PATH chooses every external command too:
umask decides the permissions of files you create. Set it to 077 early and every file a script writes is private by default:
A race on the filesystem, and the write that closes it
"Temp files, locks and timeouts" in bash-ops showed a naive > following a symlink someone planted at the path, and the fix: build the file with mktemp and rename it into place. The gap between choosing a name and using it is a time-of-check to time-of-use race (TOCTOU), and a script that writes with more privilege than whoever can plant the link needs two additions to that fix. Here is the write with both:
#!/usr/bin/env bash# Write stdin to DEST so that a reader sees the old file or the new one, never a# half-written one; the file is never world-readable, even for an instant; and a# symlink planted at DEST before the run (to a file or to a directory) is replaced,# not followed.set -Eeuo pipefaildest=${1:?usage: write-secure.sh DEST}dir=$(dirname -- "$dest")umask 077 # every file this process creates is 0600# Refuse a directory reached through a symlink: whoever controls the link chooses# where the file lands. (stat without -L would report the link's own mode, 777.)if [[ -L $dir ]]; thenprintf 'write-secure: refusing %s: it is a symlink; name the real directory\n' "$dir" >&2exit 1fi# Refuse a directory another user can write: there, that user could swap the temp# file for a symlink between the steps below, and no redirect can prevent it.mode=$(stat -c %a -- "$dir")if [[ ! -O $dir ]] || ((8#$mode & 8#022)); thenprintf 'write-secure: refusing %s: another user can write it (owner %s, mode %s)\n' \"$dir" "$(stat -c %U -- "$dir")" "$mode" >&2exit 1fitmp=$(mktemp -- "$dir/.tmp.XXXXXX") # 0600, random name, same filesystemtrap 'rm -f -- "$tmp"' EXITcat -- - > "$tmp" # content arrives on stdinchmod 0640 -- "$tmp"# -T (--no-target-directory): DEST is always the name to replace. Without it, a DEST# that is a symlink to a directory makes mv move the file INTO that directory.mv -fT -- "$tmp" "$dest"printf 'wrote %s\n' "$dest"
Plant the link yourself: spool/report points at a file holding a secret (-m 0755 makes spool writable by you alone, which the script checks), then write through it:
The script built the content in a mktemp file in the destination directory and renamed it over spool/report. The rename replaced the symlink with a regular file, the secret was untouched, and a reader saw the old file or the new one. The temp file lives in the destination directory so the final mv is a rename; /tmp is a separate tmpfs on Ubuntu 26.04, and a move from there is a copy, which reopens the window.
The first addition is mv -T. Without it, a destination that is a symlink to a directory makes mv move the file into that directory:
Plain mv -f put .tmp.demo into victim/, with your permissions and content, and a cleanup trap that removes $tmp would miss it. With -T (--no-target-directory) the destination is always the name to replace.
The second addition is the directory check. write-secure.sh opens the temp file by name, then chmod and mv use the name again. In a directory another user can write, that user can swap the temp file for a symlink between those steps, and no Bash redirect can refuse to follow it. So the script refuses such a directory:
The message names the owner and the mode, so you can see which rule failed. A directory reached through a symlink is refused first, with a message of its own: whoever can change the link decides where the file lands, and stat without -L would describe the link itself (mode 777) and blame the wrong thing:
The rule behind the check: a privileged job never writes into a directory other users can write. Write into a directory only the job's user (or root) can write, or drop to the owner of that directory first, so the worst a planted link can do is redirect a write made with that owner's rights. When you cannot avoid a shared directory, use Python's O_NOFOLLOW and O_EXCL with a directory descriptor ("Handling hostile input at scale", later in this course).
Sending a command to another host
The examples need an SSH server, and this lesson never touches the system one on port 22. setup-sshd.sh from the lesson files creates a second one's host key and config on 127.0.0.1:18722, a lab client key, a pinned known_hosts and an ssh_config entry called labhost. The ssh_config part is the client side of any unattended SSH call: StrictHostKeyChecking yes with a pinned UserKnownHostsFile (never StrictHostKeyChecking no), BatchMode yes so a password or passphrase prompt fails instead of hanging the job, and ConnectTimeout.
#!/usr/bin/env bash# A throwaway SSH target for this lesson: a second sshd on 127.0.0.1:18722 with its# own host key and config (never the system sshd on port 22), a lab-only client key,# and an ssh_config entry "labhost". Run it as yourself in the lesson directory;# start sshd with sudo afterwards, as the lesson shows.set -euo pipefailport=18722here=$PWDrm -f -- hostkey hostkey.pub id_lab id_lab.pubssh-keygen -q -t ed25519 -f hostkey -N '' -C lab-hostssh-keygen -q -t ed25519 -f id_lab -N '' -C lab-client # lab only: no passphrasecp id_lab.pub authorized_keyschmod 0600 hostkey id_lab authorized_keys# Pin the host key: ssh accepts this key for this address and port, nothing else.printf '[127.0.0.1]:%s %s\n' "$port" "$(cat hostkey.pub)" > known_hostscat > sshd_config <<CFGPort $portListenAddress 127.0.0.1HostKey $here/hostkeyPidFile $here/sshd.pidAuthorizedKeysFile $here/authorized_keysAllowUsers $USERPasswordAuthentication noKbdInteractiveAuthentication noUsePAM yes# lab only: an unpacked lesson directory may be group-writableStrictModes noPrintMotd noCFG# The client side of an unattended ssh: a pinned host key, never a prompt, a deadline.cat > ssh_config <<CFGHost labhostHostName 127.0.0.1Port $portUser $USERIdentityFile $here/id_labIdentitiesOnly yesUserKnownHostsFile $here/known_hostsStrictHostKeyChecking yesBatchMode yesConnectTimeout 5CFGecho "created hostkey, id_lab, known_hosts, sshd_config and ssh_config in $here"
ssh host cmd args does not pass args as separate arguments. It joins everything after the host into one string and hands that string to the remote login shell, which parses it again, so a value that is safe locally can start a second command remotely. This wrapper quotes the value with ${var@Q}, which expands to a form Bash reads back as the one value it was:
#!/usr/bin/env bash# Count failed-login lines in a log file on a remote host, where the path comes from# outside this script. ssh joins its command words with spaces and feeds the result# to the remote login shell, so every value must be quoted FOR THAT shell.set -Eeuo pipefaildest=${1:?usage: remote-run.sh DEST LOGPATH}logpath=${2:?usage: remote-run.sh DEST LOGPATH}# ${var@Q} expands to a form that Bash reads back as the original value, so the# remote shell sees one argument whatever logpath contains. The remote login shell# must be Bash: for control characters @Q emits $'...', which dash reads differently.remote="grep -c -e 'Failed password' -- ${logpath@Q}"ssh -F "${SSH_CONFIG:-$HOME/.ssh/config}" "$dest" "$remote"
The safe run counted four failed-login lines. The unsafe form, with an unquoted $logpath inside the remote string, let a ; start echo INJECTED-COMMAND-RAN on the remote host. remote-run.sh sent ${logpath@Q}, so the remote grep looked for one oddly named file and did not find it.
Both ${var@Q} and printf '%q' are Bash features, and their output is meant for a Bash reader. For a value with a control character they produce $'...', which dash (Ubuntu's /bin/sh, a common login shell for service accounts) reads as something else:
dash has no %q, and it read $'a\nb' as a dollar sign followed by a quoted string, so the value changed. It is still one word, so this is corruption rather than injection. For a remote sh, wrap the value in single quotes and write each ' inside it as '\'' (what Python's shlex.quote does), or avoid the remote shell. The same holds for anything that re-parses your string: sudo sh -c, docker exec sh -c.
One root step, then give up root
Many jobs need root for one action: install a file, bind a low port, read a protected secret. Keeping root for the rest of the run means a bug in any later line runs as root. When systemd starts the job, let the unit hold the privilege (User=, NoNewPrivileges=, ProtectSystem=, AmbientCapabilities=); "Sandboxing services with systemd" in Linux hardening covers those settings. For a standalone script, setpriv drops privilege without the surprises of su. This one does its root step, then re-executes itself as the user who ran sudo:
#!/usr/bin/bash# One root step, then everything else as the user who ran sudo. Started as root; it# re-executes itself as that user with no way to regain privilege, and does the rest.# Absolute interpreter path: a privileged script must not let PATH choose its bash.set -Eeuo pipefailconf=/etc/scr-bash-secure.confif [[ ${1:-} != --worker ]]; then# ---- privileged part: the single action that needs root ----user=${SUDO_USER:?run this with sudo} # set by sudo itself, not by the callerprintf 'managed=1\n' > "$conf"chown root:root -- "$conf"chmod 0644 -- "$conf"echo "root step done as $(id -un)"# Drop for good: new uid/gid, no supplementary groups, no way to gain privilege# later (no-new-privs), no inherited capabilities, a clean environment.# Open file descriptors are NOT dropped: close any the root part opened first.exec setpriv --reuid="$user" --regid="$(id -g -- "$user")" --clear-groups \--no-new-privs --inh-caps=-all --reset-env -- "$0" --workerfi# ---- unprivileged part: cannot become root again ----echo "worker running as $(id)"grep NoNewPrivs /proc/self/status# A later bug here has no root to grab. Proof: sudo refuses, and says why.if sudo -n true; thenecho "UNEXPECTED: regained privilege"elseecho "sudo refused (exit $?)"fi
A privileged script must not be writable by the user it drops to, or that user could rewrite what root runs next time. So install it root-owned first, as you would in production:
After the re-exec the worker runs as your user with NoNewPrivs: 1, and sudo refuses with its own reason. setpriv does not close open file descriptors: a secret or root-only log the root part opened stays readable after the drop, so close it (exec {fd}<&-) before the exec. The last command removed the config file and the installed copy.
Try this
Two exercises. First, set -C (noclobber) makes > refuse to overwrite an existing regular file, including through a symlink to one: plant ln -sf secret.txt spool/link2 and confirm that ( set -C; echo blocked > spool/link2 ) fails with "cannot overwrite existing file" and exit 1. It does not protect a link to a device or a FIFO, which is still written through. Second, create a log whose name contains a space, touch -- "$PWD/odd name.log", then run SSH_CONFIG="$PWD/ssh_config" ./remote-run.sh labhost "$PWD/odd name.log". The whole name arrives as one argument, so the remote grep reads the empty file, prints 0 and exits 1 (no match), and nothing splits on the space. Both results are verified in this lab.
When you are done, stop the throwaway server: sudo kill "$(cat sshd.pid)".
Takeaway
Force every outside value into a data slot, get a clean environment from whatever starts a privileged script (and name its interpreter by absolute path), write through mktemp and mv -T in a directory no one else can write, and let the supervisor or setpriv hold privilege so your code never has to.
git ls-remote "$url" to check that a repository URL submitted through a web form is reachable, on a host with git's default configuration. A reviewer says git's own protocol policy already makes this safe. What is the gap?ext:: is blocked by default. file:// stays allowed for commands you run yourself, as the lab's check showed.-- keeps a value that starts with a dash an operand.ext:: ("transport 'ext' not allowed"). The gap is the schemes it does allow, and curl has its own list of schemes to restrict.${var@Q} matters where a string is parsed again, as with ssh.#!/usr/bin/env bash, then unset BASH_ENV ENV and PATH=/usr/bin:/bin. A colleague calls it hardened against a hostile caller environment. What is the gap?BASH_ENV before the script's first line; the unset only protects the commands the script starts later.IFS at startup, so an exported value never reaches the script; it is not the gap here.BASH_ENV is read and the interpreter is chosen before any script line runs, so only the boundary (env -i, sudo, systemd) and an absolute shebang protect it.systemctl lives in /usr/bin; the narrow PATH is fine. The problem is when the protective lines run, not what they contain.ssh "$host" "systemctl restart $unit" where $unit comes from a form, and an attacker submits app; rm -rf /var/tmp/x. What happens on the remote host, and what fixes it?ssh hands the far side one string, that shell re-parses it, and quoting the value for that shell (Bash here) keeps it a single argument.ssh joins everything after the host into one string for the remote shell, so the ; starts a second command there.; still splits it on the far side.ssh does not inspect the command for metacharacters; it passes the whole string to the remote shell unchanged.