Introduction to Bash Scripting

Bourne Again Shell (Bash)

Bash is the shell and scripting language we use to interact with Unix-like systems and issue commands to the operating system. Since May 2019, Windows ships the Windows Subsystem for Linux (WSL), which lets you run Bash on Windows. Learning Bash is essential for working quickly and effectively—unlike compiled programming languages, scripts run directly through an interpreter without a separate compilation step.

As penetration testers we must be comfortable on any platform, whether Windows or Unix-based. Much of our effectiveness—especially when performing privilege escalation or large-scale reconnaissance—comes down to how well we know the OS and its command-line tools. On Unix systems, you’ll frequently need to manipulate, filter, and aggregate large volumes of data quickly to find useful leads and potential weaknesses.

A key skill is composing small tools and piping their outputs together so you can process intermediate results. That’s exactly what scripting enables: automating repetitive tasks and chaining commands to save time and reduce manual error. Conceptually, a scripting language resembles a programming language and typically includes these building blocks:

  • Input & output
  • Arguments, variables & arrays
  • Conditional execution
  • Arithmetic
  • Loops
  • Comparison operators
  • Functions

Scripts are especially useful for automating repetitive workflows or processing large datasets. A script itself is not a running process; it’s executed by an interpreter (Bash, in our case). To run a script you invoke the interpreter and point it at the script file, or run it directly if it’s executable. Examples:

# run with bash
suricatoti@local[/local]$ bash script.sh <optional-arguments>

# run with sh
suricatoti@local[/local]$ sh script.sh <optional-arguments>

# execute directly (requires executable bit)
suricatoti@local[/local]$ ./script.sh <optional-arguments>

Below is an example workflow: create a small script that accepts a domain as an argument and outputs gathered information about it. Running the script with a domain will demonstrate the kind of results you can automate and reuse.

Conditional Execution in Bash

Conditional execution lets us control the logic and flow of our script. Without it, a script would only run one command after another without any decision-making. With conditionals, we can execute different blocks of code depending on values, inputs, or states.

When a condition is met, the related code block runs, while others are skipped. After that block finishes, the script continues with the commands that follow outside of the conditional.

Example: Checking Arguments

#!/bin/bash

# Check for given argument
if [ $# -eq 0 ]
then
    echo -e "You need to specify the target domain.\n"
    echo -e "Usage:"
    echo -e "\t$0 <domain>"
    exit 1
else
    domain=$1
fi

Key Components

  • #!/bin/bash – The shebang, always at the top, defines which interpreter will run the script.
  • if … else … fi – The syntax for conditional execution in Bash.
  • echo – Prints output to the terminal.
  • Special variables:
    • $# – Number of arguments passed to the script.
    • $0 – The script’s name.
    • $1 – The first argument.
  • domain – A variable that stores the user’s input.

Here the script checks if an argument was given:

  • If no arguments ($# -eq 0), it prints an error message and exits.
  • Otherwise, it assigns the first argument to the domain variable and continues.

Defining Conditions

Conditions can be based on:

  • Variables ($#, $0, $1, domain)
  • Values (e.g., 0)
  • Strings

These are compared using comparison operators (like -eq), which we’ll explore in the next section.

Shebang

The shebang (#!) tells the system which interpreter to use. It’s always at the very start of the script. Common examples:

#!/bin/bash      # Use Bash
#!/usr/bin/env python   # Use Python
#!/usr/bin/env perl     # Use Perl

This flexibility means you can write scripts in different languages and make them directly executable, as long as the right interpreter is available.

Create an “If-Else” condition in the “For”-Loop of the “Exercise Script” that prints you the number of characters of the 35th generated value of the variable “var”. Submit the number as the answer.

For this exercise, I made the script bellow:

#!/bin/bash
# Count number of characters in a variable:
#     echo $variable | wc -c

# Variable to encode
var="nef892na9s1p9asn2aJs71nIsm"

for counter in {1..35}
do
        var=$(echo $var | base64)
done
echo "$var" | wc -c

Save it as script.sh. Give the permission to execute it.

chmod +x script.sh

Execute the script and get the value

[REDACTED]

Arguments, Variables & Arrays — Arguments

One big convenience of Bash scripts is how easily you can pass arguments to them. When you run a script, the shell automatically populates positional parameters that you can reference inside the script.

  • $0 always holds the script name (or the path used to invoke it).
  • $1, $2, …$9 are the first nine positional arguments.
  • You can access arguments beyond $9 too — use braces like ${10}, ${11}, etc.
  • $# gives the number of arguments passed.

Example invocation:

$ ./script.sh ARG1 ARG2 ARG3 ... ARGn

Inside the script those map like:

  • $0./script.sh
  • $1ARG1
  • $2ARG2
  • ${10}ARG10 (use braces for double-digit positions)

These positional parameters are examples of special variables — placeholders the shell provides so you don’t have to manually assign every argument to a named variable.

Here’s a typical check you’ll see in many scripts (and one we used earlier):

#!/bin/bash

# Ensure an argument was provided
if [ $# -eq 0 ]; then
  echo -e "You need to specify the target domain.\n"
  echo -e "Usage:\n\t$0 <domain>"
  exit 1
else
  domain=$1
fi

This snippet does two things:

  1. Uses $# to test whether any arguments were passed.
  2. If at least one argument exists, it assigns the first one to a named variable (domain=$1) for clearer use later in the script.

Note: before you try to execute a script directly (e.g. ./script.sh), make sure it’s executable:

chmod +x script.sh
./script.sh arg1 arg2

Submit the echo statement that would print “www2.inlanefreight.com” when running the last “Arrays.sh” script.

In the last Arrays.sh script, the array was declared as:

domains=("www.inlanefreight.com ftp.inlanefreight.com vpn.inlanefreight.com" www2.inlanefreight.com)

That means:

  • ${domains[0]} contains www.inlanefreight.com ftp.inlanefreight.com vpn.inlanefreight.com” (all as a single string, because it’s inside quotes).
  • ${domains[1]} contains “www2.inlanefreight.com”.

So, the echo statement that will print www2.example.com is:

[REDACTED]

Comparison Operators

Comparison operators let your script decide how two values relate to each other. In Bash we commonly group them by purpose:

  • String operators — compare text.
  • Integer (numeric) operators — compare numbers.
  • File operators — check file properties (existence, permissions, size, etc.).
  • Boolean/logical operators — combine conditions (&&, ||, !).

Below we focus on string operators and important gotchas when using them.

String operators

When comparing strings, use the operators below:

OperatorMeaning
==equal to
!=not equal to
<less than (ASCII order)
>greater than (ASCII order)
-zstring is empty (zero length)
-nstring is not empty

Important: always quote variables in tests — for example "$1" — so values containing spaces or empty strings are handled safely. If you don’t quote, you may get syntax errors or unexpected behavior.

Example: string checks

#!/bin/bash

# Check the given argument
if [ "$1" != "HackTheBox" ]; then
    echo "You need to give 'HackTheBox' as argument."
    exit 1

elif [ $# -gt 1 ]; then
    echo "Too many arguments given."
    exit 1

else
    domain=$1
    echo "Success!"
fi

Note on < and >

The < and > string comparisons behave according to ASCII ordering and are only safe inside the double-bracket test form:

if [[ "$a" < "$b" ]]; then
  echo "$a comes before $b in ASCII order"
fi

Using </> inside single brackets ([ ... ]) can be interpreted by the shell as redirection unless properly escaped, so prefer [[ ... ]] when doing ASCII comparisons.

Checking ASCII order

If you want to inspect ASCII values (to understand ordering), you can consult the ASCII reference on your system:

man ascii

Create an “If-Else” condition in the “For”-Loop that checks if the variable named “var” contains the contents of the variable named “value”. Additionally, the variable “var” must contain more than 113,450 characters. If these conditions are met, the script must then print the last 20 characters of the variable “var”. Submit these last 20 characters as the answer.

For this exercise, I made the follow script:

#!/bin/bash
var="8dm7KsjU28B7v621Jls"
value="ERmFRMVZ0U2paTlJYTkxDZz09Cg"

for i in {1..40}
do
    var=$(echo "$var" | base64)
    if [[ $var == *"$value"* && ${#var} -gt 113450 ]]; then
        echo "${var: -20}"
        exit 0
    fi
done

Save the file and give permission to execute it.

chmod +x script.sh
./script.sh
[REDACTED]

Arithmetic

Bash provides several arithmetic operators that let you perform basic math and manipulate integers directly within your scripts. These are extremely useful for counters, loops, and handling numeric data in penetration testing or system automation tasks.

Available Operators

OperatorDescription
+Addition
-Subtraction
*Multiplication
/Division
%Modulus (remainder after division)
variable++Increments the variable’s value by 1
variable--Decrements the variable’s value by 1

Example Script

Arithmetic.sh

#!/bin/bash

increase=1
decrease=1

echo "Addition: 10 + 10 = $((10 + 10))"
echo "Subtraction: 10 - 10 = $((10 - 10))"
echo "Multiplication: 10 * 10 = $((10 * 10))"
echo "Division: 10 / 10 = $((10 / 10))"
echo "Modulus: 10 % 4 = $((10 % 4))"

((increase++))
echo "Increase Variable: $increase"

((decrease--))
echo "Decrease Variable: $decrease"

Execution Output

suricatoti@local[/local]$ ./Arithmetic.sh

Addition: 10 + 10 = 20
Subtraction: 10 - 10 = 0
Multiplication: 10 * 10 = 100
Division: 10 / 10 = 1
Modulus: 10 % 4 = 2
Increase Variable: 2
Decrease Variable: 0

Measuring String Length

You can also calculate the length of a string (i.e., number of characters) using ${#variable}.

VarLength.sh

#!/bin/bash

htd="Hack The Dome"

echo ${#htd}

Execution:

suricatoti@local[/local]$ ./VarLength.sh
10

Practical Usage in Scripts

In larger scripts (for example, a CIDR.sh script that pings hosts in a subnet), increment (++) and decrement (--) operators are used frequently inside loops. They control variables such as counters and status flags, ensuring that loops terminate correctly once conditions are met.

For instance:

  • When a host responds to a ping (exit code 0), a variable like hosts_up might be incremented.
  • A stat variable may be toggled with increment/decrement to determine whether to keep looping.

Input and Output

Input control

When a script runs commands or sends requests, it often produces results that require your judgment before proceeding. For example, your script might define several functions for different tasks — you need a way to choose which function to execute after inspecting the output. Some scans or operations may also be off-limits depending on rules of engagement, so it’s useful to pause a script and wait for user input before continuing.

In our CIDR.sh example we present a simple menu and wait for the user to pick an option:

# Available options
<SNIP>
echo -e "Additional options available:"
echo -e "\t1) Identify the corresponding network range of target domain."
echo -e "\t2) Ping discovered hosts."
echo -e "\t3) All checks."
echo -e "\t*) Exit.\n"

read -p "Select your option: " opt

case $opt in
    "1") network_range ;;
    "2") ping_host ;;
    "3") network_range && ping_host ;;
    "*") exit 0 ;;
esac

The echo lines display the menu. read -p shows the prompt on the same line and stores the user’s choice in the variable opt. The case statement then dispatches to the appropriate function based on that value (e.g., network_range, ping_host).

Output control

Earlier modules covered basic redirection (e.g., >, 2>) but redirecting output to a file means you don’t see it on the terminal. For long-running or complex scripts, you usually want to both view output in real time and save it for later. The tee utility does exactly that: it duplicates a stream to stdout and to a file.

Here are two uses from CIDR.sh:

# Inside a function that finds network ranges
netrange=$(whois $ip | grep "NetRange\|CIDR" | tee -a CIDR.txt)
cidr=$(whois $ip | grep "CIDR" | awk '{print $2}')
cidr_ips=$(prips $cidr)
echo -e "\nNetRange for $ip:"
echo -e "$netrange"

# Save discovered hosts while still printing them
hosts=$(host $domain | grep "has address" | cut -d" " -f4 | tee discovered_hosts.txt)

By piping output into tee, you get immediate terminal feedback while the same output is appended to a file when using -a (or overwritten without -a). This pattern is great for logging results as they appear, so you don’t need to wait until the script finishes to inspect findings.

Example of collected output:

$ cat discovered_hosts.txt CIDR.txt

165.22.119.202
NetRange:       165.22.0.0 - 165.22.255.255
CIDR:           165.22.0.0/16

Flow Control — Loops

Controlling the flow of a script is crucial for writing efficient, reliable automation. We’ve already seen branching with if/else — branches decide between alternative code paths. Loops, on the other hand, let you repeat actions until a condition changes. Together, branches and loops form the core control structures that make scripts powerful.

Control structures are driven by boolean expressions and fall into two groups:

  • Branches
    • if / else
    • case statements
  • Loops
    • for loops
    • while loops
    • until loops

For loops

A for loop iterates over a list of items (or over generated values) and executes the loop body once for each item. They are ideal when you have a collection of targets — for example, IPs, filenames, or ports — and want to run the same action for each entry. In pentesting workflows, for loops are commonly used to probe multiple hosts, scan a set of ports, or run a series of enumeration commands.

Basic forms

Iterating over literal values:

for variable in 1 2 3 4
do
    echo $variable
done

Iterating over filenames:

for file in file1 file2 file3
do
    echo $file
done

Iterating over IPs (note the values are separate items):

for ip in 127.0.0.170 127.0.0.174 127.0.0.175
do
    ping -c 1 $ip
done

One-liner style

You can write the entire loop in one line for quick ad-hoc tasks:

for ip in 127.0.0.170 127.0.0.174; do ping -c 1 $ip; done

Example output when pings succeed:

PING 127.0.0.170 (127.0.0.170): 56 data bytes
64 bytes from 127.0.0.170: icmp_seq=0 ttl=63 time=42.106 ms
--- 127.0.0.170 ping statistics ---
1 packets transmitted, 1 packets received, 0.0% packet loss

PING 127.0.0.174 (127.0.0.174): 56 data bytes
64 bytes from 127.0.0.174: icmp_seq=0 ttl=63 time=45.700 ms
--- 127.0.0.174 ping statistics ---
1 packets transmitted, 1 packets received, 0.0% packet loss

When to use a for loop

  • When you already know the list of items to process (IPs, filenames, ports).
  • When you want to run the same command for each item in a set.
  • When you need simple sequential processing (no complex loop-condition logic required).

Create a “For” loop that encodes the variable “var” 28 times in “base64”. The number of characters in the 28th hash is the value that must be assigned to the “salt” variable.

Insert the FOR value in the script showed on the text.

#!/bin/bash

# Decrypt function
function decrypt {
    MzSaas7k=$(echo $hash | sed 's/988sn1/83unasa/g')
    Mzns7293sk=$(echo $MzSaas7k | sed 's/4d298d/9999/g')
    MzSaas7k=$(echo $Mzns7293sk | sed 's/3i8dqos82/873h4d/g')
    Mzns7293sk=$(echo $MzSaas7k | sed 's/4n9Ls/20X/g')
    MzSaas7k=$(echo $Mzns7293sk | sed 's/912oijs01/i7gg/g')
    Mzns7293sk=$(echo $MzSaas7k | sed 's/k32jx0aa/n391s/g')
    MzSaas7k=$(echo $Mzns7293sk | sed 's/nI72n/YzF1/g')
    Mzns7293sk=$(echo $MzSaas7k | sed 's/82ns71n/2d49/g')
    MzSaas7k=$(echo $Mzns7293sk | sed 's/JGcms1a/zIm12/g')
    Mzns7293sk=$(echo $MzSaas7k | sed 's/MS9/4SIs/g')
    MzSaas7k=$(echo $Mzns7293sk | sed 's/Ymxj00Ims/Uso18/g')
    Mzns7293sk=$(echo $MzSaas7k | sed 's/sSi8Lm/Mit/g')
    MzSaas7k=$(echo $Mzns7293sk | sed 's/9su2n/43n92ka/g')
    Mzns7293sk=$(echo $MzSaas7k | sed 's/ggf3iunds/dn3i8/g')
    MzSaas7k=$(echo $Mzns7293sk | sed 's/uBz/TT0K/g')

    flag=$(echo $MzSaas7k | base64 -d | openssl enc -aes-128-cbc -a -d -salt -pass pass:$salt)
}

# Variables
var="9M"
salt=""
hash="VTJGc2RHVmtYMTl2ZnYyNTdUeERVRnBtQWVGNmFWWVUySG1wTXNmRi9rQT0K"

# Base64 Encoding Example:
#        $ echo "Some Text" | base64

for counter in {1..28}
do
 var=$(echo $var | base64)
 if [[ $counter -eq 28 ]]
 then 
  salt=$(echo $var | wc -m)
 fi
done

# Check if $salt is empty
if [[ ! -z "$salt" ]]
then
    decrypt
    echo $flag
else
    exit 1
fi

Save the script, give permission to execute it and get the flag.

chmod +x script.sh
./script.sh
[REDACTED]

Flow Control — Branches

Branches let your script choose between different code paths. You already know if/else for testing boolean expressions; case statements provide a cleaner way to choose behavior when you need to compare a single value against multiple exact patterns.

case vs if/else

  • if/else can evaluate any boolean expression (-gt, -f, string tests, compound conditions).
  • case compares one expression against patterns (shell-style glob patterns). Use case when you want to dispatch behavior based on the value of a single variable — it’s usually more readable than a long chain of if/elif.

Syntax

case <expression> in
    pattern1 ) statements ;;
    pattern2 ) statements ;;
    pattern3 ) statements ;;
    * ) default_statements ;;
esac
  • expression is typically a quoted variable, e.g. case "$opt" in.
  • Each pattern is a shell glob (not a regex).
  • ;; ends the block for that pattern.
  • Use * as the default (catch-all) pattern.

Example (menu dispatch)

This is the same style used in your CIDR.sh example:

read -p "Select your option: " opt

case "$opt" in
    "1") network_range ;;
    "2") ping_host ;;
    "3") network_range && ping_host ;;
    "*"|q|quit) exit 0 ;;
esac

Depending on what the user types, the matching branch runs and then the case ends. Quoting the variable ("$opt") avoids word-splitting and surprises if the input contains spaces.

Pattern features & variants

  • Patterns support wildcards: "a*" matches anything starting with a; ? matches one character; character classes like [0-9] work too.
  • For multiple alternatives you can separate them with | in the same pattern: case "$cmd" in start|run) do_start ;; stop|halt) do_stop ;; esac
  • Bash also supports ;;& and ;& with different fall-through semantics (advanced uses):
    • ;;& after a block continues testing the next patterns.
    • ;& executes the following block without testing its pattern.
      Most scripts only need ;;.

When to use case

  • Menu selection or CLI subcommands.
  • Dispatching based on a single argument value.
  • Replacing long if/elif chains for clearer code.

Functions

As scripts grow larger, they can quickly become messy and repetitive. If the same block of commands is used multiple times, the script size increases unnecessarily. Functions provide a clean solution: they allow you to group commands under a name and reuse them whenever needed.

A function is simply a block of code enclosed in curly braces ({ ... }) and given a name. Once defined, you can call that name anywhere in your script, and the commands inside the function will be executed.

Functions are a fundamental part of scripting because they:

  • Prevent code duplication.
  • Make scripts shorter and more readable.
  • Organize recurring tasks into reusable blocks.
  • Simplify maintenance and updates.

Important: Since Bash scripts are executed from top to bottom, functions must be defined before their first use. This is why functions are usually placed at the beginning of a script.

Defining Functions

Bash supports two equivalent ways to define a function:

Method 1

function name {
    <commands>
}

Method 2

name() {
    <commands>
}

Both methods work the same way; which one you use is a matter of style or team convention.

Debugging Bash scripts

Debugging is the process of finding and fixing bugs in your scripts. Bash gives you simple but powerful tools to trace execution, inspect variables, and catch errors so you can understand what’s happening and why.

Here are practical techniques and short examples you can use right away.

1) Quick runtime tracing: -x and -v

  • bash -x script.sh — runs the script with xtrace, printing each command and its arguments as they are executed (very useful to see flow and expansions).
  • bash -v script.sh — prints each line before it is executed (shows the raw source).
  • Combine them: bash -xv script.sh.

Inside a script you can toggle tracing:

#!/bin/bash
echo "before trace"
set -x            # start tracing
some_command "$var"
set +x            # stop tracing
echo "after trace"

2) Better traces with context (file/line/function)

Add informative prefixes so trace lines tell you where they came from:

#!/bin/bash
export PS4='+ ${BASH_SOURCE}:${LINENO}:${FUNCNAME[0]}: '
set -x
# ... rest of script

This makes -x output show filename:line:function: command, which is invaluable for larger scripts or when functions call each other.

3) Redirecting trace output to a file

If you want to capture traces without cluttering stdout/stderr:

#!/bin/bash
exec 3>trace.log              # open FD 3 for trace output
export BASH_XTRACEFD=3        # tell bash to send xtrace to FD 3
export PS4='+ ${BASH_SOURCE}:${LINENO}: '
set -x
# commands...
set +x
exec 3>&-                     # close FD 3

Now trace.log contains the -x output.

4) Fail fast and catch errors

Use strict modes to catch mistakes early:

set -euo pipefail
# -e : exit on error
# -u : error on unset variables
# pipefail : pipeline returns failure if any stage fails

Add an ERR trap to print context when something fails:

trap 'echo "Error on or near line ${LINENO}. Command: ${BASH_COMMAND}"' ERR

Combined example:

#!/bin/bash
set -euo pipefail
trap 'echo "ERROR at line ${LINENO}: ${BASH_COMMAND}" >&2' ERR
# rest of script

5) Inspect variables and state safely

Use printf or declare -p to dump variable contents reliably:

printf 'domain=%q\n' "$domain"
declare -p array_var

For arrays:

for i in "${!arr[@]}"; do
  printf "%s -> %s\n" "$i" "${arr[i]}"
done

6) Use case, if, and small test prints for flow checks

Sprinkle short debug prints where logic branches:

[ "$DEBUG" = "1" ] && printf 'DEBUG: in function foo, var=%s\n' "$var"

Or enable debug only when needed:

if [ "${DEBUG:-0}" -eq 1 ]; then
  set -x
fi

7) Static analysis and interactive debugging tools

  • shellcheck — lint your scripts and catch many common mistakes (missing quotes, bad tests, Useless use of cat, etc.).
  • bashdb — interactive Bash debugger (step, breakpoints) if you need line-by-line stepping.

Install via your distro package manager and run shellcheck script.sh or bashdb script.sh.

8) Logging best practices

  • Send logs to a file and keep output readable: logger, tee, or custom logging function.
  • Example logging function:
log() { printf '%s %s\n' "$(date --iso-8601=seconds)" "$*"; }
log "Starting scan for $domain"

9) When tracing is noisy

Turn tracing around specific blocks rather than globally. Also prefer targeted printf or declare -p checks rather than full -x for long loops or heavy commands.