Skip to main content
  1. Posts/

Bash Scripting Language - Basic Concepts and Syntax

··3141 words·15 mins·
Table of Contents

Bash is the lingua franca of Linux. Every shell-bound system you’ll touch in a pen test has it, every container ships it, and most restricted shells eventually let it through. You’re going to write Bash whether or not you want to, so it’s worth learning the parts that won’t trip you up.

This post is a working tour: variables, control flow, functions, and the bits of Bash that catch people out (quoting, error handling, the strict-mode preamble). It also has a section on /dev/tcp because every red teamer should know how to do TCP from a shell that has nothing else.

Security and safety considerations
#

Note: Before proceeding with any scripting examples in this article, consider these security practices:

Before running any script
#

  1. Always get written authorization before testing on systems you don’t own
  2. Test in isolated environments first (virtual machines, containers, or dedicated lab systems)
  3. Use ShellCheck to check your scripts for security issues
  4. Follow the principle of least privilege - never run scripts with unnecessary permissions
  5. Understand the legal and ethical implications of your actions

Essential safety settings
#

Every professional Bash script should start with these safety settings:

#!/bin/bash

# Essential safety settings - ALWAYS include these
set -euo pipefail

# -e: Exit immediately if a command exits with non-zero status
# -u: Treat unset variables as error
# -o pipefail: Return exit status of last command in pipe that failed

Basic syntax
#

If you’ve used a programming language before, the basics will look familiar. Skip ahead if you have. Otherwise:

Variables and data types
#

Like most programming languages, Bash uses variables to store data. Variables can hold any data type, including strings, integers, and arrays. To declare a variable, assign a value to it:

#!/bin/bash
set -euo pipefail

# Declare a string variable
str="Hello, World!"

# Declare an integer variable
num=42

# Declare an array
arr=("apple" "banana" "cherry")

# Access array elements
echo "${arr[0]}"  # Output: apple
echo "${arr[@]}"  # Output: apple banana cherry

Operators
#

Bash supports various operators, including arithmetic, comparison, and logical operators. Here are a few examples:

#!/bin/bash
set -euo pipefail

# Arithmetic operators
a=10
b=5
echo $((a + b))    # Output: 15
echo $((a - b))    # Output: 5
echo $((a * b))    # Output: 50
echo $((a / b))    # Output: 2
echo $((a % b))    # Output: 0

# Comparison operators
a=10
b=5
if [ "$a" -gt "$b" ]; then
    echo "a is greater than b"
fi

# String comparison
str1="hello"
str2="world"
if [[ "$str1" < "$str2" ]]; then
    echo "str1 comes before str2 alphabetically"
fi

# Logical operators
a=10
b=5
if [ "$a" -gt "$b" ] && [ "$a" -lt 20 ]; then
    echo "a is between 5 and 20"
fi

Control structures
#

Control structures allow you to execute code conditionally or repeatedly. Bash supports if/else statements, loops, and case statements. Here are a few examples:

#!/bin/bash
set -euo pipefail

# If/else statement
a=10
b=5
if [ "$a" -gt "$b" ]; then
    echo "a is greater than b"
else
    echo "b is greater than a"
fi

# For loop with range
for i in {1..5}; do
    echo "$i"
done

# While loop
counter=0
while [ "$counter" -lt 5 ]; do
    echo "Counter: $counter"
    ((counter++))
done

# Case statement
a="apple"
case "$a" in
    "apple") echo "It's an apple";;
    "banana") echo "It's a banana";;
    "cherry") echo "It's a cherry";;
esac

Functions
#

Functions allow you to encapsulate code and reuse it throughout your script. To define a function, use the following syntax:

#!/bin/bash
set -euo pipefail

# Define a function
function greet {
    local name="$1"  # Use local variables for function parameters
    echo "Hello, $name!"
}

# Alternative function syntax
greet_alt() {
    local name="$1"
    echo "Hello, $name!"
}

# Call the function
greet "World"

Advanced elements
#

The basics will get you 80% of the way. The remaining 20% — argument handling, conditional tests, redirection, parameter expansion — is where most of the bugs you’ll write live.

Conditional tests
#

Bash supports a wide range of conditional tests, including file tests, string tests, and numeric tests. Here are a few examples:

#!/bin/bash
set -euo pipefail

# File tests
if [ -f "/path/to/file" ]; then
    echo "File exists and is regular file"
fi

if [ -d "/path/to/directory" ]; then
    echo "Directory exists"
fi

if [ -r "/path/to/file" ]; then
    echo "File is readable"
fi

# String tests
if [ "$str" = "Hello, World!" ]; then
    echo "Strings are equal"
fi

if [ -n "$str" ]; then
    echo "String is not empty"
fi

if [ -z "$empty_var" ]; then
    echo "Variable is empty or unset"
fi

# Numeric test
if [ "$num" -gt 10 ]; then
    echo "Number is greater than 10"
fi

Taking arguments for the script
#

Bash scripts can take arguments from the command line, allowing you to customize their behavior for different use cases. Here’s an example:

#!/bin/bash
set -euo pipefail

# Validate required arguments
if [ $# -lt 2 ]; then
    echo "Usage: $0 <arg1> <arg2>" >&2
    exit 1
fi

# Take two arguments from the command line
echo "First argument: $1"
echo "Second argument: $2"

You could then run this script with two arguments:

./script.sh arg1 arg2

Default values
#

You can also set default values for arguments in case they’re not provided. Here’s an example:

#!/bin/bash
set -euo pipefail

# Set default values for the first two arguments
arg1="${1:-default1}"
arg2="${2:-default2}"

echo "First argument: $arg1"
echo "Second argument: $arg2"

You could then run this script with only one argument:

./script.sh arg1

Heredoc
#

Heredoc allows you to define a text block as a variable, which can be helpful for creating templates or multi-line strings. Here’s an example:

#!/bin/bash
set -euo pipefail

# Define a Heredoc block as a variable
myvar=$(cat << 'EOF'
This is a multi-line string.
It can contain any characters, including "quotes" and 'apostrophes'.
EOF
)

# Print the variable
echo "$myvar"

Boolean logic
#

Bash supports boolean logic by using the “&&” and “||” operators. Here are a few examples:

#!/bin/bash
set -euo pipefail

# Boolean AND
if [ "$a" -gt 10 ] && [ "$a" -lt 20 ]; then
    echo "a is between 10 and 20"
fi

# Boolean OR
if [ "$a" -lt 5 ] || [ "$a" -gt 20 ]; then
    echo "a is less than 5 or greater than 20"
fi

Redirection
#

Bash supports redirection of input and output, allowing you to read input from files and redirect output to files. Here are a few examples:

#!/bin/bash
set -euo pipefail

# Read input from a file
while IFS= read -r line; do
    echo "Line: $line"
done < "input.txt"

# Read with line numbers
line_num=0
while IFS= read -r line; do
    ((line_num++))
    echo "Line $line_num: $line"
done < "input.txt"

# Redirect output to a file
echo "Output" > "output.txt"

Reading Input
#

Bash supports reading input from the user, which can be helpful for interactive scripts. Here’s an example:

#!/bin/bash
set -euo pipefail

# Ask the user for input
read -p "Enter your name: " name

# Validate input
if [[ -z "$name" ]]; then
    echo "Name cannot be empty" >&2
    exit 1
fi

# Print the input
echo "Hello, $name!"

Special variables
#

Bash includes several special variables that provide information about the environment, such as the current working directory and the number of arguments passed to the script. Here are a few examples:

#!/bin/bash
set -euo pipefail

# Current working directory
echo "Current directory: $PWD"

# Number of arguments
echo "Number of arguments: $#"

# Script name
echo "Script name: $0"

# Last argument
echo "Last argument: ${!#}"

Variable substitutions
#

Bash supports variable substitutions, which allow you to manipulate variables in various ways. Here are a few examples:

#!/bin/bash
set -euo pipefail

# Replace a substring (first occurrence)
str="Hello, World!"
echo "${str/Hello/Hi}" # Output: Hi, World!

# Replace all occurrences of a substring
str="Hello, World!"
echo "${str//o/}" # Output: Hell, Wrld!

# Extract substring
str="Hello, World!"
echo "${str:0:5}" # Output: Hello
echo "${str:7:5}" # Output: World

# Replace with default value if variable is empty
echo "${var:-default}"

# Replace with default value if variable is unset
echo "${var:=default}"

# Replace with default value if variable is empty or unset
echo "${var:-default}"

# Return error if variable is empty or unset
echo "${var:?error}"

Modern Bash best practices
#

ShellCheck integration
#

ShellCheck is an essential tool for validating Bash scripts and catching security issues:

# Install shellcheck
sudo apt-get install shellcheck

# Check your scripts
shellcheck myscript.sh

# ShellCheck does not auto-fix scripts. It reports issues and recommended rewrites.
# Apply fixes manually, then re-run ShellCheck.

Proper quoting
#

Always quote your variables to prevent word splitting and globbing issues:

#!/bin/bash
set -euo pipefail

# BAD - Word splitting issues with spaces in filenames
for file in $(ls *.txt); do
    echo "$file"
done

# GOOD - Proper quoting with find
while IFS= read -r -d '' file; do
    echo "$file"
done < <(find . -name "*.txt" -print0)

Input validation
#

Always validate and sanitize inputs:

#!/bin/bash
set -euo pipefail

# Validate inputs
validate_input() {
    local input="$1"
    if [[ ! "$input" =~ ^[a-zA-Z0-9_-]+$ ]]; then
        echo "Invalid input detected" >&2
        exit 1
    fi
}

# Sanitize file paths
sanitize_relpath() {
    local path="$1"

    # Prefer rejecting unsafe input over trying to "clean" it.
    # Require a relative path.
    if [[ "$path" == /* ]]; then
        echo "Absolute paths are not allowed: $path" >&2
        exit 1
    fi

    # Reject path traversal components.
    if [[ "$path" == ".." || "$path" == ../* || "$path" == */../* || "$path" == */.. ]]; then
        echo "Path traversal detected: $path" >&2
        exit 1
    fi

    printf '%s\n' "$path"
}

Advanced error handling
#

Using traps for cleanup
#

#!/bin/bash
set -euo pipefail

# Cleanup function
cleanup() {
    echo "Cleaning up temporary files..."
    rm -f /tmp/script_temp_*
}

# Set trap to run cleanup on exit
trap cleanup EXIT INT TERM

# Your script logic here
main() {
    temp_file=$(mktemp /tmp/script_temp_XXXXXX)
    echo "Working with $temp_file"
    # Script continues...
}

main "$@"

Debug mode
#

#!/bin/bash
set -euo pipefail

# Enable debug mode with -x flag
[[ "${DEBUG:-}" == "1" ]] && set -x

debug_log() {
    [[ "${DEBUG:-}" == "1" ]] && echo "DEBUG: $*" >&2
}

debug_log "Starting script with args: $*"

Bash networking: the power of /dev/tcp
#

Bash has built-in networking via /dev/tcp and /dev/udp. These aren’t actually devices — they’re shell-level pseudo-paths that open a TCP or UDP connection when you redirect to them. The reason to know about them: when you’re on a stripped-down box without nc, nmap, or curl, you can still talk to network services.

Basic banner grabbing
#

You can grab a service banner without any external tools:

#!/bin/bash
set -euo pipefail

host="127.0.0.1"
port="22"

# Open a file descriptor (3) for reading and writing
exec 3<>/dev/tcp/"$host"/"$port"

# Read the banner
banner=""
IFS= read -r -t 2 banner <&3 || true
echo "Banner for $host:$port: $banner"

# Close the file descriptor
exec 3>&-

Simple port scanner
#

Using /dev/tcp, we can build a lightweight port scanner that is extremely difficult for defenders to block via traditional “process-based” detection, as it’s just the shell itself talking to the network.

#!/bin/bash
set -euo pipefail

target="${1:-127.0.0.1}"

echo "Scanning $target..."

for port in {1..1024}; do
    # Try to open a connection.
    # WARNING: Without a timeout, some networks can cause this to hang.
    # On macOS, GNU timeout is usually available as `gtimeout` via `brew install coreutils`.
    if command -v timeout >/dev/null 2>&1; then
        timeout 1 bash -c 'echo >/dev/tcp/"$1"/"$2"' _ "$target" "$port" >/dev/null 2>&1 && echo "Port $port is open"
    elif command -v gtimeout >/dev/null 2>&1; then
        gtimeout 1 bash -c 'echo >/dev/tcp/"$1"/"$2"' _ "$target" "$port" >/dev/null 2>&1 && echo "Port $port is open"
    else
        (echo >/dev/tcp/"$target"/"$port") >/dev/null 2>&1 && echo "Port $port is open"
    fi
done

Using Bash for pen testing and red teaming
#

Same Bash, applied to pen-testing tasks. Authorization first — every example below assumes you have a signed engagement letter or written approval. Without one, the code is academic at best and a felony at worst.

Ethical pen testing with Bash
#

Note: All examples in this section assume you have proper authorization for testing.

Authorization checks
#

#!/bin/bash
# Professional penetration testing script template

set -euo pipefail

# Color codes for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color

# Check for authorization file
check_authorization() {
    local auth_file="./AUTHORIZATION.txt"
    if [[ ! -f "$auth_file" ]]; then
        printf '%b\n' "${RED}Authorization file missing${NC}" >&2
        echo "Create AUTHORIZATION.txt with client approval before proceeding" >&2
        exit 1
    fi
    printf '%b\n' "${GREEN}Authorization verified${NC}"
}

# Network discovery with rate limiting
safe_network_scan() {
    local target="$1"
    local delay="${2:-1}"  # Default 1 second delay

    printf '%b\n' "${YELLOW}Scanning $target with $delay second delay${NC}"

    # Rate-limited port scan
    for port in 22 80 443 3389; do
        if command -v timeout >/dev/null 2>&1; then
            timeout 3 bash -c 'echo >/dev/tcp/"$1"/"$2"' _ "$target" "$port" >/dev/null 2>&1 && \
                printf '%b\n' "${GREEN}Port $port is open${NC}"
        elif command -v gtimeout >/dev/null 2>&1; then
            gtimeout 3 bash -c 'echo >/dev/tcp/"$1"/"$2"' _ "$target" "$port" >/dev/null 2>&1 && \
                printf '%b\n' "${GREEN}Port $port is open${NC}"
        else
            # Best-effort fallback without timeout (may hang on filtered networks)
            (echo >/dev/tcp/"$target"/"$port") >/dev/null 2>&1 && printf '%b\n' "${GREEN}Port $port is open${NC}"
        fi
        sleep "$delay"  # Respectful scanning
    done
}

# Usage
check_authorization
safe_network_scan "192.168.1.1" 2

Safe file operations
#

A script that wraps nmap with input validation:

#!/bin/bash
set -euo pipefail

# Validate target input
target="${1:-}"
if [[ -z "$target" ]]; then
    echo "Usage: $0 <target-ip>" >&2
    exit 1
fi
if [[ ! "$target" =~ ^[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}$ ]]; then
    echo "Invalid IP address format" >&2
    exit 1
fi

# Additional validation for valid IP ranges
IFS='.' read -r -a octets <<< "$target"
for octet in "${octets[@]}"; do
    if ((octet < 0 || octet > 255)); then
        echo "Invalid IP address: octet $octet out of range" >&2
        exit 1
    fi
done

# Scan for open ports on the target IP address
nmap -- "$target"

You could then run this script with the target IP address as a parameter:

./scan.sh 192.168.0.1

Searching the filesystem for interesting files:

#!/bin/bash
set -euo pipefail

# Search for a file called "passwords.txt"
find / -name "passwords.txt" 2>/dev/null

You could then run this script on the target system to search for the file:

./search.sh

Password testing example
#

Here’s an example that demonstrates password validation concepts:

#!/bin/bash
# Password testing example - ensure you have authorization

set -euo pipefail

# Simple authorization check
read -p "Are you authorized to test on this system? (yes/no): " auth
if [[ "$auth" != "yes" ]]; then
    echo "Authorization required. Exiting."
    exit 1
fi

# Demonstrate password validation
validate_password() {
    local password="$1"
    echo "Testing password: $password"
    # In a real scenario, you would attempt sudo here
}

echo "Password Testing Example"

# Read in a list of passwords from a file
if [[ -f "passwords.txt" ]]; then
    while IFS= read -r password; do
        validate_password "$password"
    done < "passwords.txt"
else
    echo "No password file found."
fi

Putting it together
#

A larger example combining the patterns above — port scan, parse the output, and surface vulnerability-assessment hints per service:

#!/bin/bash
set -euo pipefail

# Take the target IP address as an argument
target="${1:-}"
if [[ -z "$target" ]]; then
    echo "Usage: $0 <target-ip>" >&2
    exit 1
fi

# Validate target input
if [[ ! "$target" =~ ^[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}$ ]]; then
    echo "Invalid IP address format" >&2
    exit 1
fi

# Scan for open ports using nmap and save the output to a file
nmap -oN scan.txt "$target"

# Loop through the open ports and check for potential vulnerabilities
while IFS= read -r line; do
    # Extract the port number from the nmap output
    port=$(echo "$line" | cut -d '/' -f 1)

    # If the port is open, suggest vulnerability assessment
    if echo "$line" | grep -q 'open'; then
        echo "Port $port is open - consider vulnerability assessment:"
        echo "  - searchsploit -p $port"
        echo "  - nmap --script=vuln -p $port $target"

        # Suggest service-specific checks
        case "$port" in
            21) echo "  - Check for anonymous FTP access" ;;
            22) echo "  - Check for weak SSH configurations" ;;
            80|443) echo "  - Check for web application vulnerabilities" ;;
            3389) echo "  - Check for RDP security settings" ;;
            *) echo "  - Research common vulnerabilities for port $port" ;;
        esac
    fi
done < <(grep -E '^[0-9]+/' scan.txt)

# Clean up the scan output file
rm -f -- scan.txt

Let’s break down what’s happening in this script:

  • The script takes the target IP address as an argument.
  • The script validates the input to ensure it’s a proper IP address format and valid octet ranges.
  • The script uses nmap to scan the target for open ports and saves the output to a file.
  • The script loops through the open ports found by nmap.
  • The script extracts the port number from the nmap output for each open port.
  • If the port is open, the script suggests appropriate vulnerability assessment tools and techniques.
  • The script provides service-specific recommendations based on common port numbers.
  • The script cleans up the scan output file.

The pattern — validate input, drive a tool, parse its output, suggest follow-ups — is what most operational Bash scripts look like in practice. Keep them small enough to read in one screen and you’ll catch your own bugs faster.

Performance considerations
#

Efficient loops
#

#!/bin/bash
set -euo pipefail

# BAD - Command substitution in loop
for file in $(find . -name "*.txt"); do
    echo "$file"
done

# GOOD - Process substitution
while IFS= read -r file; do
    echo "$file"
done < <(find . -name "*.txt")

Avoiding subshells
#

#!/bin/bash
set -euo pipefail

# BAD - Creates subshell
result=$(some_command)

# GOOD - Use process substitution when possible
while IFS= read -r line; do
    process_line "$line"
done < <(some_command)

Conclusion
#

Bash isn’t going to win against Python for serious tooling, but it’s everywhere — every Linux box, every container, every restricted shell you’ll find yourself dropped into. The reason to learn it well isn’t elegance, it’s universality. If you can write decent Bash, you can move when the box doesn’t have anything else.

Two pieces of advice that don’t fit elsewhere: run shellcheck on everything, and start every script with set -euo pipefail. Most of the painful Bash bugs come from unset variables, silent pipe failures, and unquoted expansions. The defaults won’t catch any of them; the strict-mode line catches most.

Beyond that, write small scripts, quote everything, and when something starts to need real data structures or proper error handling, switch to Python.

References
#

Official documentation
#

Security best practices
#

Tools and validators
#

Penetration testing ethics
#

UncleSp1d3r
Author
UncleSp1d3r
As a computer security professional, I’m passionate about building secure systems and exploring new technologies to enhance threat detection and response capabilities. My experience with Rails development has enabled me to create efficient and scalable web applications. At the same time, my passion for learning Rust has allowed me to develop more secure and high-performance software. I’m also interested in Nim and love creating custom security tools.