Trust boundaries: arguments, environment and privilege

Option injection, remote quoting, PATH and BASH_ENV, races and dropping privilege.

Expert40 min · lesson 4 of 15
Lesson files
The scripts, test data and local test servers this lesson uses, exactly as they ran on the lab machine (7 files, 3 KB): scr-bash-secure.tar.gz. Unpack it with tar -xzf scr-bash-secure.tar.gz, which creates scr-bash-secure/. SHA-256: 466b19f19bf655182693481f8a7fff0ef29cd5b55f466c89a93d605c25f8289d

This 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:

deploy@web01:~/scr-bash-secure · Ubuntu 26.04 LTS
$ rm -f ext-marker; git -c protocol.ext.allow=always ls-remote "ext::touch ext-marker" 2>/dev/null; ls ext-marker 2>&1
ext-marker
$ rm -f ext-marker2; git ls-remote "ext::touch ext-marker2" 2>&1 | grep -o "transport .ext. not allowed"; ls ext-marker2 2>&1
transport 'ext' not allowed ls: cannot access 'ext-marker2': No such file or directory
$ rm -f tar-marker; tar -cf /dev/null --checkpoint=1 --checkpoint-action=exec="touch tar-marker" auth.log 2>/dev/null; ls tar-marker
tar-marker

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:

dispatch.sh
#!/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 pipefail
run_action() {
local action=$1
shift
case $action in
disk) 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 "$@"
deploy@web01:~/scr-bash-secure · Ubuntu 26.04 LTS
$ action="disk; touch eval-marker"; rm -f eval-marker; eval "echo running $action" ; ls eval-marker 2>&1
running disk eval-marker
$ ./dispatch.sh disk /
Filesystem Size Used Avail Use% Mounted on /dev/vda1 23G 4.9G 18G 23% /
$ ./dispatch.sh "disk; touch eval-marker2"; echo "---"; ls eval-marker2 2>&1
unknown action: disk; touch eval-marker2 --- ls: cannot access 'eval-marker2': No such file or directory

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:

deploy@web01:~/scr-bash-secure · Ubuntu 26.04 LTS
$ env IFS=XYZ BASH_ENV=/dev/null bash -c 'printf "IFS=%q\n" "$IFS"'
IFS=$' \t\n'

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:

env-guard.sh
#!/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 ENV
PATH=/usr/bin:/bin
printf 'script body ran as %s, BASH_ENV is now %s\n' "$(id -un)" "${BASH_ENV:-unset}"
deploy@web01:~/scr-bash-secure · Ubuntu 26.04 LTS
$ printf "touch %s/bashenv-marker\n" "$PWD" > inject.sh rm -f bashenv-marker BASH_ENV="$PWD/inject.sh" ./env-guard.sh ls bashenv-marker
script body ran as deploy, BASH_ENV is now unset bashenv-marker
$ mkdir -p evil printf "#!/bin/sh\necho \"HIJACKED: this is not bash\"\n" > evil/bash chmod +x evil/bash PATH="$PWD/evil:$PATH" ./env-guard.sh
HIJACKED: this is not bash

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:

deploy@web01:~/scr-bash-secure · Ubuntu 26.04 LTS
$ sed "1s|.*|#!/usr/bin/bash|" env-guard.sh > env-guard-abs.sh chmod +x env-guard-abs.sh PATH="$PWD/evil:$PATH" ./env-guard-abs.sh
script body ran as deploy, BASH_ENV is now unset
$ rm -f bashenv-marker BASH_ENV="$PWD/inject.sh" env -i PATH=/usr/bin:/bin ./env-guard-abs.sh BASH_ENV="$PWD/inject.sh" sudo ./env-guard-abs.sh ls bashenv-marker
script body ran as deploy, BASH_ENV is now unset script body ran as root, BASH_ENV is now unset ls: cannot access 'bashenv-marker': No such file or directory

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:

deploy@web01:~/scr-bash-secure · Ubuntu 26.04 LTS
$ bash -c 'td=$(mktemp -d); printf "#!/bin/sh\necho HIJACKED\n" > "$td/date"; chmod +x "$td/date"; PATH="$td:$PATH"; type date; date; rm -rf "$td"'
date is /tmp/tmp.wzVuGcBT4U/date HIJACKED
$ rm -r evil inject.sh env-guard-abs.sh

umask decides the permissions of files you create. Set it to 077 early and every file a script writes is private by default:

deploy@web01:~/scr-bash-secure · Ubuntu 26.04 LTS
$ (umask 077; f=$(mktemp); ls -l "$f"; rm -f "$f")
-rw------- 1 deploy deploy 0 Sep 28 20:44 /tmp/tmp.unVivn7HKZ

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:

write-secure.sh
#!/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 pipefail
dest=${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 ]]; then
printf 'write-secure: refusing %s: it is a symlink; name the real directory\n' "$dir" >&2
exit 1
fi
# 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)); then
printf 'write-secure: refusing %s: another user can write it (owner %s, mode %s)\n' \
"$dir" "$(stat -c %U -- "$dir")" "$mode" >&2
exit 1
fi
tmp=$(mktemp -- "$dir/.tmp.XXXXXX") # 0600, random name, same filesystem
trap 'rm -f -- "$tmp"' EXIT
cat -- - > "$tmp" # content arrives on stdin
chmod 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:

deploy@web01:~/scr-bash-secure · Ubuntu 26.04 LTS
$ printf "SECRET\n" > secret.txt mkdir -p -m 0755 spool ln -sf "$PWD/secret.txt" spool/report ls -l spool/report
lrwxrwxrwx 1 deploy deploy 39 Sep 28 20:44 spool/report -> /home/deploy/scr-bash-secure/secret.txt
$ printf "new report body\n" | ./write-secure.sh spool/report; echo "--- secret.txt still:"; cat secret.txt; echo "--- spool/report is now:"; ls -l spool/report
wrote spool/report --- secret.txt still: SECRET --- spool/report is now: -rw-r----- 1 deploy deploy 16 Sep 28 20:44 spool/report

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:

deploy@web01:~/scr-bash-secure · Ubuntu 26.04 LTS
$ mkdir -p victim ln -sfn "$PWD/victim" spool/report printf "new\n" > spool/.tmp.demo mv -f -- spool/.tmp.demo spool/report ls -A victim
.tmp.demo
$ rm -f victim/.tmp.demo printf "new report body\n" | ./write-secure.sh spool/report ls -A victim; ls -l spool/report
wrote spool/report -rw-r----- 1 deploy deploy 16 Sep 28 20:44 spool/report

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:

deploy@web01:~/scr-bash-secure · Ubuntu 26.04 LTS
$ chmod g+w spool printf "x\n" | ./write-secure.sh spool/report
write-secure: refusing spool: another user can write it (owner deploy, mode 775)
$ chmod g-w spool

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:

deploy@web01:~/scr-bash-secure · Ubuntu 26.04 LTS
$ ln -sfn spool spool-link printf "x\n" | ./write-secure.sh spool-link/report
write-secure: refusing spool-link: it is a symlink; name the real directory
$ rm spool-link

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.

setup-sshd.sh
#!/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 pipefail
port=18722
here=$PWD
rm -f -- hostkey hostkey.pub id_lab id_lab.pub
ssh-keygen -q -t ed25519 -f hostkey -N '' -C lab-host
ssh-keygen -q -t ed25519 -f id_lab -N '' -C lab-client # lab only: no passphrase
cp id_lab.pub authorized_keys
chmod 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_hosts
cat > sshd_config <<CFG
Port $port
ListenAddress 127.0.0.1
HostKey $here/hostkey
PidFile $here/sshd.pid
AuthorizedKeysFile $here/authorized_keys
AllowUsers $USER
PasswordAuthentication no
KbdInteractiveAuthentication no
UsePAM yes
# lab only: an unpacked lesson directory may be group-writable
StrictModes no
PrintMotd no
CFG
# The client side of an unattended ssh: a pinned host key, never a prompt, a deadline.
cat > ssh_config <<CFG
Host labhost
HostName 127.0.0.1
Port $port
User $USER
IdentityFile $here/id_lab
IdentitiesOnly yes
UserKnownHostsFile $here/known_hosts
StrictHostKeyChecking yes
BatchMode yes
ConnectTimeout 5
CFG
echo "created hostkey, id_lab, known_hosts, sshd_config and ssh_config in $here"
deploy@web01:~/scr-bash-secure · Ubuntu 26.04 LTS
$ ./setup-sshd.sh
created hostkey, id_lab, known_hosts, sshd_config and ssh_config in /home/deploy/scr-bash-secure
$ sudo /usr/sbin/sshd -f "$PWD/sshd_config" -E "$PWD/sshd.log"
# sshd needs root to log users in; it runs in the background until you stop it (end of the lesson)
$ ssh -F ssh_config labhost "id -un; hostname"
deploy web01

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:

remote-run.sh
#!/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 pipefail
dest=${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"
deploy@web01:~/scr-bash-secure · Ubuntu 26.04 LTS
$ SSH_CONFIG="$PWD/ssh_config" ./remote-run.sh labhost "$PWD/auth.log"
4
$ logpath="$PWD/auth.log; echo INJECTED-COMMAND-RAN" ssh -F ssh_config labhost "grep -c -e Failed -- $logpath" 2>&1 | tail -2
4 INJECTED-COMMAND-RAN
$ SSH_CONFIG="$PWD/ssh_config" ./remote-run.sh labhost "$PWD/auth.log; echo INJECTED-COMMAND-RAN" 2>&1 | tail -2
grep: /home/deploy/scr-bash-secure/auth.log; echo INJECTED-COMMAND-RAN: No such file or directory

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:

deploy@web01:~/scr-bash-secure · Ubuntu 26.04 LTS
$ dash -c 'printf "%q\n" "a b"' v=$'a\nb'; q=${v@Q}; echo "quoted: $q" bash -c "printf '[%s]\n' $q" dash -c "printf '[%s]\n' $q"
dash: 1: printf: %q: invalid directive quoted: $'a\nb' [a b] [$a\nb]

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:

drop-priv.sh
#!/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 pipefail
conf=/etc/scr-bash-secure.conf
if [[ ${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 caller
printf '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" --worker
fi
# ---- 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; then
echo "UNEXPECTED: regained privilege"
else
echo "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:

deploy@web01:~/scr-bash-secure · Ubuntu 26.04 LTS
$ sudo install -o root -g root -m 0755 drop-priv.sh /usr/local/sbin/scr-bash-secure-drop
$ sudo /usr/local/sbin/scr-bash-secure-drop
root step done as root worker running as uid=1001(deploy) gid=1001(deploy) groups=1001(deploy) NoNewPrivs: 1 sudo: The "no new privileges" flag is set, which prevents sudo from running as root. sudo: If sudo is running in a container, you may need to adjust the container configuration to disable the flag. sudo refused (exit 1)
$ ls -l /etc/scr-bash-secure.conf /usr/local/sbin/scr-bash-secure-drop
-rw-r--r-- 1 root root 10 Sep 28 20:44 /etc/scr-bash-secure.conf -rwxr-xr-x 1 root root 1310 Sep 28 20:44 /usr/local/sbin/scr-bash-secure-drop
$ sudo rm /etc/scr-bash-secure.conf /usr/local/sbin/scr-bash-secure-drop

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.

Four trust boundaries in one script
Arguments
Value in the option slot
anchor with -- or ./ or -e
eval on input
a case table, not built code
Environment
BASH_ENV / PATH
clean env from the caller; absolute shebang
IFS
reset by bash; not a threat
Filesystem
Symlink at a known path
mktemp in the dir, then mv -T
Directory others can write
refuse it, or drop to its owner
Privilege
Root for the whole run
one root step, then drop
Remote/extra shell
${var@Q} for Bash, single quotes for sh
An attacker only needs the one boundary you left open.

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.

Quick check
01A job runs 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?
Incorrect — Only ext:: is blocked by default. file:// stays allowed for commands you run yourself, as the lab's check showed.
Correct — the default policy stops the transport that runs commands, not local paths, and -- keeps a value that starts with a dash an operand.
Incorrect — With the default policy git refused ext:: ("transport 'ext' not allowed"). The gap is the schemes it does allow, and curl has its own list of schemes to restrict.
Incorrect — git receives the URL as one argument and runs no shell over it; ${var@Q} matters where a string is parsed again, as with ssh.
02A script run through a sudo rule starts with #!/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?
Incorrect — Bash sources BASH_ENV before the script's first line; the unset only protects the commands the script starts later.
Incorrect — Bash resets IFS at startup, so an exported value never reaches the script; it is not the gap here.
Correct — 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.
Incorrect — systemctl lives in /usr/bin; the narrow PATH is fine. The problem is when the protective lines run, not what they contain.
03Your script runs 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?
Correct — 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.
Incorrect — ssh joins everything after the host into one string for the remote shell, so the ; starts a second command there.
Incorrect — Local quoting keeps it one argument locally, but the remote shell re-parses the joined string, so the ; still splits it on the far side.
Incorrect — ssh does not inspect the command for metacharacters; it passes the whole string to the remote shell unchanged.

Related