在Linux服务器运维中,Shell脚本的便利性往往掩盖了其危险性。一个没有做输入验证的脚本,从网络上接收一个参数就直接拼接到"rm -rf"命令里,这无异于把服务器root权限拱手送人。Shell脚本的安全问题,90%源于对输入数据的盲目信任。解决这个问题的核心不是堆砌防火墙,而是在代码层面建立严格的输入验证机制。
变量引用必须加双引号,这是铁律Shell解析变量时,如果不加双引号,会发生单词分割和路径名展开,这会导致两个严重问题:一是包含空格的参数被拆成多个参数,二是通配符被意外展开。比如你写了一个删除临时文件的脚本,用户输入文件名包含"*",不加引号就会删除当前目录所有文件。
#!/bin/bash # 错误写法 file=$1 rm -f $file # 正确写法 file="$1" rm -f "$file"
双引号能阻止单词分割和通配符展开,但保留变量替换。这意味着变量里的特殊字符不会被Shell二次解释。很多人以为加了双引号就万事大吉,实际上这只是第一道防线。变量内容本身如果包含恶意命令,双引号也挡不住,因为你最终还是会把这个变量传给某些会执行命令的程序。
命令注入的根源与防御命令注入发生在脚本把用户输入直接拼接到Shell命令中执行的时候。最危险的做法是在脚本里使用"eval",或者把变量直接放到反引号或"$()"里。比如一个ping检测脚本,用户输入"127.0.0.1; cat /etc/passwd",如果直接拼接到ping命令后面,密码文件就被读取了。
#!/bin/bash
# 极度危险的写法
ping -c 1 $1
# 相对安全的白名单验证
target="$1"
if [[ "$target" =~ ^[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}$ ]]; then
ping -c 1 "$target"
else
echo "无效的IP地址"
exit 1
fi
正则表达式验证是防御命令注入的有效手段。对于IP地址、端口号、文件名这类有固定格式的输入,用正则严格匹配格式,不匹配的直接拒绝。但正则验证有局限性,遇到复杂格式比如JSON或者自由文本就不好使了。这时候需要换思路,不要让输入数据有机会被Shell解析。
用参数化方式传递数据,切断注入路径Shell本身没有真正的参数化查询概念,但可以模拟这个思路。核心原则是:永远不要把用户输入当作代码执行,只当作数据传递。使用"--"分隔选项和参数,防止用户输入以"-"开头被当作命令行选项解析。很多命令支持"--"来表示后面都是参数而非选项。
#!/bin/bash # 用户输入可能包含以-开头的内容 filename="$1" # 错误:如果filename是 -rf,grep会把它当选项 grep "pattern" "$filename" # 正确:用--明确分隔 grep -- "pattern" "$filename"
对于需要执行外部命令的场景,尽量使用命令自带的参数传递机制,而不是拼接字符串。比如"find"命令的"-name"参数本身就接受通配符,你把用户输入传给"-name",比拼接"*"到字符串里安全得多。因为"-name"参数的内容不会被Shell解析,而是由"find"命令内部处理。
路径遍历攻击与文件操作安全文件操作是Shell脚本最常见的任务,也是路径遍历攻击的高发区。用户输入"../../etc/passwd"这样的相对路径,就能读取或写入任意文件。防御方法不是简单的黑名单过滤"..",而是要把路径规范化后再验证。
#!/bin/bash
# 接收用户指定的文件名
user_file="$1"
# 获取绝对路径并解析所有符号链接和..
real_path=$(realpath -q "$user_file" 2>/dev/null)
# 定义允许操作的目录
allowed_dir="/var/myapp/data/"
# 检查解析后的路径是否在允许的目录内
if [[ "$real_path" == "$allowed_dir"* ]]; then
cat "$real_path"
else
echo "拒绝访问"
exit 1
fi
"realpath"命令能解析所有符号链接和相对路径,返回标准的绝对路径。拿到绝对路径后,用字符串前缀匹配检查它是否在允许的目录范围内。注意检查时要在允许目录后面加个斜杠,否则"/var/myapp/data_evil"这种路径也能通过前缀匹配。如果系统没有"realpath"命令,可以用"readlink -f"替代。
临时文件的安全创建与竞态条件在"/tmp"目录创建临时文件时,如果使用固定的文件名,攻击者可以提前创建一个同名软链接指向系统关键文件,你的脚本写进去就会覆盖系统文件。这种攻击叫symlink attack。正确的做法是使用"mktemp"命令创建不可预测的临时文件名。
#!/bin/bash # 错误:可预测的文件名 tmpfile="/tmp/myapp_$$.tmp" echo "data" > "$tmpfile" # 正确:使用mktemp tmpfile=$(mktemp /tmp/myapp.XXXXXX) # 或者创建临时目录 tmpdir=$(mktemp -d /tmp/myapp.XXXXXX) # 设置trap确保退出时清理 trap "rm -rf '$tmpfile' '$tmpdir'" EXIT
"mktemp"会生成随机后缀,并确保文件不存在才创建,原子性地避免了竞态条件。"trap"命令设置退出时的清理操作,防止临时文件残留。注意"trap"里的命令要用单引号,这样变量在脚本运行时才展开,而不是在设置trap时展开。
环境变量与特殊字符的坑Shell脚本运行时继承的环境变量可能被篡改。比如"IFS"变量定义了Shell的分隔符,如果被改成包含"/",很多路径操作就会出错。"PATH"变量被篡改后,脚本里调用的命令可能指向恶意程序。脚本开头应该显式设置这些关键变量。
#!/bin/bash # 安全脚本的标准开头 export PATH=/usr/bin:/bin:/usr/sbin:/sbin export IFS=$' \t\n' umask 022 # 如果不需要任何环境变量,可以用env -i启动脚本
处理包含特殊字符的数据时,比如文件名里有换行符,"ls"和"for"循环都会出问题。用"find"配合"-print0"和"xargs -0",或者用"while read"配合空字符分隔,才能正确处理任意文件名。
#!/bin/bash
# 正确处理包含换行符和空格的文件名
find /path -type f -print0 | while IFS= read -r -d '' file; do
echo "处理文件: $file"
# 对$file的操作都要加双引号
done
脚本自身的权限与部署安全
脚本文件本身的权限设置也是安全的一部分。脚本如果放在其他用户可写的目录,攻击者可以修改脚本内容植入后门。脚本文件应该设置为只有所有者可写,目录权限严格控制。对于需要提权运行的脚本,不要在脚本里写死密码,用sudo配合NOPASSWD精确控制可执行的命令。
# /etc/sudoers.d/myapp # 只允许执行特定脚本,且不能修改参数 myuser ALL=(root) NOPASSWD: /usr/local/bin/myapp_backup.sh
脚本里如果必须处理敏感数据,比如数据库密码或API密钥,不要硬编码在脚本里。用环境变量传递,或者从权限严格控制的配置文件中读取。读取配置文件时,不要直接source,因为source会执行文件里的Shell代码。应该用专门的解析逻辑只提取需要的值。
#!/bin/bash
# 安全读取配置文件中的键值对
config_file="/etc/myapp/config"
if [[ ! -f "$config_file" ]]; then
echo "配置文件不存在"
exit 1
fi
# 检查配置文件权限,不允许其他用户可写
if [[ "$(stat -c %a "$config_file")" != "600" ]]; then
echo "配置文件权限不安全"
exit 1
fi
# 逐行解析,不source
while IFS='=' read -r key value; do
case "$key" in
DB_PASS) db_pass="$value" ;;
API_KEY) api_key="$value" ;;
esac
done < "$config_file"
Shell脚本的安全编写本质上是一种防御性编程思维。每个接收外部数据的点都是潜在的攻击面,每个命令调用都要考虑参数被篡改的可能。输入验证不是可有可无的附加步骤,而是脚本能安全运行的前提条件。把验证逻辑写进脚本的最前面,不合法就退出,不给恶意输入任何执行的机会。这种习惯一旦养成,写出来的脚本自然就具备了抵御大部分攻击的能力。
