
Shell 脚本安全加固这件事我踩过的坑不比写过的脚本少。这个系列写到现在已经是第 77 篇了很多人问我为什么能坚持这么久——因为只要还有人觉得“脚本能跑就行”安全事件就会反复发生。一旦脚本里涉及用户输入、文件操作、定时任务、数据库连接风险就变得非常现实。注入攻击、权限泄露这两个问题听起来像安全专家的黑话实际上在运维和自动化脚本里天天露出真面目——一个未加引号的变量、一段拼接命令的 eval、一个顺手分配的 777 权限都可能成为整个系统的突破口。这篇文章不打算讲高深的理论而是把我自己项目中真正用到的加固思路整理成一份可复用的实践清单适合刚接触 shell 脚本、或者在工程化实践中想提升脚本安全性的同学参考。1. 内容整体设计与思路拆解1.1 注入攻击为什么防不胜防Shell 里有个反直觉的事实命令、变量、文件名、路径这些在编程语言里界限分明的东西在 Shell 中统统都是字符串。一个变量被展开的时候如果没加引号它携带的分号、管道符、换行符都会变成真正的命令语法。比如一段删除备份文件的脚本filename$1 rm -rf /data/backup/$filename如果调用者传入test.txt; rm -rf /data这样的参数命令就会变成rm -rf /data/backup/test.txt; rm -rf /data。这不是危言耸听而是 Shell 世界里最经典的命令注入案例我在实战里亲眼见过。这种问题的根因在于开发者把“数据”直接塞进了“命令语法”里却没有告诉 Shell 哪个部分是数据、哪个部分是语法。引号和参数传递方式就是用来做这种区分的。更要命的是注入并不只发生在命令行参数上。文件内容、数据库返回值、HTTP 响应体、环境变量这些外部输入只要流进了命令拼接都有可能成为注入的载体。我在一次自动化部署脚本里就遇到过把 git 提交信息直接拼成文件名使用结果某个提交消息里带着 touch /tmp/test.sh差点把错误命令执行了。注入攻击不挑场合任何一条数据链路断了防线整条链路都会失守。1.2 权限泄露的常见路径权限泄露这个词听起来像配置文件权限的问题其实远不止于此。我见过的真实案例至少有四种。第一种是脚本文件本身权限太宽比如chmod 777 script.sh任何用户都能改脚本内容也就等于任何用户都能把你机器上的 root 权限拿走。第二种是脚本里写入了不该出现的硬编码密码或密钥比如mysql -uroot -p123456只要有人能看这个脚本密码就泄露了。第三种是运行用户权限过大比如所有定时任务都用 root 跑一旦脚本里有漏洞整个系统就暴露了。第四种是临时文件没有清理干净脚本往 /tmp 写了一个日志文件里面是数据库的查询语句和变量值结果被其他用户用cat /tmp/xxx.log轻松读到。把脚本的运行用户、文件的权限位、临时文件的所有者、敏感信息的存放方式放在一起看你会发现权限泄露通常不是一个“开关”问题而是一整套“访问边界”没有被划清楚。划清边界的基本原则只有一个谁需要访问就只给他最小、最短时间、最封闭的访问。这个思想贯穿了后面每一个实操步骤。1.3 加固方案的总体设计思路我自己做 Shell 脚本安全加固不是一上来就翻文档改代码而是先在脑子里画一条流水线输入从哪里来经过哪些命令写入到哪里以什么用户身份运行期间产生了哪些临时文件和日志。这条流水线上每一个环节都问自己三句话这里的输入完全可信吗这里有没有可能被当作命令执行这条路径上的文件和权限是不是最小化把这三个问题过一遍脚本的安全缺口基本就暴露出来了。加固操作本身也讲究顺序。我的习惯是从 shell 运行时选项做基础设置比如set -Eeuo pipefail先把“未定义变量”“命令失败继续跑”这种默认的松散行为收紧然后处理输入验证和引号引用把注入的口子堵上再处理权限和用户维度把攻击面缩小最后加静态检查工具和日志审计让问题能被发现。这套顺序用起来很顺手接下来的四章基本就是按这个顺序展开的。2. 防注入的核心细节与实操要点2.1 输入验证在入口处拦住脏数据防注入的第一道门不是写过滤函数而是定义“什么样的输入是合法的”。拿前面那个删除备份文件的例子来说合法的文件名只能是字母、数字、点、连字符、下划线长度不超过 64 字节。那就在拿到参数的第一时间做白名单校验任何不匹配的输入直接拒绝执行而不是等到命令拼接的时候才想办法转义。白名单校验比黑名单过滤安全得多因为黑名单永远列不全“所有危险字符”而白名单只需要定义“我允许什么进来”。具体到 Shell 里的实现我自己最常用的是 case 模式匹配或正则校验。例如case $filename in *[!a-zA-Z0-9._-]*|) echo 非法文件名 2 exit 1 ;; esac这个模式的意思是只要文件名里包含不在a-zA-Z0-9._-集合中的字符或者整个就是空字符串就拒绝。写成 case 而不是用 grep是因为纯 Shell 场景下 case 匹配是原生内置逻辑不依赖外部命令解析速度更快在 for 循环遍历文件列表时性能差距很明显。正则校验也可以用 grep但要记得 grep 本身也是外部进程在高频循环里会拖慢脚本。验证输入这件事本质上是一个“尽早退出”的设计思路。很多人写脚本喜欢把错误留到最后统一处理但安全场景正好相反越早发现脏数据越早退出系统越安全。一个不干净的用户输入进入脚本中段后面每一行代码都要为其买单。我在部署脚本里就是先集中校验一批参数全部合法才继续跑否则直接报错退出这样既安全脚本逻辑也清晰。2.2 变量引用引号是最便宜的安全措施在 Shell 里$var和$var的区别隔着一条安全线。不加引号的时候Shell 会先对变量做分词word splitting和路径扩展变量里的空格、制表符、换行都会被当作分隔符更危险的是一旦变量里含有;、|、这类字符如果后面还跟了命令替换或者 eval就可能被解释成新的命令语法。加引号之后整个变量的值就作为一个整体字符串传递Shell 不会去拆解它大部分注入风险随之消失。举个例子file$1 ls -l $file如果传入$(whoami)在某些 Shell 环境下会先执行命令替换直接泄露当前用户名。正确写法是ls -l $file给$file加双引号$(whoami)就只是普通字符串不会被当作命令替换执行。双引号和单引号的差别要说清楚双引号内的变量会被展开但展开后的结果仍然是字符串不会做分词和命令替换单引号则是完全不进行任何展开适合用于纯字面量字符串。两者根据场景选但核心准则是“所有变量引用尽量加引号”。还有一个细节很多人不知道数组变量在展开时也要注意。for f in ${array[]}这种写法是安全的但如果写for f in $array你会得到一个被空格切碎的错误列表。数组的元素数量不可控不加引号时可能把一个元素的值拆成多个元素加到错误参数列表里后面的命令就全乱套了。规则的优先级永远一样凡是你想让 Shell 当做一个整体来处理的内容都给引号。2.3 避免命令拼接与 eval 的诱惑我知道很多人写 Shell 脚本图省事喜欢把整条命令拼成一个字符串再执行比如cmdtar -czf $backup_name $dir eval $cmd这段代码的问题显而易见$backup_name和$dir里一旦混入; rm -rf /之类的片段eval 会如实地把它当作命令执行。别以为 eval 是“高级功能”在安全视角里 eval 就是一颗定时炸弹。绝大多数 Shell 脚本根本不需要 eval需要动态命令时用数组方式保存参数列表再执行即可args(tar -czf $backup_name $dir) ${args[]}这样每个参数都是独立的数组元素哪怕里面带空格、带特殊字符都会被原样作为单个参数传给 tar不会被拆分或解释成其他命令。这也是为什么我说“数组是 Shell 里的安全容器”它能在不使用 eval 的前提下保留命令的参数边界。和 eval 同样危险的还有反引号命令替换和直接拼接管道链。反引号里如果出现某些环境变量它们会被当作命令替换执行。更隐晦的坑是管道链中的变量拼接比如grep $2 $1 | awk -F, {print $$col$1}你以为在拼接列号结果一个没处理好就破坏了 awk 的语法边界。我的做法是需要管道时尽量让每个阶段的参数保持“引用完整”尽量不在管道中间夹带运行时变量确需使用时就先把参数写好再执行不让命令在同一条字符串里被反复拼装。理解命令是有边界的、参数是要完整传递的比绞尽脑汁记一堆转义规则更本质。2.4 编写安全函数而不是裸跑命令函数是 Shell 里的一个极好组织单元因为在函数内部可以隔离变量作用域把验证逻辑和目标命令打包在一起用的时候只需要调用函数不需要重复写验证代码。比如为文件路径操作定义一个安全函数safe_rm() { local target$1 if [[ ! -e $target ]]; then echo 目标不存在: $target 2 return 1 fi if [[ $target / || $target $HOME || $target ~ ^/etc ]]; then echo 危险路径拒绝删除: $target 2 return 1 fi rm -rf $target }这样调用方就不需要关心路径校验了函数内部把危险情况全部拦截。几个通用的原则函数里尽量使用local声明局部变量避免污染全局所有需要外部输入的参数在函数入口处做一次校验函数内部不要直接使用 eval也不要依赖外部环境的条件分支做决定。合理封装后整个脚本的可读性、复用性和安全程度都有明显提升。很多重视工程化的团队都把这类安全函数单独抽到一个common.sh里所有脚本统一引用是非常好的实践。3. 权限最小化从文件到运行者的全链路加固3.1 文件权限与属主属组的正确设置文件权限这个话题说起来基础但真实运维里踩坑率极高。脚本文件本身应该只允许属主写、其他人只读甚至不可读尤其是包含敏感信息如路径、连接字符串、密钥的脚本不要把权限放开到644以上。我的原则是脚本文件设700或者750如果确实需要其他用户执行用750加一个专门的脚本组不要图省事直接755。755意味着任何用户都能读文件内容脚本里的任何字符串都可能泄露哪怕没有明文密码脚本里的路径结构、库名、用户名也是攻击者的重要情报。配置文件或者密钥文件的权限更严格通常设成600属主是运行服务的专用用户。很多人在项目里顺手chmod 600之后就不管属主了但一个600的文件如果属主是 root运行脚本的用户是 appuser脚本根本读不到内容反过来改成744又让其他用户能读中间的坑绕来绕去。我的建议是创建一个专用系统用户配置文件归它所有运行时只用它一个身份这样一个用户对应一套配置权限边界最干净。用stat -c %a %U %G 文件可以快速检查权限信息这个命令我在实战部分还会再演示。3.2 以最小权限用户运行脚本这是权限最小化里最重要的一步——明明只需要读取数据库却用 root 跑脚本等于给人留了一把万能钥匙。我的经验是每个脚本任务单独创建一个系统用户比如备份任务用 backupuser日志清理任务用 cleanupuser它们各自的权限只够操作自己那一摊目录和文件。创建专用用户的做法useradd -r -s /usr/sbin/nologin backupuser chown -R backupuser:backupuser /data/backup加上-r表示创建系统用户不登录-s /usr/sbin/nologin表示禁止交互式 Shell 登录即使被提权也无法直接开一个 Shell 操作。然后配置 sudo 时只允许这个用户执行那几条具体命令不要给多余的 shell 权限更不要把所有命令都塞给一个用户。在 crontab 里也要明确指定运行用户像0 2 * * * backupuser /usr/local/bin/backup.sh /dev/null 21这样写而不是默认用 root 跑。用户权限这件事还有个容易被忽略的地方脚本里面如果需要切换用户用sudo -u backupuser不要用su -。su -会加载目标用户的环境并进入交互 Shell这种“全量切换”把环境变量、home 目录全都带进来了攻击面太大sudo -u则只切换执行者身份保持了环境变量的最小集合配合 sudoers 里面用NOPASSWD限定命令安全边界清晰得多。在需要执行特权操作的脚本中我甚至会把 sudo 命令尽量安排在脚本靠后的位置把“提权窗口”压缩到最小。3.3 临时文件的安全创建方案临时文件是权限泄露的低调入口。大多数人写脚本直接往 /tmp 写文件比如echo $db_pass /tmp/db.info如果后续没有及时删除文件就会一直躺在那里即使立即用掉了在文件存在的窗口里其他用户也能读取内容因为 /tmp 目录的 sticky bit 机制只限制删除并不限制读取。正确做法有二第一尽量把临时文件创建在脚本自己的目录下配合700权限的临时子目录这也是我强烈推荐的方案第二必须用 /tmp 时用mktemp -d创建私有目录然后在这个目录里操作结束后用 trap 自动清理。trap的用法是脚本安全防御的老朋友tmp_dir$(mktemp -d) trap rm -rf $tmp_dir EXIT这句命令保证脚本无论正常退出还是中途报错都会自动删除临时目录避免了“脚本崩了留下文件”的典型事故。还可以给临时文件本身设置umask 077这意味着新创建的文件默认只有创建者自己能读而不是默认的644或者666。umask 的坑在于它在子 shell 中也可能被继承如果脚本后面要调用外部程序最好在每个关键位置显式设置一次umask 077不要指望一次设置全程生效。很多安全事故就发生在临时文件上因为开发者觉得“临时”就无所谓恰恰是临时文件最容易泄漏敏感信息。3.4 sudo 与提权策略的收敛sudo 是安全加固里最容易“一省到底”的地方。有人图方便直接给脚本里的用户配一个ALL(ALL) ALL这可太危险了。我见过一次事故某台机器的 crontab 是以 www-data 运行的但是 www-data 被授权了 ALL 权限脚本里一个简单的命令注入直接变成了整个系统的控制权。收敛 sudo 的策略不复杂就是把“能少给就少给”落实到 sudoers 文件的每一行。比如只允许 backupuser 执行/usr/bin/tar和/usr/bin/mysqldump写成backupuser ALL(root) NOPASSWD: /usr/bin/tar -czf *, /usr/bin/mysqldump *注意这里的-czf *是参数模板实际使用时要小心通配符的匹配粒度它虽然不完美但远比 ALL 权限安全得多。更精细的方案是让备份脚本用受限 Shell 或者 AppArmor/SELinux 来约束但那是另一个深水区的内容日常工程化实践里先把 sudo 规则收敛到“命令级白名单”已经能挡住九成风险。我还想提醒一句不要在脚本里明文写 sudo 密码也不要使用echo password | sudo -S这类做法。这种设计等于把密码硬编码进脚本日志、history 文件、审计记录里都会留下痕迹且一旦有人碰过脚本密码就泄露了。现代环境里更推荐用 NOPASSWD 配合命令白名单从源头上减少对明文密码的依赖。如果确实有场景必须使用密码式 sudo至少把密码放在权限受限的配置文件里用专门的机密管理服务来管理不要让脚本本身直接接触密码。4. 从问题脚本到加固脚本完整实操记录4.1 一份真实的问题脚本这是一份我在实战项目里接手过的问题脚本脱敏改写它本身是一个每日凌晨备份数据库并清理旧备份的通用脚本#!/bin/bash BACKUP_DIR/data/backup DB_NAME$1 BACKUP_FILE$BACKUP_DIR/backup_$DB_NAME_$(date %F).sql mysqldump -uroot -proot123 $DB_NAME $BACKUP_FILE if [ $? -eq 0 ]; then echo 备份成功 /tmp/backup_log_$DB_NAME else echo 备份失败 /tmp/backup_log_$DB_NAME fi cp $BACKUP_FILE /tmp/这个脚本问题密集我们一条条分析。第一$1直接用了没有任何校验数据库名里如果带了分号mysqldump 的参数解析就会出现严重问题。第二用-uroot -proot123硬编码了 root 账号和密码等于把整个数据库的钥匙写在门上。第三if [ $? -eq 0 ]这种写法不但容易受中间命令干扰而且没有set -e保护。第四临时日志直接写到 /tmp文件名还带了个数据库变量内容可能被其他用户读到。第五cp $BACKUP_FILE /tmp/把整个备份文件公开到了临时目录这个最致命等于把数据库内容直接放进了所有人能读的目录。第六整个文件权限如果没有认真设置一个755就让所有用户都能看到root123。这个脚本能跑但基本没有安全可言接手必须更新。4.2 加固后的完整脚本下面是我改造后的版本为了方便阅读我用了变量化配置并且切分了不同部分#!/bin/bash set -Eeuo pipefail umask 077 DB_NAME${1:-} BACKUP_DIR/data/backup LOG_DIR/var/log/backup-script MYSQL_USERbackupuser MYSQL_PASS_FILE/etc/backup/mysql_backup.conf cleanup() { rm -rf $TMP_WORK_DIR } trap cleanup EXIT # 1. 输入白名单校验 case $DB_NAME in *[!a-zA-Z0-9_]*|) echo 非法数据库名: $DB_NAME 2 exit 1 ;; esac # 2. 读取密码文件避免硬编码 if [[ -r $MYSQL_PASS_FILE ]]; then MYSQL_PASS$(sed -n s/^MYSQL_PASSWORD//p $MYSQL_PASS_FILE) else echo 缺少密码文件 $MYSQL_PASS_FILE 2 exit 1 fi # 3. 创建私有临时目录 TMP_WORK_DIR$(mktemp -d /tmp/backup-XXXXXX) chmod 700 $TMP_WORK_DIR # 4. 执行备份参数使用数组或变量传参 BACKUP_FILE$BACKUP_DIR/backup_${DB_NAME}_$(date %F).sql mysqldump -u$MYSQL_USER -p$MYSQL_PASS $DB_NAME $BACKUP_FILE chmod 600 $BACKUP_FILE # 5. 日志写入固定目录 mkdir -p $LOG_DIR echo 数据库备份成功: $BACKUP_FILE $LOG_DIR/backup_${DB_NAME}.log # 6. 清理旧备份仅保留最近7天 find $BACKUP_DIR -name backup_${DB_NAME}_*.sql -mtime 7 -delete为了演示我把每个部分的注释写得很清楚实际项目里我会把这些函数和公共部分抽成一个common.sh集中维护。现在这个脚本已经解决了很多安全问题但即便如此它仍然有一些需要结合部署环境进一步调整的点比如密码文件的读取方式、临时目录的清理时机这些我下面会补充说明。4.3 加固前后差异逐条说明对照前文的问题脚本这个加固版做了几层关键改动我逐个讲透。第一层脚本顶部加了set -Eeuo pipefail。-e让脚本在任意命令失败时立即退出-u让未定义变量直接报错而不是展开成空字符串-o pipefail让管道中任何一个环节失败都会让整个管道失败-E让 ERR 陷阱能够被捕获。这一套组合拳是 Shell 安全加固里最基础也最重要的“运行时安全带”没有它后面的一切加固都可能因静默失败而失去效果。第二层通过umask 077配合mktemp -d让所有新建文件默认权限都是600临时目录权限是700。这就解决了原始脚本里/tmp/backup_log_$DB_NAME和cp $BACKUP_FILE /tmp/带来的公开可读问题。注意mktemp -d /tmp/backup-XXXXXX中的XXXXXX是随机占位符mktemp会用随机字符替换它保证目录名不可预测配合 trap 能确保退出时自动删除。第三层把数据库密码从脚本里移到了单独的权限受限文件/etc/backup/mysql_backup.conf并且用专门的备份用户backupuser来执行 mysqldump不再使用 root 账号。密码文件的内容约定为MYSQL_PASSWORDSomeSecret脚本只读取这一行即使泄露单个值也不会暴露更多配置。账号和密码分离备份用户只能做备份操作数据库被拖库时还能把损失控制在单个权限领域内。第四层备份文件生成后立即chmod 600日志写入固定目录而不是 /tmp并且对旧备份执行find ... -mtime 7 -delete清理。备份文件本身属于敏感数据存放在受限目录的前提下还要保证文件权限只能由脚本用户读写。日志也不应该留在共享临时目录固定到/var/log/backup-script下权限和属主都采用专用用户体系。我还在脚本里特意保留了$?的替代方案——set -e本身已经让失败即退出后面不需要再用if [ $? -eq 0 ]去手动判断这样逻辑更简洁也避免了$?被其他命令覆盖的问题。如果你确实需要记录失败信息可以在函数内部用trap echo error line $LINENO 2 ERR来捕获具体出错位置这比$?粗暴判断靠谱得多。5. 常见问题、工具选型与避坑清单5.1 静态检查工具shellcheck 与 bash -n代码写多了单靠人眼一眼扫过去安全问题是看不全的。我的建议是每个 Shell 脚本在提交前至少过一遍静态分析工具。我常用的工具是 ShellCheck同类工具还有 shfmt、bashate 等也可以交叉验证。ShellCheck 能检测到未引用变量、缺引号、错误使用 eval、命令替换歧义等一大堆问题。运行shellcheck script.sh时它会给出类似SC2086: Double quote to prevent globbing and word splitting的提示这个提示对应的正是变量未加引号的安全风险。很多公司把 ShellCheck 集成进 CI/CD 流水线作为 shell 脚本的统一“安全门禁”这很值得借鉴。除了静态检查bash -n script.sh可以快速检查语法错误bash -x可以在调试时逐行打印命令但这两种手段偏“调试”不能替代安全加固。我的习惯是把 ShellCheck 的告警级别调成error再配合团队代码评审一起使用效果最好。安全加固是持续的事情不是写完一遍就万事大吉工具能把人的疏忽补上但最终把关的还是人工评审。工具只能帮你发现“看起来不对的地方”真正决定安全的是你对业务逻辑和权限边界的理解。5.2 常见问题速查表我整理了一张高频问题速查表表格里是我在项目里真实遇到、真实排查过的问题可以当作检查清单来用。症状可能原因快速排查方法修复方向变量内容被当成命令执行未加引号或使用了 evalecho $var观察输出是否有额外语法所有变量加引号使用数组参数脚本在中间静默失败但退出码为 0没有set -e用bash -x逐步运行观察启用set -Eeuo pipefail文件权限变成 777umask 默认值不当umask查看掩码脚本头部显式设置umask 077备份文件被其他用户读取/tmp 下共享权限文件find /tmp -type f -perm -004用 mktemp -d 700 目录sudo 权限过大sudoers 配置了 ALL 权限sudo -l查看授权情况收敛到命令级白名单密钥明文出现在脚本硬编码密码grep -rn password|passwd|pwd移到独立权限受限配置定时任务执行失败crontab 缺用户指定grep -v ^# /etc/crontab每条任务指定用户输入校验被绕过后命令执行白名单过宽或不存在手动传入危险字符串验证收紧白名单尽早 exit这张表的价值在于它不只是“问题-答案”的罗列而是给了一个排查路径先观察症状再定位最可能的原因然后用推荐命令快速确认最后落到理性的修复方向上。按着表格走大多数 Shell 脚本的安全隐患在一个小时内就能扫干净。我在团队里会把这张表贴在共享文档里新人接手脚本类任务时先对照着做一轮自查能省掉很多后续的排障时间。5.3 避坑经验与最后的一点心得最后分享几个我多次踩坑之后悟出来的经验这些在常规文档里基本看不到。第一set -e有个“隐形陷阱”当set -e开启时函数内部如果有命令失败整个函数立即退出这本身是好事但很多人会把command || true这种写法滥用来绕过退出机制结果掩盖了真正的错误。我的建议是如果某条命令失败不太影响主流程那就明确写出期望失败并处理比如rm -f $tmp || true如果是真正不该失败的步骤让它失败后退出反而更安全。第二检查注入安全时不要只测“正常链路”一定要把那些“恶意输入”当成一等公民来测。我习惯在本地准备一组脏数据列表里面包含; rm -rf /tmp/test、$(whoami)、| id、id、/dev/null、换行等经典 payload每次改完脚本就跑一遍回归看有没有某个分支把脏数据当成命令执行。这套做法我叫它“脏数据回归测试”虽然名字土但在工程化实践里比任何口头承诺都靠谱。第三一个脚本的安全等级取决于它最脆弱的那一处。哪怕你做了再多的变量引号、输入校验、权限设置只要 sudoers 里给了 ALL 权限或者密码硬编码没删干净前面的一切都是白费。所以做完加固以后我会花几分钟用最基础的命令自查一遍grep -rn $(whoami)\|root\|password /path/to/scripts/配合stat -c %a %U %G /path/to/scripts/*检查权限再通过sudo -l看授权列表。每次看到输出里有不该出现的字符串就知道还有漏洞要修。Shell 脚本安全加固本身不是一次性任务而是一个持续的过程。我接手过的脚本经常会因为需求演进被加上新的参数、新的分支每加一次安全隐患就有机会卷土重来。把上面这些检查内化成习惯每次修改后都跑一遍脏数据回归和权限自查才算真正把安全落到日常工作里。这个系列写到第 77 篇我自己回头看每一章其实都在讲同一件事——给 Shell 脚本画出清晰的边界让数据是数据命令是命令权限是权限。希望这篇实践笔记对你接下来的脚本工程化有点用处也欢迎你来交流你踩过的坑。