Bash Commands Cheat Sheet for Developers and Security Engineers
#Your Shell Executes Things You Never Typed#link
On September 24, 2014, the security world learned that bash would happily execute code hidden inside an environment variable. The bug, CVE-2014-6271 and known as Shellshock, scored 9.8 on the NVD scale. A variable whose value began with a function definition was parsed as a function, and anything appended after the closing brace ran as a command. Every CGI script that copied an HTTP header into the environment handed an attacker a remote shell. The lesson was not that CGI was dangerous. It was that bash has a much larger attack surface than the scripts people write in it, and most engineers never audit the parts they did not author.
bash --version | head -1env x='() { :;}; echo VULNERABLE' bash -c 'echo this is a test'
The parsing flaw behind Shellshock was patched, but exported bash functions still exist as a mechanism and still cross privilege boundaries when you run sudo with a preserved environment. Do not treat a shell version number as proof that environment-driven execution is impossible in your stack. If a privileged script consumes data from an environment variable, that data is executable input until you prove otherwise.
Belief 1: Unquoted Variables Are Safe When the Value Has No Spaces
The space is not the dangerous character. The dangerous characters are the asterisk, the question mark, the square brackets, and the leading dash. An unquoted expansion undergoes word splitting and then pathname expansion, so a variable holding a single asterisk becomes the entire contents of the working directory. A variable whose value begins with a dash is not a filename at all as far as the receiving command is concerned, it is an option. Both failures happen with perfectly ordinary values that contain no whitespace whatsoever.
The four-line demo above is harmless. The production version of it is not. A script that runs `rm -rf $TARGET_DIR/` exits with a clean status on a normal day and deletes the filesystem root on the day TARGET_DIR is empty, because the unquoted expansion vanishes and the command becomes `rm -rf /`. Two defenses, both cheap: write `${TARGET_DIR:?refusing to run without TARGET_DIR}` so an unset variable aborts the script, and write `rm -rf -- "$TARGET_DIR"` so a path starting with a dash is treated as a path.
Belief 2: set -e Makes a Script Safe
errexit is the most trusted and least reliable safety mechanism in shell scripting. It does not fire inside conditionals, it does not fire for most commands in a pipeline, it does not fire for anything in an or-list, and it does not fire inside a function that was invoked from an if statement. The one that catches experienced engineers is the assignment combined with local, because the local builtin itself returns zero and errexit only ever sees the outer command's status.
#!/usr/bin/env bashset -euo pipefailcheck() {# local's own exit status is 0, which is the only thing errexit inspects.# The failure of false is captured into the variable and then discarded.local cached=$(false)echo "still running, and cached holds an empty string"}check
| Construct | Does errexit fire? | Why |
|---|---|---|
| cmd | Yes | The command's status is the status of the whole list |
| cmd | other | Only if other fails | Without pipefail the pipeline reports its last command's status |
| if cmd; then | No | Explicitly exempt, so a failing test is usable as a condition |
| cmd || true | No | The command sits inside an or-list |
| ! cmd | No | The return value is inverted before errexit sees it |
| local x=$(cmd) | No | local returns 0, so the substitution's failure never surfaces |
| function body called from if | No, throughout the call | errexit is suppressed inside the entire function invocation |
Belief 3: grep Is Enough to Parse Logs
grep is a line matcher, and it is excellent at that job. It is a poor parser, and it fails in three ways that are hard to notice. It stops scanning a file it decides is binary and prints a single line saying so, which silently truncates results on logs with a stray null byte. It treats character classes as locale-dependent, so a pattern that matches reliably on your workstation can behave differently on a container with a minimal locale. And it has no concept of structure, so a JSON log line with a nested field of the same name will match a pattern you aimed at a different field entirely. For one-off searches grep wins. For anything you will run twice, reach for ripgrep if it is installed, awk for column arithmetic, and jq for structured records. Install jq on your jump hosts before you need it, not during an incident.
Failed password for invalid user admin from 203.0.113.44 port 51234 ssh2\nFailed password for root from 198.51.100.7 port 40222 ssh2\nAccepted password for deploy from 192.0.2.19 port 33110 ssh2
# Two captures per match: the account name and the source address.grep -oP 'Failed password for (?:invalid user )?\K[^ ]+ from [0-9.]+' /var/log/auth.log \| awk '{print $1, $3}' | sort | uniq -c | sort -rn | head -20# The same question against structured nginx logs, where grep cannot help.jq -r 'select(.status >= 400) | [.time_iso8601, .remote_addr, .status, .request] | @tsv' \/var/log/nginx/access.log | sort | uniq -c | sort -rn | head -20
Belief 4: find Piped to xargs Is the Same as find -exec
They are equivalent right up until a filename contains a space or a newline, at which point xargs splits one path into two and your cleanup command operates on files that do not exist. The default delimiter for xargs is any whitespace, and filenames on Linux are permitted to contain everything except a null byte and a slash. The fix is a matched pair: find emits null-delimited output with -print0, and xargs consumes it with -0. There is also a difference in behavior worth knowing, because find -exec runs one invocation per file unless you terminate it with a plus sign instead of a semicolon, and on a large tree that difference is the gap between seconds and minutes.
- find /var/log -name "*.gz" | xargs zcat | grep -i error+ find /var/log -name "*.gz" -print0 | xargs -0 -r zcat | grep -i -- error
Belief 5: A Secret Passed on the Command Line Is Private
Every argument you type is world-readable on a default Linux system. The kernel exposes it at /proc/PID/cmdline, ps renders it, and your shell writes it to a history file that outlives the session. Environment variables are not a fix either, because /proc/PID/environ is readable by any process running as the same user. The credential in the example below is fictional and exists only to show the shape of the leak. The correct patterns are a configuration file with mode 0600, a file descriptor passed to the tool, or a secrets manager that the process queries at startup.
Clearing history does not undo the exposure. `HISTCONTROL=ignorespace` prevents the line from reaching the history file, but the process list still carried it for the lifetime of the command and any monitoring agent may already have recorded it. `history -c` runs too late if the shell has already flushed. The only reliable control is not putting the secret in an argument or an environment variable in the first place, and if you must, prefixing the command with a space and unsetting the history file for the session with `set +o history`.
The Commands I Actually Reach For During an Investigation
| Command | What it answers | The flag people forget |
|---|---|---|
| find / -xdev -perm -4000 -type f 2>/dev/null | Which SUID binaries exist | -xdev stops it crossing into mounted filesystems |
| getcap -r / 2>/dev/null | Which binaries carry file capabilities | -r is required to recurse, and it is not the default |
| ss -tulpn | What is listening and which process owns it | -p only names other users' processes when run as root |
| lsof +L1 | Which deleted files are still held open | +L1 means link count below one, the classic hidden-malware tell |
| journalctl -u ssh --since "2 hours ago" -o json | Structured systemd logs you can pipe to jq | -o json, because the default short format discards fields |
| ausearch -m EXECVE -ts recent | What auditd recorded being executed | auditd must be running, not merely installed |
| find /etc -mtime -2 -type f | Config files changed in the last two days | -mtime counts 24-hour periods, not calendar days |
| sha256sum -c manifest.txt | Whether a set of files still matches its known hashes | -c reads the checksum list, which nobody remembers |
| strings -a -n 8 sample.bin | grep -i http | Quick triage of an unknown binary | -n raises the minimum string length past noise |
| diff <(sort a.txt) <(sort b.txt) | Compare two unordered lists without temp files | Process substitution needs bash, not sh |
- ▪Start every non-trivial script with `set -euo pipefail`, then stop assuming it catches everything, because it demonstrably does not.
- ▪Quote every expansion, use `"$@"` and never `$@`, and build argument lists as arrays rather than strings.
- ▪Guard against unset variables with `${VAR:?message}` before any destructive command, especially anything involving rm or a redirect to a path.
- ▪Put `--` before any user-supplied or variable-supplied path so a leading dash cannot be read as an option.
- ▪Set `umask 077` before creating a file that will hold a secret, rather than creating it permissively and running chmod after, which leaves a window.
- ▪Treat `curl | bash` as a finding. Fetch with `curl -sSfL -o install.sh`, read the file, verify a hash if one is published, then run it.
- ▪Prefer `printf '%s\n'` over `echo` for any data you did not author, because echo's handling of backslashes and leading dashes varies.
- ▪Keep `set -x` out of anything that touches credentials, since the trace prints expanded values into the log.
Learn the four curl flags that make downloads safe by default. `-s` silences the progress meter, `-S` restores error messages that `-s` would otherwise hide, `-f` fails the command on an HTTP status of 400 or higher, and `-L` follows redirects. Without `-f` a 404 HTML page is a successful download as far as the shell is concerned, and a pipeline that sends it straight into an interpreter will try to execute it.
The engineers whose scripts survive contact with production are not the ones who memorized the most flags. They are the ones who stopped assuming that a variable is a string, that a filename has no spaces, that a secret typed into a terminal is private, and that set -e is a substitute for checking a return code. Audit those four assumptions in your own scripts this week and you will find something.