脚本安全与最佳实践
安全性是编写 Shell 脚本时必须重视的方面。Shell 脚本常用于系统管理、自动化部署等敏感操作,安全漏洞可能导致严重后果。本章介绍 Shell 脚本的安全风险和最佳实践。
常见安全风险
命令注入
命令注入是 Shell 脚本最常见的安全漏洞,攻击者可以通过构造恶意输入执行任意命令。
危险的代码
#!/bin/bash
# 危险:用户输入直接拼接到命令中
filename="$1"
rm "$filename" # 仍然危险
rm $filename # 非常危险
eval "rm $filename" # 极其危险
攻击示例
# 正常使用
./script.sh "file.txt"
# 攻击:删除所有文件
./script.sh "*"
# 攻击:执行任意命令
./script.sh "file.txt; rm -rf /"
# 攻击:通过反引号执行命令
./script.sh "$(cat /etc/passwd)"
安全的代码
#!/bin/bash
filename="$1"
# 验证输入:只允许安全的字符
if [[ ! "$filename" =~ ^[a-zA-Z0-9_./-]+$ ]]; then
echo "错误: 文件名包含非法字符" >&2
exit 1
fi
# 检查文件是否存在
if [[ ! -f "$filename" ]]; then
echo "错误: 文件不存在: $filename" >&2
exit 1
fi
# 使用双引号保护变量
rm "$filename"
变量未加引号
未加引号的变量会进行词分割和通配符扩展,导致意外行为。
#!/bin/bash
# 危险:未加引号
filename="my document.txt"
rm $filename # 被分割为 rm my document.txt
# 安全:加双引号
rm "$filename" # 正确处理带空格的文件名
安全规则:除非有特殊原因,否则总是用双引号包围变量。
eval 的危险
eval 命令会执行其参数作为 Shell 命令,极易导致安全问题。
#!/bin/bash
# 危险:eval 执行任意命令
user_input="$1"
eval "echo $user_input"
# 攻击示例
./script.sh "$(rm -rf /)"
替代方案
#!/bin/bash
# 使用变量展开而不是 eval
value="${user_input}"
echo "$value"
# 如果必须使用动态变量名,使用间接引用
var_name="config"
echo "${!var_name}"
不安全的临时文件
#!/bin/bash
# 危险:使用固定名称的临时文件
temp_file="/tmp/myapp.tmp"
echo "data" > "$temp_file"
# 攻击:攻击者可以预先创建符号链接
ln -s /etc/passwd /tmp/myapp.tmp
# 运行脚本后,/etc/passwd 会被覆盖
安全的临时文件
#!/bin/bash
# 安全:使用 mktemp 创建唯一临时文件
temp_file=$(mktemp)
echo "data" > "$temp_file"
# 或者使用带模板的 mktemp
temp_file=$(mktemp /tmp/myapp.XXXXXX)
# 或者使用特定目录
temp_file=$(mktemp -t myapp.XXXXXX)
# 确保清理
trap 'rm -f "$temp_file"' EXIT
输入验证
验证原则
- 白名单优于黑名单:只允许已知安全的输入
- 尽早验证:在使用输入之前验证
- 严格限制:限制输入长度、字符集、格式
字符集验证
#!/bin/bash
# 只允许字母、数字、下划线
validate_alnum() {
local input="$1"
if [[ ! "$input" =~ ^[a-zA-Z0-9_]+$ ]]; then
echo "错误: 输入包含非法字符" >&2
return 1
fi
return 0
}
# 只允许字母数字和基本路径字符
validate_path() {
local input="$1"
if [[ ! "$input" =~ ^[a-zA-Z0-9_./-]+$ ]]; then
echo "错误: 路径包含非法字符" >&2
return 1
fi
return 0
}
# 只允许数字
validate_number() {
local input="$1"
if [[ ! "$input" =~ ^[0-9]+$ ]]; then
echo "错误: 必须是数字" >&2
return 1
fi
return 0
}
格式验证
#!/bin/bash
# 验证邮箱格式
validate_email() {
local email="$1"
if [[ ! "$email" =~ ^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$ ]]; then
echo "错误: 无效的邮箱格式" >&2
return 1
fi
return 0
}
# 验证 IP 地址
validate_ip() {
local ip="$1"
local regex="^([0-9]{1,3}\.){3}[0-9]{1,3}$"
if [[ ! "$ip" =~ $regex ]]; then
echo "错误: 无效的 IP 格式" >&2
return 1
fi
# 验证每个部分是否在 0-255 范围内
local IFS='.'
read -ra octets <<< "$ip"
for octet in "${octets[@]}"; do
if ((octet < 0 || octet > 255)); then
echo "错误: IP 地址部分超出范围" >&2
return 1
fi
done
return 0
}
# 验证 URL
validate_url() {
local url="$1"
if [[ ! "$url" =~ ^https?://[a-zA-Z0-9.-]+ ]]; then
echo "错误: 无效的 URL 格式" >&2
return 1
fi
return 0
}
长度限制
#!/bin/bash
validate_length() {
local input="$1"
local min_len="${2:-1}"
local max_len="${3:-100}"
local len=${#input}
if ((len < min_len)); then
echo "错误: 输入太短,最少需要 $min_len 个字符" >&2
return 1
fi
if ((len > max_len)); then
echo "错误: 输入太长,最多允许 $max_len 个字符" >&2
return 1
fi
return 0
}
文件路径验证
#!/bin/bash
# 验证文件路径安全
validate_filepath() {
local filepath="$1"
local base_dir="${2:-/safe/directory}"
# 检查是否包含危险字符
if [[ "$filepath" =~ [\|\&\;\$\<\>\`\(\)\{\}\!\* ]]; then
echo "错误: 文件路径包含非法字符" >&2
return 1
fi
# 检查路径遍历攻击
if [[ "$filepath" =~ \.\. ]]; then
echo "错误: 不允许使用 .. 目录" >&2
return 1
fi
# 获取绝对路径
local abs_path
abs_path=$(realpath "$filepath" 2>/dev/null) || {
# 文件不存在,构造预期路径
abs_path=$(realpath "$(dirname "$filepath")")/$(basename "$filepath")
}
# 确保文件在允许的目录内
if [[ ! "$abs_path" =~ ^"$base_dir" ]]; then
echo "错误: 文件必须在 $base_dir 目录内" >&2
return 1
fi
return 0
}
文件权限
设置安全的文件权限
#!/bin/bash
# 设置文件权限
secure_file() {
local file="$1"
# 确保文件存在
touch "$file"
# 设置所有者读写,其他人无权限
chmod 600 "$file"
# 验证权限
local perms
perms=$(stat -c '%a' "$file" 2>/dev/null || stat -f '%Lp' "$file")
if [[ "$perms" != "600" ]]; then
echo "警告: 无法正确设置文件权限" >&2
fi
}
# 创建安全的临时文件
create_secure_temp() {
local temp_file
temp_file=$(mktemp)
# 设置权限
chmod 600 "$temp_file"
echo "$temp_file"
}
脚本权限
#!/bin/bash
# 检查脚本是否设置了 SUID/SGID
# 注意:SUID/SGID 对 Shell 脚本是危险的,应该避免
check_script_permissions() {
local script="$1"
# 获取权限
local perms
perms=$(stat -c '%a' "$script" 2>/dev/null || stat -f '%Lp' "$script")
# 检查 SUID(第四位为 4)
if [[ $((perms / 1000)) -ge 4 ]]; then
echo "警告: 脚本设置了 SUID,这是危险的做法" >&2
return 1
fi
return 0
}
敏感配置文件
#!/bin/bash
CONFIG_FILE="/etc/myapp/config.conf"
# 确保配置文件权限正确
setup_config() {
# 创建配置目录
mkdir -p "$(dirname "$CONFIG_FILE")"
# 创建配置文件
if [[ ! -f "$CONFIG_FILE" ]]; then
cat > "$CONFIG_FILE" << EOF
# 配置文件
DB_HOST=localhost
DB_USER=myuser
DB_PASS=mypassword
EOF
fi
# 设置安全权限
chmod 600 "$CONFIG_FILE"
chown root:root "$CONFIG_FILE"
echo "配置文件已创建: $CONFIG_FILE"
}
敏感数据处理
避免硬编码密码
#!/bin/bash
# 错误:硬编码密码
password="my_secret_password"
mysql -u root -p"$password" -e "SHOW DATABASES;"
# 正确:从环境变量读取
password="${DB_PASSWORD:-}"
if [[ -z "$password" ]]; then
echo "错误: 请设置 DB_PASSWORD 环境变量" >&2
exit 1
fi
mysql -u root -p"$password" -e "SHOW DATABASES;"
安全读取密码
#!/bin/bash
read_password() {
local prompt="${1:-请输入密码: }"
local password
# 使用 -s 选项隐藏输入
read -s -p "$prompt" password
echo # 换行
echo "$password"
}
# 使用
password=$(read_password "输入数据库密码: ")
安全传递密码
#!/bin/bash
# 错误:密码在命令行可见(ps 命令可以看到)
mysql -u root -p"password" -e "SHOW DATABASES;"
# 正确:使用环境变量
export MYSQL_PWD="${DB_PASSWORD}"
mysql -u root -e "SHOW DATABASES;"
unset MYSQL_PWD
# 或者使用配置文件
mysql --defaults-file=/etc/mysql/my.cnf -e "SHOW DATABASES;"
# 或者使用临时文件(确保权限正确)
temp_config=$(mktemp)
chmod 600 "$temp_config"
cat > "$temp_config" << EOF
[client]
user = root
password = ${DB_PASSWORD}
EOF
mysql --defaults-file="$temp_config" -e "SHOW DATABASES;"
rm -f "$temp_config"
清理敏感数据
#!/bin/bash
# 处理完敏感数据后清理
process_sensitive() {
local password="$1"
# 使用密码
echo "使用密码: ${password:0:2}***"
# 清理变量(不保证内存中彻底清除,但增加难度)
unset password
# 如果使用数组存储敏感数据
local -a secrets=("secret1" "secret2" "secret3")
# 使用后清理
secrets=()
unset secrets
}
安全编码实践
使用 set 选项
#!/bin/bash
# 启用严格的错误处理
set -e # 命令失败时退出
set -u # 使用未定义变量时报错
set -o pipefail # 管道中任何命令失败时返回失败
set -x # 打印执行的命令(调试用,生产环境移除)
# 或合并写法
set -euo pipefail
检查命令是否存在
#!/bin/bash
# 检查命令是否存在
require_command() {
local cmd="$1"
if ! command -v "$cmd" &> /dev/null; then
echo "错误: 需要安装 $cmd 命令" >&2
exit 1
fi
}
# 使用
require_command "curl"
require_command "jq"
安全使用外部命令
#!/bin/bash
# 检查命令路径
safe_exec() {
local cmd="$1"
shift
# 获取命令的完整路径
local cmd_path
cmd_path=$(command -v "$cmd")
if [[ -z "$cmd_path" ]]; then
echo "错误: 命令不存在: $cmd" >&2
return 1
fi
# 执行命令
"$cmd_path" "$@"
}
# 或者限制命令的搜索路径
PATH="/usr/bin:/bin"
export PATH
避免 shellshock
Shellshock 是 Bash 的一个严重漏洞,影响环境变量中的函数定义。
#!/bin/bash
# 检查 Bash 版本(Shellshock 在 4.3 之前的版本存在漏洞)
check_bash_version() {
local major minor patch
IFS='.' read -r major minor patch <<< "${BASH_VERSION}"
if ((major < 4 || (major == 4 && minor < 3))); then
echo "警告: Bash 版本可能存在 Shellshock 漏洞" >&2
echo "当前版本: $BASH_VERSION,建议升级到 4.3+" >&2
return 1
fi
return 0
}
# 清理危险的环境变量
clean_env() {
# 移除可能包含恶意函数的环境变量
for var in $(env | grep -E '^\(\)' | cut -d= -f1); do
unset "$var"
done
}
日志安全
#!/bin/bash
# 安全日志函数
log() {
local level="$1"
shift
local message="$*"
# 移除可能包含敏感信息的部分
message=$(echo "$message" | sed 's/password=[^ ]*/password=***/g')
message=$(echo "$message" | sed 's/token=[^ ]*/token=***/g')
# 写入日志
echo "[$(date '+%Y-%m-%d %H:%M:%S')] [$level] $message" >> "$LOG_FILE"
}
# 使用
log "INFO" "用户登录: user=admin password=secret123"
# 日志中显示: [INFO] 用户登录: user=admin password=***
完整的安全脚本模板
#!/bin/bash
#
# 安全脚本模板
# 描述: 展示安全编码最佳实践
# 作者: Your Name
# 日期: 2024-01-01
#
set -euo pipefail
# 脚本信息
readonly SCRIPT_NAME=$(basename "$0")
readonly SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
readonly VERSION="1.0.0"
# 安全配置
readonly MAX_INPUT_LENGTH=1000
readonly ALLOWED_PATH="/var/data"
# 颜色输出
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[0;33m'
NC='\033[0m'
# 日志函数
log() {
local level="$1"
shift
local message="$*"
# 清理敏感信息
message=$(echo "$message" | sed -E 's/(password|token|key|secret)=[^ ]*/\1=***/gi')
echo "[$(date '+%Y-%m-%d %H:%M:%S')] [$level] $message" >&2
}
log_info() { log "INFO" "$@"; }
log_warn() { log "WARN" "$@"; }
log_error() { log "ERROR" "$@"; }
# 错误处理
error_exit() {
log_error "$1"
exit "${2:-1}"
}
# 清理函数
cleanup() {
local exit_code=$?
# 清理临时文件
[[ -n "${TEMP_FILE:-}" ]] && rm -f "$TEMP_FILE"
# 清理敏感变量
unset API_KEY DB_PASSWORD
exit $exit_code
}
trap cleanup EXIT INT TERM
# 输入验证
validate_input() {
local input="$1"
local name="${2:-input}"
# 检查是否为空
if [[ -z "$input" ]]; then
error_exit "$name 不能为空"
fi
# 检查长度
if (( ${#input} > MAX_INPUT_LENGTH )); then
error_exit "$name 长度超过限制 ($MAX_INPUT_LENGTH)"
fi
# 检查危险字符
if [[ "$input" =~ [\;\|\&\$\`\{\}\(\)\<\>\!\~\*\?\[\]] ]]; then
error_exit "$name 包含非法字符"
fi
}
# 文件路径验证
validate_path() {
local path="$1"
# 解析为绝对路径
local abs_path
abs_path=$(realpath -m "$path" 2>/dev/null) || {
error_exit "无效的路径: $path"
}
# 检查路径遍历
if [[ "$abs_path" =~ \.\. ]]; then
error_exit "路径不允许包含 .."
fi
# 检查是否在允许的目录内
if [[ ! "$abs_path" =~ ^"$ALLOWED_PATH" ]]; then
error_exit "路径必须在 $ALLOWED_PATH 目录内"
fi
echo "$abs_path"
}
# 安全读取配置
load_config() {
local config_file="$1"
# 验证配置文件权限
local perms
perms=$(stat -c '%a' "$config_file" 2>/dev/null || stat -f '%Lp' "$config_file")
if [[ "$perms" != "600" && "$perms" != "400" ]]; then
log_warn "配置文件权限不安全: $config_file (当前: $perms,建议: 600)"
fi
# 读取配置
while IFS='=' read -r key value; do
# 跳过注释和空行
[[ "$key" =~ ^[[:space:]]*# ]] && continue
[[ -z "$key" ]] && continue
# 验证 key
if [[ ! "$key" =~ ^[A-Z_][A-Z0-9_]*$ ]]; then
log_warn "无效的配置项: $key"
continue
fi
# 设置变量
declare -g "$key"="$value"
done < "$config_file"
}
# 安全执行外部命令
safe_exec() {
local cmd="$1"
shift
# 检查命令是否存在
if ! command -v "$cmd" &> /dev/null; then
error_exit "命令不存在: $cmd"
fi
# 执行命令
"$cmd" "$@"
}
# 使用帮助
usage() {
cat << EOF
用法: $SCRIPT_NAME [选项] <文件>
选项:
-c, --config FILE 配置文件
-v, --verbose 详细输出
-h, --help 显示帮助
--version 显示版本
示例:
$SCRIPT_NAME -c /etc/myapp/config.conf data.txt
EOF
exit 0
}
# 参数解析
parse_args() {
VERBOSE=0
CONFIG_FILE=""
INPUT_FILE=""
while [[ $# -gt 0 ]]; do
case $1 in
-c|--config)
CONFIG_FILE="$2"
shift 2
;;
-v|--verbose)
VERBOSE=1
shift
;;
-h|--help)
usage
;;
--version)
echo "$SCRIPT_NAME version $VERSION"
exit 0
;;
-*)
error_exit "未知选项: $1"
;;
*)
INPUT_FILE="$1"
shift
;;
esac
done
# 验证必需参数
[[ -z "$INPUT_FILE" ]] && error_exit "必须指定输入文件"
# 验证输入文件
validate_input "$INPUT_FILE" "输入文件"
INPUT_FILE=$(validate_path "$INPUT_FILE")
}
# 主函数
main() {
parse_args "$@"
# 创建安全的临时文件
TEMP_FILE=$(mktemp)
chmod 600 "$TEMP_FILE"
log_info "开始处理: $INPUT_FILE"
# 加载配置
if [[ -n "$CONFIG_FILE" ]]; then
load_config "$CONFIG_FILE"
fi
# 主逻辑...
log_info "处理完成"
}
# 入口
main "$@"
安全检查清单
在编写 Shell 脚本时,使用以下清单检查安全性:
输入处理
- 所有用户输入都经过验证
- 使用白名单验证而不是黑名单
- 限制输入长度
- 验证文件路径,防止路径遍历
变量处理
- 所有变量都用双引号包围
- 使用
set -u捕获未定义变量 - 避免使用
eval - 使用
local声明函数局部变量
命令执行
- 检查外部命令是否存在
- 避免拼接用户输入到命令中
- 使用绝对路径执行关键命令
文件操作
- 使用
mktemp创建临时文件 - 设置适当的文件权限(如 600)
- 清理临时文件(使用 trap)
- 检查文件权限是否被篡改
敏感数据
- 不硬编码密码和密钥
- 使用环境变量存储敏感信息
- 日志中不记录敏感信息
- 使用后清理敏感变量
错误处理
- 使用
set -e在错误时退出 - 使用
set -o pipefail检查管道错误 - 提供有意义的错误信息
- 记录操作日志
小结
本章介绍了 Shell 脚本安全的关键要点:
- 常见风险:命令注入、未引用变量、危险函数、不安全临时文件
- 输入验证:白名单验证、格式验证、长度限制、路径验证
- 文件权限:设置安全权限、检查 SUID、保护配置文件
- 敏感数据:避免硬编码、安全传递密码、清理敏感信息
- 安全编码:使用 set 选项、检查命令存在、安全日志
安全意识应该贯穿脚本开发的整个过程。定期使用 ShellCheck 等工具检查脚本,及时更新 Bash 版本修复已知漏洞。