Hardening Bash: injection, IFS, PATH & temp races
Command/argument injection, IFS attacks, PATH hygiene, safe temp files and privilege drop.
A Bash (Bourne Again Shell, the default command interpreter on most Linux servers) script spends its whole life handing other people's data to powerful tools. It reads a filename it didn't choose, an environment variable it didn't set, the contents of a file some other service wrote. Then it feeds all of that to rm, curl, tar, and the kernel (the core of the operating system that actually deletes files and starts programs). The risk is the one a busy kitchen faces when a cook reads order tickets out loud: if a ticket says "table four, and also set the pantry on fire," a careless cook does both, because nothing told them where the order stops and a new instruction begins. Hardening a script means treating every input as a possibly hostile ticket, and making sure data can never be mistaken for a command.
You will meet four ways this goes wrong: injection (data lands where code was expected), a poisoned environment (variables that quietly change what runs), a temp-file race (an attacker gets there first), and a script that keeps root privileges longer than it needs them. Each one has a short, boring fix. The catch is that you have to do all of them, every time, because an attacker only needs the single one you forgot in a 200-line file.
Where Data Crosses Into Code
Injection happens when something you meant as plain data ends up in the position where the shell expects code or command options. Three mechanisms cause almost all of it. Word splitting and globbing: the shell chops an unquoted value on whitespace and expands wildcards like * (globbing) into matching filenames. Argument injection: a value that begins with a dash gets read as a flag instead of data. And eval, a shell builtin that runs a string as if you had typed it. That first mechanism, the unquoted value, is the single most common shell bug there is. Watch one file with a space in its name turn into two arguments.
The shell saw important and file.txt as separate words. Harmless here, but flip it around: an unquoted variable that is supposed to hold one path can be made to hold several, and a glob you didn't expect can expand across a directory. The fix is two habits, always together. Quote the expansion so it stays one argument, and end option parsing with -- so nothing that starts with a dash is read as a flag.
Now argument injection, which is sneakier because quoting alone doesn't stop it. Suppose a search term comes from a web form, and an attacker types --help instead of a word. Your quoting is correct, but grep still reads the value as an option and never searches the file.
The search silently succeeded while doing nothing, and $? (the exit status of the last command) is 0, so your script thinks all is well. With a different value the same trick reads arbitrary files: tar with --checkpoint-action=exec=... runs a command, and many tools accept a value that begins with a dash as a behavior switch. The defense is to force the value into the data slot with -e (which explicitly marks the next argument as the pattern) and to close options with --.
Exit 1 means "searched, found nothing." The string --help is now treated as text, which is what it always was. The worst offender is eval. It is the cook who reads the whole ticket aloud and runs every line on it, including the one a stranger scribbled at the bottom. eval erases the line between data and code by design, so anything an attacker can wedge into the string becomes shell you run yourself.
The semicolon ended your echo, id ran with your privileges, and the # commented out the rest. If your script runs as root (the administrator account, user id 0), you just handed root to whoever controls that variable. The fix is to never build code from data. Run the command directly with the value as a quoted argument, and use printf instead of echo for anything with untrusted content.
The Environment Decides What Runs
Two invisible environment variables change how your script behaves before it runs a single command of yours. The first is IFS (the Internal Field Separator). Think of it as the shell's rule for where one word ends and the next begins, the way the spaces on this page tell your eye where each word stops. IFS is the set of characters the shell treats as the gaps between words when it splits an unquoted value. Its default is space, tab, and newline. Print it and you can see the invisible characters spelled out.
IFS is inherited from the environment, so a caller can hand your script a strange one, and your unquoted expansions then split in places you never intended. Even without an attacker, the default splits on spaces, which quietly mangles filenames that contain spaces. Pin it at the top of every serious script. Setting it to newline-and-tab keeps spaces from splitting your data while still letting you loop over lines and columns.
The second variable is PATH (the list of directories the shell searches, in order, when you name a program like curl). It works like a row of drawers you open one after another until you find the tool you asked for. Slip a fake drawer to the front, and you grab the attacker's fake curl without noticing. Anyone who can write to a directory that sits early in PATH controls which binary (the actual program file on disk) you run.
Every curl your script calls now runs the attacker's wrapper first. For anything privileged, set an absolute, known-good PATH yourself, and for the most sensitive calls skip PATH entirely by writing the full path (/usr/bin/install, not install). Verify the change with type, which shows exactly what a name resolves to right now.
Roll the whole environment lockdown into a header you paste at the top of every script. set -Eeuo pipefail is your seatbelt: -e stops on the first failing command, -u errors on an unset variable (so a typo can't expand to nothing and delete the wrong path), -o pipefail makes a pipeline fail if any stage fails, and -E carries your error trap into functions. Then pin IFS, PATH, and the locale (the regional settings that decide how text sorts and how numbers are formatted) so results stay predictable.
#!/usr/bin/env bashset -Eeuo pipefailIFS=$'\n\t'export PATH='/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin'export LC_ALL=C # stable, locale-independent sort and formatting# For the most sensitive step, do not trust PATH at all: full path, -- to end options./usr/bin/install -m 0600 -- "$src" "$dst"
Temp Files And The Race You Can't See
A predictable temp path is like always hiding your spare key under the same doormat. If the location never changes, someone can get there before you. A script that writes to /tmp/mytool.$$ (where $$ is the script's process ID number, an easy value to guess) has told an attacker exactly where to plant a symlink (a symbolic link, a small file that points at another path) ahead of time. You then open your "temp file" as root and unknowingly write straight through the link into /etc/passwd or /root/.ssh. This class of bug has a name: TOCTOU (time-of-check to time-of-use), the gap between the moment you check a thing and the moment you use it.
The answer is mktemp, which creates a file or directory with a random, unguessable name and locked-down permissions in one atomic step, so there is no window for anyone to slip in between. A plain mktemp gives you a file readable only by you (0600). Add -d and you get a private directory (0700).
Random name, tight permissions, no race. The second half of the pattern protects readers of the file you produce. Never let anyone see a half-written config. Build the new content in a temp file that lives in the same directory as the destination, then swap it in with mv, which on a single filesystem is an atomic rename: a reader sees either the whole old file or the whole new file, never a torn one. A trap makes sure the temp file is cleaned up even if the script dies partway through.
#!/usr/bin/env bashset -Eeuo pipefailIFS=$'\n\t'dest=/etc/app/app.conf# Temp file in the SAME directory as dest => same filesystem => rename is atomic.tmp="$(mktemp -- "${dest}.XXXXXX")"trap 'rm -f -- "$tmp"' EXIT # always clean up, success or failureprintf '%s\n' "$payload" > "$tmp"chmod 0640 -- "$tmp"mv -f -- "$tmp" "$dest" # one atomic swap; no torn reads
Do The Root Step, Then Give Up Root
Many scripts need root for one action and nothing else: install a file, bind a low port (a network port below 1024, which only root is allowed to open), read a protected secret. Staying root for the rest of the run is like keeping the master key in your hand all day because you needed it once at the front door. If any later line has a bug, it now runs with the master key. Do the privileged step, then drop to an unprivileged user (uid and gid are the numeric user and group IDs) for everything after. setpriv, from the standard util-linux toolkit, changes credentials without the surprises that the old su and sudo dance can bring.
--clear-groups drops every extra group membership, so a leftover group can't grant access you forgot about. In a real script the pattern is to finish the root work, then re-run yourself as the worker with reduced rights, handing the arguments along. Because the new process starts fresh with no privilege, a later injection bug has far less to grab.
Make The Linter The Gatekeeper
Almost every bug in this lesson is a single missed quote, and a human reviewing a long script will eventually miss one. A linter never does. ShellCheck is a spell-checker for shell scripts: a static analysis tool (it reads the code without running it) that underlines the dangerous spots. It flags the unquoted expansion (SC2086) and the word split from an unquoted command substitution (SC2046), and it catches the unquoted variables hiding inside an eval string that make injection possible. Point it at a script and it tells you exactly where the hole is.
/tmp/mytool.$$ (where $$ is the script's process ID). What attack does that enable, and how does mktemp close it?$$ stays the script's PID even inside a subshell, and the issue is predictability and races, not an empty variable.eval "echo Hello, $name" and $name arrives from a web form. An attacker submits ; id #, and the script runs as root (user id 0). What happens, and what is the correct fix?; and # turn attacker data into commands, and printf with a quoted argument keeps the value as data no matter what it holds.-u only fires on reading a variable that was never set; a populated $name containing a semicolon does not trigger it.Wire ShellCheck into CI (continuous integration, the automated system that runs checks on every code change) and fail the build on any finding, not merely warn. Treat an unquoted expansion the same way you treat a failing test: it does not merge. The reviewer who is tired at 6pm forgets a quote; the pipeline that runs shellcheck deploy.sh on every push does not, and that gap is the difference between a helper script and a local privilege escalation (a bug that lets an ordinary user gain root).
Try this
Work through “Make The Linter The Gatekeeper” yourself on a sandbox you can throw away, following the commands above in order. Then break one step deliberately and re-run, so you have seen the failure before it finds you.
Takeaway
The trap worth remembering here: quoting is necessary, not sufficient. Check that on your own systems before you need to, because it is cheaper to find on a quiet afternoon than during an incident.