Bash scripts are often treated as disposable utilities, but when they handle sensitive information or execute with elevated privileges, they become a significant security risk. The core problem is that Bash’s flexible, interpreted nature makes it easy to accidentally introduce vulnerabilities like command injection or insecure variable handling if you’re not hyper-vigilant.

Let’s see a simple, yet dangerous, script in action. Imagine a script designed to download a file based on a user-provided URL:

#!/bin/bash

URL="$1"
FILENAME=$(basename "$URL")

echo "Downloading from: $URL"
curl -O "$URL"
echo "Downloaded to: $FILENAME"

This looks innocent enough. If you run it like ./download.sh http://example.com/myfile.txt, it works as expected. But what if an attacker provides a malicious URL?

./download.sh "http://example.com/myfile.txt; rm -rf /"

The curl -O command might fail or behave unexpectedly, but the crucial part is that the ; rm -rf / is also executed by Bash as a separate command. The script, in its attempt to be helpful by downloading a file, has just become a vector for arbitrary command execution.

The "Quoting is Everything" Fallacy (and Why It’s Not Enough)

Many developers think they’re safe if they quote their variables, like "$URL". This is a good start, but it’s not a silver bullet. Quoting prevents word splitting and globbing for that specific variable expansion. It stops http://example.com/file with spaces.txt from becoming four separate arguments to curl.

However, quoting doesn’t prevent the commands embedded within the variable’s value from being executed if they are placed after shell metacharacters. The attacker’s input http://example.com/myfile.txt; rm -rf / is still treated as a single string by "$URL", but the semicolon is a command separator. When Bash parses the line curl -O "$URL", it first expands "$URL" to its literal value, then passes that entire string to curl. If curl itself were to execute shell commands (which it doesn’t directly do in this mode, but other commands might), or if the script itself were more complex and chained commands, the vulnerability would be exposed. The danger here is more subtle: the script itself is the vulnerable component.

Common Pitfalls and How to Dodge Them

  1. Arbitrary Command Injection via Unsanitized Input:

    • Diagnosis: Any script that takes user input (from $1, $@, environment variables, read, or files) and uses it directly in commands or eval is suspect.
    • Cause: Metacharacters (|, ;, &, (, ), <, >, $(), `) in user-controlled input being interpreted by the shell.
    • Fix:
      • Sanitize: Use printf %q "$variable" to quote the variable so its value is treated as a single, literal argument. This is the most robust solution.
      • Example: Replace curl -O "$URL" with curl -O $(printf %q "$URL"). This will escape any special characters in $URL so they are treated as literal parts of the filename or URL, not shell metacharacters.
      • Why it works: printf %q outputs the argument in a format that can be safely re-input into the shell. For example, if $URL was http://example.com/file;ls, printf %q "$URL" would output http://example.com/file\;ls. The backslash escapes the semicolon, preventing it from being interpreted as a command separator.
    • Diagnosis: If you’re using eval, you’re almost certainly doing something wrong.
    • Cause: eval re-parses and executes its argument.
    • Fix: Avoid eval entirely. If you need to construct commands dynamically, use arrays.
    • Example: Instead of eval "my_command --option $value" use my_command --option "${value}". If you need to build a command with many parts, use an array: cmd_parts=("my_command" "--option" "$value") and then "${cmd_parts[@]}".
    • Why it works: Arrays store arguments as distinct elements. When expanded with "${array[@]}", each element becomes a separate, properly quoted argument to the command, preventing any shell interpretation of spaces or metacharacters within the elements.
  2. Insecure Temporary File Creation:

    • Diagnosis: Scripts creating temporary files using mktemp without the correct options, or using fixed filenames like /tmp/my_script_temp.txt.
    • Cause: Predictable temporary file names can be race-condition exploited by attackers to overwrite sensitive files or inject data.
    • Fix: Always use mktemp with a unique template and secure permissions.
    • Example: TMPFILE=$(mktemp /tmp/my_app.XXXXXX) or even better, TMPFILE=$(mktemp) which uses $TMPDIR or /tmp and guarantees a unique name. Ensure you rm -f "$TMPFILE" at the end, ideally in a trap.
    • Why it works: mktemp creates a file with a unique, random name, making it impossible for an attacker to predict its name and exploit it. The XXXXXX placeholder is replaced by random characters.
  3. Unsafe Path Handling:

    • Diagnosis: Scripts that rely on $PATH to find executables without specifying the full path.
    • Cause: An attacker can manipulate $PATH or place a malicious executable earlier in the $PATH (e.g., in ./ if the current directory is in $PATH) to trick the script into running their code.
    • Fix: Always use absolute paths for critical executables or explicitly set PATH to a minimal, trusted set of directories.
    • Example: Instead of command arg, use /usr/bin/command arg or command --option arg where command is known to be in a trusted directory that is prepended to the PATH early in the script, e.g., PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin.
    • Why it works: By specifying the absolute path, you guarantee that the script executes the intended binary, bypassing any potentially malicious executables that might be found via a compromised $PATH.
  4. Sensitive Data in Environment Variables:

    • Diagnosis: Scripts that read secrets (passwords, API keys) directly from environment variables without careful consideration.
    • Cause: Environment variables can be inspected by other processes running as the same user, or leaked through logs, process listings (ps), or network traffic.
    • Fix: Avoid storing secrets in environment variables if possible. If you must, ensure they are only set for the specific script’s execution and are cleared afterward. Use dedicated secret management tools.
    • Example: Instead of export MY_SECRET="supersecret", consider reading from a file with restricted permissions (chmod 600 secrets.txt) or using a tool like pass or Vault. If using read, use read -s to prevent echoing to the terminal.
    • Why it works: Explicitly reading from secure sources or using tools designed for secret management minimizes the exposure surface area of sensitive credentials.
  5. Insecure File Permissions:

    • Diagnosis: Scripts that create or modify files without explicitly setting restrictive permissions.
    • Cause: Files created with default permissions (e.g., 664 or 644) might be readable or writable by unintended users.
    • Fix: Use umask 077 at the start of your script if you need to create files that only the owner can access, or explicitly set permissions with chmod.
    • Example: umask 077 at the beginning of the script ensures that any files created subsequently will have rw-r----- or rw------- permissions, depending on the application’s default.
    • Why it works: umask defines the mask of permissions to be removed from the default permissions when a new file or directory is created. 077 removes all permissions for group and others, leaving only owner read/write access.
  6. Ignoring Command Exit Codes:

    • Diagnosis: Scripts that run commands and don’t check $? or use set -e.
    • Cause: If a critical command fails (e.g., cp fails because the disk is full), the script continues as if everything is fine, potentially leading to data corruption or security issues down the line.
    • Fix: Use set -e at the beginning of your script. This causes the script to exit immediately if any command fails (returns a non-zero exit status).
    • Example: Add set -e as the very first line after the shebang.
    • Why it works: set -e enforces that every command must succeed. If a command fails, the script halts immediately, preventing the propagation of errors and potential security risks that could arise from continuing execution with bad state.

When you nail these down, the next thing you’ll likely encounter is understanding how to handle signals gracefully, especially SIGTERM and SIGINT, to ensure cleanup operations always run.

Want structured learning?

Take the full Bash course →