Quoting and word splitting

The bug class behind most script incidents.

Beginner25 min · lesson 10 of 14

A restaurant kitchen never cooks straight from the handwritten ticket. A prep cook reads it, then lines up separate labeled bowls, one ingredient per bowl, and the chef works only from those bowls. Your shell does the same thing. Before `rm`, `cp`, or `rsync` ever starts, the shell reads your line and chops it into a list of separate words called arguments, and the program receives only that finished list. Quoting is how you decide where the chops land.

Get it wrong and the shell tears one filename into several arguments, or hands a command a list of targets you never named. A lot of the worst one-liners in operations history are not clever exploits. They are a missing pair of double quotes around a variable.

Four words, defined once. A *variable* is named storage (`file='report.txt'`). *Expansion* is the shell swapping `$file` for its value before the command runs. *Word splitting* is the shell cutting that expanded text into separate arguments wherever it finds whitespace. *Globbing* (also called pathname expansion) turns a pattern like `*.log` into the list of filenames it matches. Here is the trap: word splitting and globbing happen automatically to every unquoted expansion. That default is the whole reason this lesson exists.

How the shell chops your line

Every command line runs through a fixed assembly line, and the order matters. First the shell expands parameters (`$var`), command substitutions (`$(...)`), and arithmetic (`$((...))`). Then it word-splits the unquoted results. Then it globs any pattern characters that survived. Then it strips the quote characters themselves. Only then does it hand the finished argument list to the program. The one detail that saves you: splitting and globbing touch the results of expansion, never text sitting inside quotes. `"$file"` rides the whole way as a single, uncuttable word, whatever it holds.

What happens between Enter and the program starting
1You type a line
raw text, e.g. rm $file
2Expand
$var, $(cmd), $((math)) become their values
3Word-split
unquoted results are cut on whitespace: space, tab, newline
4Glob
leftover *, ?, [ ] are matched against filenames
5Strip quotes
the quote characters are removed
6Run
the program receives the final argument list
Quotes lift a value out of the split and glob steps; nothing else does.

You can watch the cut happen. `printf '[%s]\n'` prints each argument it receives inside brackets on its own line, so the bracket count tells you how many words the shell made.

~/secopslog — bash
$ file='quarterly report.txt' printf '[%s]\n' $file # unquoted expansion printf '[%s]\n' "$file" # quoted expansion
[quarterly] [report.txt] [quarterly report.txt]

Brackets you can count. Unquoted, the space inside the name became a cut, and one file turned into two arguments. `"$file"` came through whole. Now watch the same split in front of a command that acts on whatever it is given.

~/secopslog — bash
$ touch "$file" rm $file # the shell hands rm two arguments
rm: cannot remove 'quarterly': No such file or directory rm: cannot remove 'report.txt': No such file or directory

That error is the lucky outcome. `rm` complained only because nothing named `quarterly` or `report.txt` was sitting there. Had those files existed, `rm` would have deleted both, exited 0, and left nothing in the logs to say the wrong things were gone. The characters the shell cuts on live in a variable called IFS (Internal Field Separator, the shell's own list of separator characters), which defaults to space, tab, and newline. A Unix filename can hold any byte except `/` and the all-zeros NUL byte, spaces and newlines included. So no clever IFS value makes unquoted expansion safe against every name a directory can hold. Double-quoting is the general fix.

That exit code is the part most people miss. A split filename that matches nothing fails loudly, the way you just saw. A split filename that matches real files does its damage in silence and returns success. Never read a zero exit code as proof that your quoting was correct. A clean exit is exactly what the dangerous case produces.

Three kinds of quotes, and the one everyone forgets

The rules are short enough to keep in your head. Double quotes allow expansion but switch off splitting and globbing, so `"$file"` is one argument, always. Single quotes are fully literal, with no expansion at all, which makes them right for regular expressions and awk programs where you want `$` and `*` handed to that tool untouched. Unquoted is the only mode where the shell splits, and inside a script that is almost never what you meant.

One expansion is worth memorizing on its own. `"$@"`, with the double quotes, expands to every argument your script was given, each preserved as its own separate word. It is the way, effectively the only correct way, to forward your script's arguments to another command. Its lookalikes all corrupt anything containing a space: `$*` and a bare unquoted `$@` re-split, and `"$*"` mashes every argument into one.

show-args.sh
#!/usr/bin/env bash
echo '--- "$@" keeps each argument whole ---'
printf '[%s]\n' "$@"
echo '--- $* re-splits on whitespace ---'
printf '[%s]\n' $*
~/secopslog — bash
$ ./show-args.sh 'one two' three
--- "$@" keeps each argument whole --- [one two] [three] --- $* re-splits on whitespace --- [one] [two] [three]

Build argument lists with arrays, not strings

Remember the labeled bowls from the kitchen. An array is that same idea in bash: one slot per item, and the shell never re-chops whatever is already sitting in a slot. When the flags you pass depend on something decided at runtime, an optional `--dry-run`, or a directory whose name has a space in it, build them in an array, which is bash's real list type. Append with `name+=(...)`, expand with `"${name[@]}"`, and every element lands as exactly one argument regardless of its contents. The shortcut people reach for, gluing flags into one space-separated string and expanding it unquoted, quietly puts splitting and globbing back on data you only half control.

sync-backup.sh
#!/usr/bin/env bash
set -euo pipefail
opts=(--archive --verbose --compress --delete)
if [[ -n "${DRY_RUN:-}" ]]; then
opts+=(--dry-run)
fi
src='/srv/app data' # the space is deliberate
rsync "${opts[@]}" "$src/" [email protected]:/srv/backups/app/

You can confirm the array is right before you let it drive a real transfer. Replace `rsync` with `printf` and print the exact argument list the script would build, then count the brackets.

~/secopslog — bash
$ DRY_RUN=1 bash -c ' opts=(--archive --verbose --compress --delete) [[ -n "${DRY_RUN:-}" ]] && opts+=(--dry-run) src="/srv/app data" printf "[%s]\n" rsync "${opts[@]}" "$src/"'
[rsync] [--archive] [--verbose] [--compress] [--delete] [--dry-run] [/srv/app data/]

A filename that pretends to be a flag

A command's option parser works like a doorman reading a guest list. Anyone whose name starts with a dash, he assumes, is staff carrying instructions, so he acts on them instead of showing them to a seat. Quoting guarantees a value arrives as a single argument. It does nothing to stop that argument from posing as an option. Any directory other people can write to, an upload folder, `/tmp`, a shared build workspace, can hold a file named literally `-rf` or `--delete`. When a glob expands, that name falls into your command's argument list, the parser sees the leading dash, and it treats the file as a flag. This is a genuine attack class, not a party trick. Filenames like `--checkpoint=1` paired with `--checkpoint-action=exec=sh` have turned a routine `tar` backup into code execution, and a cron job (a task the system runs automatically on a schedule) that sweeps a shared directory is a favorite place to leave them.

~/secopslog — bash
$ cd /srv/uploads # a directory other users can write to ls -A
-rf archive report.pdf

Someone dropped a file named `-rf`. Now you clean the directory the way you have a thousand times before.

~/secopslog — bash
$ rm * # you meant: delete everything here ls -A
-rf

The glob sorted `-rf` to the front, `rm` read it as its recursive-and-force flag, and `archive` and `report.pdf` were gone. The planted file, counted as a flag rather than a target, came through untouched. Two fixes exist, and one of them belongs on every destructive command like this. Put `--` after the last real option. It tells the command that everything after it is a filename, so `rm -- *` treats `-rf` as the operand it actually is. That marker is part of the POSIX (Portable Operating System Interface, the standard that defines how Unix tools behave) argument conventions, and nearly every tool respects it, `grep -r -- "$pattern" .` and `chown root: -- "$dir"` included. The other fix is to anchor the glob to a path, so no expanded name can begin with a dash: `rm ./*` yields `./-rf`, which can only be read as a filename. The path form even covers the rare tool that ignores `--`.

Quoting cannot save an empty variable
In 2015 the Steam Linux client ran `rm -rf "$STEAMROOT/"*`, quoted correctly. After a moved install left `STEAMROOT` empty, the line collapsed to `rm -rf /*` and wiped users' home directories and mounted drives. Quoting decides how a value is split, not whether it holds anything. Before a destructive command, demand the value: `"${STEAMROOT:?not set}"` aborts with a message instead of expanding to nothing, and `set -u` catches variables that were never set (though not ones set to an empty string).

When you actually want splitting

Sometimes chopping a string apart is the actual goal. Slicing a loaf into even portions is honest work when you reach for a bread knife. Doing it by tearing at the loaf with both hands is not. So when you have a configuration line with several fields, or a list you need to walk one item at a time, cut it deliberately with a tool built for the job, instead of leaning on the unquoted default and hoping. `read -ra parts <<< "$line"` splits one string into an array, and you can set IFS for that single command to choose the delimiter. `mapfile -t lines < file` reads a whole file into an array, one line per element, with no splitting or globbing at all. For filenames, drop the text processing entirely: `find . -type f -print0` ends each name with a NUL byte, the one byte a name can never contain, and `xargs -0 rm --` reads them the same way, so spaces, newlines, and glob characters all survive the trip.

~/secopslog — bash
$ line='web01:10.0.0.11:active' IFS=':' read -ra parts <<< "$line" printf 'field: [%s]\n' "${parts[@]}"
field: [web01] field: [10.0.0.11] field: [active]

Standing defaults for anything you ship: double-quote every expansion, `"$var"`, `"$(cmd)"`, `"${arr[@]}"`, without stopping to debate whether a value 'could' hold a space; write `--` before the file operands of destructive commands; and prefer `printf '%s\n'` to `echo` for arbitrary data, because `echo` mishandles values that start with `-` or contain backslashes. You do not have to police this by eye. ShellCheck (a static analyzer for shell scripts) flags every unquoted expansion as SC2086, which turns the habit into something a machine enforces, and a later lesson wires that check into your CI (continuous integration) pipeline so it runs on every change.

~/secopslog — bash
$ shellcheck deploy.sh
In deploy.sh line 4: rm -rf $BUILD_DIR/* ^--------^ SC2086 (info): Double quote to prevent globbing and word splitting. For more information: https://www.shellcheck.net/wiki/SC2086 -- Double quote to prevent globbing ...

Point it at your scripts today and clear what it finds: `find . -name '*.sh' -exec shellcheck {} +`. Every SC2086 you fix is one more filename that cannot quietly become an argument you never typed.

Quick check
01A cron job runs `rm *` in a world-writable upload directory. An attacker drops a file named literally `-rf`, so the glob expands to `rm -rf archive report.pdf` and files are recursively deleted. Which single change stops the injected filename from being read as an option?
Correct — `--` marks the end of options, so `-rf` after it is treated as a filename, not a flag.
Incorrect — IFS controls word splitting, but `-rf` is already a single, correctly-split argument; the problem is option parsing.
Incorrect — `"$@"` forwards positional parameters and has nothing to do with the glob `*`; a `-rf` in that list would still parse as a flag.
Incorrect — quotes stop the glob from expanding at all, so `rm` looks for one file named literally `*` and fails.
02A wrapper script must forward every argument it received to another command, with each argument kept intact even when it contains spaces. Which form does this correctly?
Incorrect — unquoted, it re-splits every argument on whitespace, breaking any that contains a space.
Incorrect — it joins all arguments into a single word, so the command receives one argument instead of many.
Incorrect — like `$*`, a bare `$@` re-splits arguments on whitespace.
Correct — quoted, `"$@"` expands to every argument with each preserved as its own separate word, the only reliable way to forward them.
03A script runs `rm $target` with `target='old logs'`, and the working directory happens to contain files named `old` and `logs`. Both files are deleted, `rm` exits 0, and the job's monitoring shows green. Why is that clean exit the dangerous case, and what prevents it?
Incorrect — `rm` does report real failures; it exited 0 here only because the deletions genuinely succeeded.
Incorrect — no recursion occurred and there is no such flag; the cause is word splitting, not recursion.
Correct — the space in the value split one intended name into two arguments, and a successful deletion of the wrong files returns 0, which is why a clean exit is no proof of correct quoting.
Incorrect — the deletion did not fail; it succeeded on the wrong files, which is exactly why strict mode stays silent.

Related