CoursesAdvanced scripting for DevSecOpsHardening Bash: injection, IFS, PATH & temp races

Hardening Bash: injection, IFS, PATH & temp races

Command/argument injection, IFS attacks, PATH hygiene, safe temp files and privilege drop.

Expert40 min · lesson 5 of 15

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.

~/secopslog — bash
$ mkdir -p /tmp/demo && cd /tmp/demo : > 'important file.txt' target='important file.txt' rm $target # unquoted: the space splits one name into two args
rm: cannot remove 'important': No such file or directory rm: cannot remove 'file.txt': No such file or directory

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.

~/secopslog — bash
$ rm -- "$target" && echo "removed cleanly"
removed cleanly

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.

~/secopslog — bash
$ q='--help' grep "$q" /etc/hostname echo "exit: $?"
Usage: grep [OPTION]... PATTERNS [FILE]... Search for PATTERNS in each FILE. Example: grep -i 'hello world' menu.h main.c ... exit: 0

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

~/secopslog — bash
$ q='--help' grep -e "$q" -- /etc/hostname echo "exit: $?"
exit: 1

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.

~/secopslog — bash
$ name='; id #' eval "echo Hello, $name" # the string becomes: echo Hello, ; id #
Hello, uid=0(root) gid=0(root) groups=0(root)

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.

~/secopslog — bash
$ printf 'Hello, %s\n' "$name" # the value stays data, no matter what it holds
Hello, ; id #
Quoting is necessary, not sufficient
Rewriting rm $dir/* as rm "$dir"/* is correct, but the glob still runs and still returns whatever files exist in $dir. If an attacker can create files there, a file named -rf or --no-preserve-root lands in rm's option position. Quote AND add -- : rm -rf -- "$dir"/*. Two separate defenses, both required.

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.

~/secopslog — bash
$ printf '%q\n' "$IFS"
$' \t\n'

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.

~/secopslog — bash
$ files=$(printf 'report one.txt\nreport two.txt') IFS=$' \t\n' # the default: space also splits, so names break apart for f in $files; do echo "[$f]"; done echo '---' IFS=$'\n\t' # only tab/newline split: spaces are safe for f in $files; do echo "[$f]"; done
[report] [one.txt] [report] [two.txt] --- [report one.txt] [report two.txt]

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.

~/secopslog — bash
$ mkdir -p /tmp/evil cat > /tmp/evil/curl <<'EOF' #!/bin/sh echo "stealing args: $*" >&2 exec /usr/bin/curl "$@" EOF chmod +x /tmp/evil/curl export PATH="/tmp/evil:$PATH" type curl
curl is /tmp/evil/curl

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.

~/secopslog — bash
$ export PATH='/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin' type curl
curl is /usr/bin/curl

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.

/opt/deploy/harden.sh
#!/usr/bin/env bash
set -Eeuo pipefail
IFS=$'\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).

~/secopslog — bash
$ f="$(mktemp)"; ls -l "$f" d="$(mktemp -d)"; ls -ld "$d"
-rw------- 1 app app 0 Jul 17 09:14 /tmp/tmp.k3Jf9Qz2Ab drwx------ 2 app app 4096 Jul 17 09:14 /tmp/tmp.9Xcv2Lm0Pn

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.

/opt/app/write-config.sh
#!/usr/bin/env bash
set -Eeuo pipefail
IFS=$'\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 failure
printf '%s\n' "$payload" > "$tmp"
chmod 0640 -- "$tmp"
mv -f -- "$tmp" "$dest" # one atomic swap; no torn reads
mv is only atomic within one filesystem
Renaming across filesystems (for example from /tmp to /etc when they are separate mounts) is a copy-then-delete under the hood, which reopens the very race you are trying to close. Always create the temp file next to its destination, as above, not in /tmp. Also respect $TMPDIR (the environment variable that names your temp directory) if it is set, and never assume /tmp is private on a shared host.

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.

~/secopslog — bash
$ # running as root: drop to the 'app' account (uid 1000) and prove it setpriv --reuid=1000 --regid=1000 --clear-groups id
uid=1000(app) gid=1000(app) groups=1000(app)

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

~/secopslog — bash
$ chown root:root /etc/app/app.conf # the one root step exec setpriv --reuid=app --regid=app --clear-groups \ "$0" --worker "$@" # everything else, unprivileged

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.

~/secopslog — bash
$ cat > deploy.sh <<'EOF' #!/usr/bin/env bash rm -rf $TMPDIR/build EOF shellcheck deploy.sh
In deploy.sh line 2: rm -rf $TMPDIR/build ^-----^ SC2086 (info): Double quote to prevent globbing and word splitting. Did you mean: rm -rf "$TMPDIR"/build For more information: https://www.shellcheck.net/wiki/SC2086 -- Double quote to prevent globbing and word ...
Four ways data becomes code, and how you close each one
Injection
Unquoted value / glob
Quote it: "$x"
Value starts with a dash
End options: add --
eval on untrusted text
Run the command directly
Environment
IFS hijacked
Pin IFS=$'\n\t'
PATH poisoned
Absolute PATH; export it
Sensitive call
Use the full binary path
Temp races
Predictable name
mktemp: random name, 0600
Half-written reads
Write temp, then mv (rename)
Leftover files
trap ... EXIT to clean up
Privilege
Root for the whole run
Drop after the one root step
Extra group access
setpriv --clear-groups
Assume every input is hostile. An attacker only needs the single defense you skipped.
Quick check
01You change a cleanup line from rm $dir/* to rm "$dir"/*. An attacker who can create files inside $dir can still cause damage. Why?
Correct — quoting protects $dir, but the wildcard is outside the quotes and still returns attacker-controlled names that act as flags. Add -- : rm -rf -- "$dir"/*.
Incorrect — the * sits outside the quotes, so it still expands normally; quoting only protects the $dir part.
Incorrect — rm respects argument boundaries, and quoting $dir does prevent word splitting on that value.
Incorrect — this is argument injection through a glob, unrelated to TOCTOU or temp files.
02The lesson warns against writing to a predictable path like /tmp/mytool.$$ (where $$ is the script's process ID). What attack does that enable, and how does mktemp close it?
Incorrect — the danger is not a PID collision, and mktemp does not lock or serialize anything.
Incorrect — mktemp does not encrypt; it restricts permissions to 0600, and the real threat is a pre-planted link, not mere readability.
Correct — the predictable name is the opening for a TOCTOU (time-of-check to time-of-use) symlink race, and mktemp's atomic create with a random name and tight permissions removes the gap.
Incorrect — $$ stays the script's PID even inside a subshell, and the issue is predictability and races, not an empty variable.
03A script contains 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?
Incorrect — eval does no quoting; it re-parses the whole assembled string as code, which is precisely why it is dangerous.
Correct — the injected ; and # turn attacker data into commands, and printf with a quoted argument keeps the value as data no matter what it holds.
Incorrect — -u only fires on reading a variable that was never set; a populated $name containing a semicolon does not trigger it.
Incorrect — quoting inside an eval string does not help, because eval strips one level of quoting and re-parses the injected code anyway.

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.

Related