Linux Fundamentals

Bash Scripting for Hackers

Variables, conditionals, loops, and functions - automate recon and build your own tools.

Medium 26 minbashscriptingautomation

A command you type once is a task. A command you write down is a tool. Bash scripting turns your one-liners into repeatable, parameterized programs that you can share, version, and run against a hundred targets while you drink coffee.

This lesson builds from the ground up, ending with a practical host-sweep and banner-grab script that you'll actually use.

The shebang

Every bash script starts with a shebang - the first line that tells the OS which interpreter to use.

#!/usr/bin/env bash

Using /usr/bin/env bash (rather than /bin/bash directly) is more portable - it finds bash in $PATH regardless of where it's installed.

Make your script executable:

chmod +x script.sh
./script.sh

Variables

name="Alice"
count=42
result=$(whoami)        # Command substitution - captures output

Rules: no spaces around =, variable names are case-sensitive, convention is lowercase for local variables, ALL_CAPS for environment/global.

Quoting matters

name="Alice Smith"
 
echo $name          # Works if no special chars, but risky
echo "$name"        # Always quote variables - correct
echo '$name'        # Single quotes: literal string, no expansion

Always double-quote your variables

If $name contains spaces or special characters and you forget the quotes, word splitting will break your script in mysterious ways. "$var" is almost always what you want.

Command substitution

user=$(whoami)
hostname=$(hostname -f)
date_stamp=$(date +%Y-%m-%d)
ip=$(ip route get 1 | awk '{print $NF; exit}')

$( ) runs the command in a subshell and captures its stdout. The older backtick syntax `cmd` works identically but is harder to nest and read.

Special variables

VariableMeaning
$0Script name
$1, $2...Positional arguments
$@All arguments (as separate words)
$#Number of arguments
$?Exit code of last command
$$PID of current shell
$!PID of last background job
#!/usr/bin/env bash
echo "Script: $0"
echo "First arg: $1"
echo "All args: $@"
echo "Arg count: $#"

Conditionals

if / else

if [ condition ]; then
    echo "true"
elif [ other_condition ]; then
    echo "other"
else
    echo "false"
fi

Test conditions with [ ] and [[ ]]

[ -f file ]      # File exists and is a regular file
[ -d dir ]       # Directory exists
[ -r file ]      # File is readable
[ -x file ]      # File is executable
[ -z "$var" ]    # String is empty
[ -n "$var" ]    # String is non-empty
[ "$a" = "$b" ]  # String equality
[ "$a" != "$b" ] # String inequality
[ $n -eq 42 ]    # Numeric equals
[ $n -gt 10 ]    # Numeric greater than
[ $n -lt 10 ]    # Numeric less than

[[ ]] is the modern bash version - supports &&, ||, =~ for regex matching, and doesn't require quoting variables:

if [[ "$response" =~ ^[0-9]+$ ]]; then
    echo "It's a number"
fi
 
if [[ -f "/etc/passwd" && -r "/etc/passwd" ]]; then
    echo "passwd is readable"
fi

Exit codes

Every command returns an exit code: 0 means success, anything else is failure. This is what $? captures.

ping -c1 -W1 10.10.10.5 &>/dev/null
if [ $? -eq 0 ]; then
    echo "Host is up"
else
    echo "Host is down"
fi
 
# Idiomatic shorthand:
ping -c1 -W1 10.10.10.5 &>/dev/null && echo "up" || echo "down"

Loops

for loop

# Iterate a list
for host in 10.10.10.1 10.10.10.2 10.10.10.3; do
    echo "Checking $host"
done
 
# Iterate a range
for i in $(seq 1 254); do
    echo "10.10.10.$i"
done
 
# Iterate files
for f in /var/log/*.log; do
    wc -l "$f"
done
 
# C-style for loop
for ((i=0; i<10; i++)); do
    echo "$i"
done

while loop

while [ condition ]; do
    # body
done
 
# Read file line by line (the correct way)
while IFS= read -r line; do
    echo "Line: $line"
done < targets.txt
 
# Countdown
count=10
while [ $count -gt 0 ]; do
    echo "$count"
    ((count--))
done

IFS= read -r for line reading

IFS= prevents leading/trailing whitespace from being stripped. -r prevents backslash interpretation. This is the canonical way to read a file line by line without mangling the content.

Loop control

break       # Exit the loop immediately
continue    # Skip to the next iteration

Functions

check_host() {
    local target="$1"    # local keeps variables scoped
    local port="${2:-80}" # default to 80 if not provided
 
    if nc -zw2 "$target" "$port" 2>/dev/null; then
        echo "[+] $target:$port OPEN"
        return 0
    else
        echo "[-] $target:$port closed/filtered"
        return 1
    fi
}
 
# Call it
check_host 10.10.10.5 22
check_host 10.10.10.5      # uses default port 80

Always use local for function variables - otherwise they pollute the global scope and cause subtle bugs.

Input and output

read -p "Enter target: " target          # Prompt and read
read -s -p "Enter password: " password   # Silent read (no echo)
echo ""                                  # Newline after silent input

Useful patterns

Check if running as root

if [[ $EUID -ne 0 ]]; then
    echo "[-] This script requires root. Run with sudo."
    exit 1
fi

Validate argument count

if [[ $# -lt 1 ]]; then
    echo "Usage: $0 <target-subnet>"
    exit 1
fi

Colors for output (optional but nice)

RED='\033[0;31m'
GREEN='\033[0;32m'
NC='\033[0m'   # No Color
 
echo -e "${GREEN}[+] Found something${NC}"
echo -e "${RED}[-] Connection refused${NC}"

Building a real script: host sweep + banner grab

Let's put it all together. This script takes a CIDR like 10.10.10.0/24, pings each host, then for the ones that respond, tries to grab a banner on a list of common ports.

#!/usr/bin/env bash
# sweep.sh - ICMP sweep + TCP banner grab
# Usage: ./sweep.sh <base-ip> <start> <end>
# Example: ./sweep.sh 10.10.10 1 254
 
set -euo pipefail   # Exit on error, undefined vars, pipe failures
 
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m'
 
PORTS=(21 22 23 25 80 443 445 3306 3389 8080)
 
if [[ $# -ne 3 ]]; then
    echo "Usage: $0 <base-ip> <start> <end>"
    echo "Example: $0 10.10.10 1 254"
    exit 1
fi
 
BASE="$1"
START="$2"
END="$3"
LIVE_HOSTS=()
 
echo -e "${YELLOW}[*] Sweeping ${BASE}.${START}-${END}...${NC}"
 
# Phase 1: ICMP ping sweep
for i in $(seq "$START" "$END"); do
    ip="${BASE}.${i}"
    if ping -c1 -W1 "$ip" &>/dev/null; then
        echo -e "${GREEN}[+] ${ip} is UP${NC}"
        LIVE_HOSTS+=("$ip")
    fi
done
 
echo ""
echo -e "${YELLOW}[*] Found ${#LIVE_HOSTS[@]} live host(s). Checking ports...${NC}"
echo ""
 
# Phase 2: Port check + banner grab on live hosts
for host in "${LIVE_HOSTS[@]}"; do
    echo -e "${YELLOW}--- $host ---${NC}"
    for port in "${PORTS[@]}"; do
        # -z: zero-I/O mode (just check), -w2: 2 second timeout
        if nc -zw2 "$host" "$port" 2>/dev/null; then
            # Try to grab a banner (1 second read timeout)
            banner=$(echo "" | nc -w1 "$host" "$port" 2>/dev/null | tr -d '\n\r' | head -c 80)
            if [[ -n "$banner" ]]; then
                echo -e "  ${GREEN}[+] $port/tcp OPEN${NC} - $banner"
            else
                echo -e "  ${GREEN}[+] $port/tcp OPEN${NC}"
            fi
        fi
    done
    echo ""
done
 
echo -e "${GREEN}[*] Sweep complete.${NC}"
kali@vr4cs: ~
 

The MySQL banner revealed its exact version - that feeds straight into searchsploit mysql 5.7.38. The SSH banners reveal the OS (Debian vs Ubuntu is inferable from the package suffix). All of this before running nmap.

set -euo pipefail

These three options make scripts dramatically safer: -e exits on any error, -u treats unset variables as errors (catches typos), -o pipefail propagates failures through pipes. Add them to every script.

Key takeaways

  • Start every script with #!/usr/bin/env bash and set -euo pipefail.
  • Always double-quote variables: "$var" not $var.
  • $(command) captures command output into a variable.
  • [[ ]] is the modern bash test - prefer it over [ ].
  • Use local in functions to avoid polluting the global scope.
  • for i in $(seq 1 254) + nc -zw2 is the foundation of any custom scanner.
  • Exit codes ($?, &&, ||) let you chain actions based on success or failure.