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
-
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 orevalis 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"withcurl -O $(printf %q "$URL"). This will escape any special characters in$URLso they are treated as literal parts of the filename or URL, not shell metacharacters. - Why it works:
printf %qoutputs the argument in a format that can be safely re-input into the shell. For example, if$URLwashttp://example.com/file;ls,printf %q "$URL"would outputhttp://example.com/file\;ls. The backslash escapes the semicolon, preventing it from being interpreted as a command separator.
- Sanitize: Use
- Diagnosis: If you’re using
eval, you’re almost certainly doing something wrong. - Cause:
evalre-parses and executes its argument. - Fix: Avoid
evalentirely. If you need to construct commands dynamically, use arrays. - Example: Instead of
eval "my_command --option $value"usemy_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.
- Diagnosis: Any script that takes user input (from
-
Insecure Temporary File Creation:
- Diagnosis: Scripts creating temporary files using
mktempwithout 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
mktempwith a unique template and secure permissions. - Example:
TMPFILE=$(mktemp /tmp/my_app.XXXXXX)or even better,TMPFILE=$(mktemp)which uses$TMPDIRor/tmpand guarantees a unique name. Ensure yourm -f "$TMPFILE"at the end, ideally in atrap. - Why it works:
mktempcreates a file with a unique, random name, making it impossible for an attacker to predict its name and exploit it. TheXXXXXXplaceholder is replaced by random characters.
- Diagnosis: Scripts creating temporary files using
-
Unsafe Path Handling:
- Diagnosis: Scripts that rely on
$PATHto find executables without specifying the full path. - Cause: An attacker can manipulate
$PATHor 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
PATHto a minimal, trusted set of directories. - Example: Instead of
command arg, use/usr/bin/command argorcommand --option argwherecommandis known to be in a trusted directory that is prepended to thePATHearly 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.
- Diagnosis: Scripts that rely on
-
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 likepassor Vault. If usingread, useread -sto 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.
-
Insecure File Permissions:
- Diagnosis: Scripts that create or modify files without explicitly setting restrictive permissions.
- Cause: Files created with default permissions (e.g.,
664or644) might be readable or writable by unintended users. - Fix: Use
umask 077at the start of your script if you need to create files that only the owner can access, or explicitly set permissions withchmod. - Example:
umask 077at the beginning of the script ensures that any files created subsequently will haverw-r-----orrw-------permissions, depending on the application’s default. - Why it works:
umaskdefines the mask of permissions to be removed from the default permissions when a new file or directory is created.077removes all permissions for group and others, leaving only owner read/write access.
-
Ignoring Command Exit Codes:
- Diagnosis: Scripts that run commands and don’t check
$?or useset -e. - Cause: If a critical command fails (e.g.,
cpfails 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 -eat 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 -eas the very first line after the shebang. - Why it works:
set -eenforces 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.
- Diagnosis: Scripts that run commands and don’t check
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.