
1. 为什么Shell脚本需要安全加固接手一套别人留下的自动化脚本第一步不是看功能而是先做安全加固——这句话是我吃了好几次亏之后才真正理解的。今天这篇就围绕Shell脚本安全加固这个主题把注入攻击、权限泄露这类最容易翻车的风险拆开讲清楚顺便把可复用的最佳实践一起交出来。先说个真实案例。我之前接手一个运维项目定时备份脚本跑了半年都没出过问题。有一天新来的同事在配置里漏了一行变量定义脚本里刚好有一句没加引号的清理命令等号右边展开之后变成了根目录。幸好当时测试环境先暴露了问题生产环境没被波及但那一整天团队都在开会复盘。另一个脚本比较隐蔽备份数据库时把导出文件临时存放在/var/tmp下目录权限是默认的0777同服务器的普通用户完全可以趁窗口期把文件拷走。这两件事放在一起看就是Shell脚本安全最典型的两个风险维度注入攻击和权限泄露。很多人觉得Shell脚本嘛能跑就行安全问题“以后再说”。“以后”通常就是事故发生的那天。Shell脚本和其他编程语言最大的区别在于它是一门“拼接和解释”的语言。变量展开之后还要再过一遍分词、通配符展开、命令替换这些流程这意味着字符串里只要混入特殊字符就可能变成一条新的命令。同时脚本运行所处的环境——PATH、当前目录、文件权限、临时文件路径——每一个环节都可能给攻击者递刀。所以这篇内容适合谁看三类人。第一类是自己写部署脚本、定时任务、初始化脚本的运维工程师和开发工程师第二类是正在做工程化落地、想把脚本质量纳入代码评审和CI流程的团队第三类是学习安全方向、想理解Shell层攻防原理的同学。看完之后你可以做的第一件事就是回头扫描一下自己服务器上所有提供入口的脚本看看有没有“裸奔”的变量和权限位。1.1 Shell脚本安全的本质一处拼接处处风险要理解Shell脚本安全的本质得先接受一个和普通语言不同的底层机制Shell没有真正的“字符串类型”。在大多数高级语言里hello; rm -rf就是一段普通字符串传到哪里都只是字符串。但Shell不是——它把脚本的每一行都当作一组命令变量替换发生的时机是在命令执行前的“词法分析阶段”替换完成的结果会再次参与分词和特殊字符解析。打个比方你写了一个表达式模板echo $x在你的理解里这只是一个“把变量x插入模板再打印”的逻辑。但在Shell看来它是“先把 符号、$x替换成实际值、再把整行重新当成一条命令去解析”。如果x的值是abc$(whoami)Shell会先执行whoami如果x的值是abc; rm -rf /tmpShell会先打印abc再执行清理。这就是Shell注入攻击的根因——数据被当成了代码。这一点和其他注入类漏洞SQL注入、模板注入背后的原理是相通的。攻击者要做的只是让脚本处理的数据里包含Shell元字符。只要有一处变量没有正确引用攻击面就打开了。所以“给变量加引号”这句看起来人尽皆知的建议其实是Shell安全里最高频也最核心的防线。1.2 Shell脚本的三大风险面日常排查时我会把Shell脚本的风险拆成三个层面来审视输入侧风险脚本从哪里拿数据命令行参数、环境变量、配置文件、外部命令的stdout、管道里的数据流全部是外部输入。只要有入口就存在注入的可能。执行侧风险脚本内部如何处理这些数据是否用eval二次求值是否用反引号执行外部命令变量展开时是否保留了对特殊字符的解释能力这是注入发生的主战场。环境侧风险脚本在什么路径下运行PATH里有没有被恶意篡改的目录临时目录是否可预知、可被其他用户写入umask是否放开了权限脚本文件本身是否允许其他用户修改这些环境因素单独看都很不起眼串起来却是权限泄露和提权的常见路径。排查时可以这样操作把脚本从“数据入口”到“命令执行”画一条线沿线检查每个节点是否有净化或校验动作。入口变量如果没校验就等于裸奔执行位置如果没引号就等于开门运行环境如果没控权限就等于把钥匙挂门口。1.3 加固的边界不是所有脚本都要锁到窒息开口闭口“必须全部加固”是不现实的。个人电脑上的临时脚本和服务器上的root定时任务安全基线不可能一样。我一般按运行场景分三档场景风险级别加固要求个人本地、一次性临时脚本低变量加引号、避免危险命令即可服务器定时任务、无人值守脚本高全套基线set -u、参数白名单、固定PATH、临时文件用mktemp、权限最小化对外开放、多用户环境、Web后端的调用脚本非常高全量校验、剥离敏感信息、禁用危险语法、配合权限边界控制判断一个脚本需要多强的防护核心看两点它运行在什么身份下它的输入能不能被不可信的人控制如果答案是“root身份”加“输入来自用户请求”那它就不是一个脚本而是一台需要重点防御的服务器。下面的所有实操都围绕这个判断来展开你可以按自己的实际场景裁剪。2. 注入攻击的根因分析与双层防御注入攻击是Shell脚本安全里最锋利的一把刀也是最容易被新手忽略的一把。我在代码评审里见过太多这样的写法直接把用户传的参数拼进命令字符串然后交给sh -c或eval执行全程没有一次校验。看完一身冷汗。2.1 命令注入是怎么发生的命令注入的前提是Shell对变量展开后的内容做了“二次解释”。典型的危险写法有这么几类# 危险变量值会被当作新命令执行 eval cat $filename sh -c cp $SRC $DST ping -c 1 $HOST第一类是eval和sh -c这两者的作用就是告诉Shell“把我给你的字符串当成代码去解析”属于主动开闸。第二类是“不带引号的变量展开”这是更隐蔽的暗流很多老手也会在某个角落翻车。举一个我实际见过的例子。脚本用来统计日志行数# 危险版本 cmdgrep $keyword /var/log/app.log | wc -l eval $cmd攻击者只要把keyword的值设为x; cat /etc/passwd; echo 这条命令就会变成三条命令连在一起执行。即便不触发系统破坏文件内容被读出并回显到终端已经是信息泄露了。还有一个容易被忽视的入口是“文件名即代码”。我见过有人把外部文件名拼接进命令# 危险文件名里带分号或 $() cat $DIR/$FILE # $FILE 可能来自文件名例如 a.txt; id变量被引号包住了引号防住了分词和通配符但防不住命令替换不对——双引号内$()和反引号依然会被执行。这是所有人都要牢记的一条规则双引号能挡空格和星号但挡不住命令替换和参数扩展。正确的姿势是先校验来源再决定是否放行。2.2 防御第一层引用与语法层的自保最基础的防御就是把“裸变量”全部包进双引号。我给你一套可以直接套用的规范所有变量展开必须加双引号$var、${var}。绝对不要用eval。很多需求看似只有eval能做实际上99.9%可以拆掉。比如上面那个统计日志的例子改成grep $keyword /var/log/app.log | wc -l就完全不需要eval。不要用反引号做命令替换统一用$(...)。反引号内部转义规则混乱容易在不经意间改变语义。使用[[ ... ]]替代[ ... ]前者是Shell关键字而非外部命令行为更可预测内部的变量展开不会发生分词。调用外部命令时优先用绝对路径或者在脚本开头重设PATH。向命令传参时多用“参数列表”而不是“命令字符串”。比如用find ... -exec时建议用find $dir -name *.log -exec rm -f -- {} 这种写法而不是把文件名拼进shell字符串。这里要额外强调一下--参数分隔符。很多命令支持--表示“后面的参数都不是选项”。当变量的值可能以-开头时比如文件名恰好叫-f你不加--命令就会把它当成一个选项。我有个真实教训脚本清理过期文件时某个文件名以-开头find返回的路径被当成了rm的选项直接报了一串错误。加上--之后问题立刻消失。细节上的安全往往都是靠这些小习惯堆出来的。2.3 防御第二层输入校验的白名单思维只靠引用层防御还不够。引号能防止“被当作代码”但防不住“数据本身是攻击者的木马”。如果输入数据里混着换行符、控制字符、甚至伪装成合法值的恶意串靠引用是兜不住的。所以第二层是输入校验。校验的原则只有一个非白名单不放行而不是“看起来不像坏人”。白名单思维具体到实现就是用正则表达式验证变量的格式# 校验只允许字母、数字、下划线、连字符、点 if [[ ! $HOSTNAME ~ ^[A-Za-z0-9._-]$ ]]; then echo invalid hostname 2 exit 1 fi # 校验只允许非负整数 if [[ ! $THRESHOLD ~ ^[0-9]$ ]]; then echo invalid threshold 2 exit 1 fi把“期望的格式”明确定义出来比“把危险字符逐个剔除”要可靠得多。后者很容易漏掉某些编码、空白符或Shell元字符的组合前者直接堵死了所有不符合格式的输入。另一个非常实用的点是参数的边界检查。比如天数、端口号这些有数值范围的参数即使通过了正则校验也要再做一次范围判断if (( DAYS 1 || DAYS 365 )); then echo days must be between 1 and 365 2 exit 1 fi还有一条铁律白名单校验必须在所有使用该变量的行之前完成。如果你先在某条命令里用了变量再来校验等于把后门开在了前面。2.4 防线总结set命令之外的五个习惯除了前面的技术方案我每次写脚本都会在文件头部固定这几行set -u # 使用未定义变量直接报错退出 set -e # 任何一条命令失败就退出 set -o pipefail # 管道中某命令失败整体返回失败这三个参数组合在一起能显著降低“半运行状态下的安全隐患”。特别是set -u它能拦住很多拼写错误导致的空变量而空变量往往就是上一篇文章事故的主角。但注意set -e容易引发“命令真失败但你不想退出”的场景要配合if cmd; then这种模式使用刻意保留失败分支。总结五条防御习惯贴在脚本编辑器上方所有变量展开一律加双引号。所有外部输入先做白名单正则校验。禁用eval、禁用反引号、禁止命令字符串拼接。固定PATH和关键命令的绝对路径。每个脚本开头都不厌其烦地写上set -u -e -o pipefail。这五条不是摆设。其中任何一条被违反你都可以在代码评审时直接打回。3. 权限泄露的全链路管控注入攻击是主动的敌人权限泄露则是被动的隐患。很多脚本看起来“安全”是因为攻击者还没发现它身上可被利用的权限位。等发现的那天往往就是被脱库的那天。3.1 脚本权限位的三个误区第一个误区脚本必须给到777才能运行。这是完全错误的。运行一个脚本只需要执行权限x读取用于排障才需要读权限r大多数情况下脚本本身根本不需要让其他用户可写。我在甲方公司做过安全基线检查统计过一批服务器全局可写ow的脚本数量占总脚本数量的四分之一这是能直接被写入后门的重灾区。第二个误区脚本文件权限改好就万事大吉。运行中的Shell也会从环境变量BASH_ENV非交互Shell读取并执行额外文件。如果BASH_ENV被设置成指向一个可写路径你的脚本在启动时就会被“加料”。所以不仅脚本本身要锁权限启动它的用户环境和PATH也得锁。第三个误区给脚本加上SUID位就能让它以root身份运行。Linux对Shell脚本的SUID位传统上是忽略的内核不会为脚本解释器提供提权机制。但脚本内部如果通过sudo调用其他命令就能间接获得更高权限。这反而成了更难的边界问题。我建议所有脚本都不依赖SUID而是通过sudoers里的白名单来做提权控制。3.2 临时文件与竞态条件临时文件是Shell脚本最容易出权限问题的地方。默认情况下/tmp/tmpfile写出来的文件权限受umask影响可能是0644或更宽松而同服务器上任何用户都能列举和读取/tmp下的文件名。更隐蔽的是符号链接攻击symlink attack和竞态条件。攻击者可以提前在/tmp下创建一个和你脚本将要创建的临时文件同名的符号链接指向他们想覆盖的任意文件。你的脚本顺理成章地往符号链接里写数据结果就是写入被重定向到攻击者指定的目标文件造成未授权修改。路径如果指向/etc/cron.d/xxx后果不堪设想。正确的做法是使用mktemp做原子化创建TMPDIR$(mktemp -d /tmp/myscript.XXXXXX) || exit 1 chmod 700 $TMPDIR trap rm -rf $TMPDIR EXITmktemp -d能生成随机后缀的目录攻击者无法预判也就很难提前布置符号链接。创建完成之后加上chmod 700保证目录内容只有自己能看。最后的trap保证脚本无论正常退出还是中途退出都会清理现场防止敏感中间产物遗留在磁盘上。3.3 敏感信息从生成到销毁的全程管控脚本运行过程中密码、令牌、密钥这些敏感信息有多个暴露面。第一暴露面是命令行本身。ps aux能看到所有进程的命令行参数如果你用mysql -uroot -p$PASSWORD ...这种写法密码会在进程列表中明文存在。正确做法是让程序从配置文件、环境变量、或运行时的交互输入中读取凭据。第二暴露面是环境变量和/proc/pid/environ。脚本里export DB_PASSx之后同权限用户的其他进程可以通过读/proc拿到。如果脚本运行环境本身不可信环境变量传递凭据也不是绝对安全。更稳妥的方案是让脚本中的程序跑起来后再从特权文件中读取使用时短、用完立刻清除。第三暴露面是磁盘上的中间产物。我之前见过脚本把带密码的SQL dump文件留在/tmp下没删。临时文件的权限必须锁到700并且用前面说的trap机制主动清理。额外一个小习惯清理前用shred覆盖敏感文件而不是直接rm因为删除只是删除了目录项索引数据块还在磁盘上。3.4 调用边界的权限控制脚本内部如果想要调用更高权限的命令最常见的方式是配置sudo。这里的坑在于sudoers的配置粒度。如果写成user ALL(ALL) NOPASSWD: /usr/local/bin/backup.sh那么用户虽然只能执行backup.sh但backup.sh脚本内部如果像$0那样允许拼接参数或者脚本自身可以被用户改写那权限依旧会扩大。更稳妥的是在sudoers里只允许固定参数user ALL(ALL) NOPASSWD: /usr/local/bin/backup.sh --backup但sudoers对一行里的多个参数匹配有限真正可靠的做法是“脚本内部二次校验”。把可允许的参数白名单写死用户传任何其他内容都直接拒绝。记住一个原则权限控制的边界应该尽可能靠近数据操作本身而不是只守在最外层调用入口。4. 完整加固实操一个备份脚本的改造全过程理论讲再多不如亲手改一个脚本。下面我们用实际场景走一遍完整的加固过程。场景一台生产服务器需要每晚以root身份运行备份脚本把应用数据打包后存到本地的归档目录并清理30天前的旧备份。4.1 加固前的脆弱脚本先看一个“典型脆弱版本”合并了我们前面提过的多个问题#!/bin/bash APP$1 TARGET_DIR/var/backup/${APP} mkdir -p ${TARGET_DIR} cp -r /data/app /var/backup/${APP}/app_$(date %F).bak rm -rf ${TARGET_DIR}/* echo backup done这段代码至少有五个问题未定义set -u如果$1未传值APP为空。APP没有白名单校验攻击者可传../../some/path造成路径穿越。mkdir -p ${TARGET_DIR}和后面的rm -rf ${TARGET_DIR}/*都未加引号且没有判空。如果APP为空${TARGET_DIR}就会变成/var/backup/后面的rm -rf /var/backup/*会删掉整个备份目录。用$(date %F)生成文件名没有带上时分秒同一天跑两次直接覆盖。文件权限完全依赖默认umask中间产物可能全组可读。没有日志记录出错也无法排查。4.2 加固版本的完整脚本把上面的问题逐个修复一份加固后的脚本如下#!/usr/bin/env bash # 强制使用未定义变量报错、失败即退出、管道失败检测 set -u -e -o pipefail # 固定PATH防止环境变量污染 export PATH/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin readonly PROG$(basename $0) readonly BACKUP_ROOT/var/backup readonly LOG_FILE/var/log/backup_${PROG}.log readonly APP_DATA_DIR/data/app # 创建日志目录 [[ -d /var/log ]] || mkdir -p /var/log log() { printf %s %s: %s\n $(date %F %T) $PROG $* $LOG_FILE } die() { log FATAL: $* exit 1 } # 参数校验个数、格式、取值范围 [[ $# -ge 1 ]] || die usage: $PROG app_name [days_to_keep] APP_NAME$1 DAYS${2:-30} [[ $APP_NAME ~ ^[A-Za-z0-9_-]$ ]] || die invalid app_name: $APP_NAME [[ $DAYS ~ ^[0-9]$ ]] || die invalid days: $DAYS if (( DAYS 1 || DAYS 365 )); then die days must be between 1 and 365 fi umask 077 # 使用 mktemp 创建安全临时目录 TMPDIR$(mktemp -d /tmp/${PROG}.XXXXXX) || die failed to create temp dir chmod 700 $TMPDIR trap rm -rf $TMPDIR EXIT # 真正的备份逻辑 STAMP$(date %Y%m%d%H%M%S) BACKUP_DIR${BACKUP_ROOT}/${APP_NAME} mkdir -p $BACKUP_DIR log starting backup for app${APP_NAME} if tar -czf ${TMPDIR}/${APP_NAME}-${STAMP}.tgz $APP_DATA_DIR 2$LOG_FILE; then mv ${TMPDIR}/${APP_NAME}-${STAMP}.tgz $BACKUP_DIR/ else die tar failed fi # 清理过期备份 if find $BACKUP_DIR -maxdepth 1 -type f -name ${APP_NAME}-*.tgz -mtime ${DAYS} -delete; then log cleaned backups older than ${DAYS} days else die cleanup failed fi log backup completed: ${APP_NAME}-${STAMP}.tgz这个脚本覆盖了前面所有核心原则开头set -u -e -o pipefail未定义变量直接报错。APP_NAME做了白名单校验排除了路径穿越字符。所有变量展开都加了双引号。mktemp -d创建临时目录权限700trap自动清理。umask 077保证脚本创建的任何文件默认权限都是600/700。日志记录了开始、成功和失败状态方便追查。清理旧备份时会判断临时目录变量是否为空且目录存在避免rm -rf的灾难性风险。mv从临时目录移动到归档目录比cp更安全因为临时目录的权限和归档目录的权限可以分开控制。4.3 加固细节的取舍逻辑你可能会问为什么tar和mv是两个步骤而不是直接在BACKUP_ROOT下生成我的考虑是tar的临时产物不应直接落在归档目录否则在tar过程中如果被其他进程读到会看到不完整或未正式发布的文件。先写入私有临时目录确认成功后一次性mv过去可以保证归档目录里不会出现半成品。同理旧文件的清理放在备份完成之后而不是之前可以避免“先删后失败”导致的数据空窗。还有一个细节脚本里[[ $# -ge 1 ]]用的是[[ ]]而非[ ]这不只是风格问题。[[ ]]内部对$#这类变量做的是字符串比较引用和分词行为都可预测安全性更高。如果你还在用[ -n $var ]这种老写法请尽快改过来。4.4 配置sudoers的边界例子假设脚本需要以root运行但你不想给执行者完整的root权限。合理配置是在sudoers里这样写operator ALL(root) NOPASSWD: /usr/local/bin/backup.sh如果担心参数被滥用可以在脚本内增加针对参数的白名单前面的APP_NAME校验正是为此服务。sudoers层面还有一层手段在命令路径上使用sudoedit传递文件路径时若担心路径遍历可以用sudo --明确传参。真正的防线仍然是脚本内的二次校验这一条是底线。4.5 加固效果的验证方法脚本改完之后不能只在理想环境里测一次就上线。我做了一个小的验证清单正常参数bash backup.sh app1 30确认备份生成、日志正确。非法参数传app1; rm -rf /tmp预期脚本拒绝日志里留下记录。路径穿越传../../etc/passwd预期被白名单拦截。空参数直接bash backup.sh预期提示usage并退出。数值越界传0或366预期拒绝。不存在的应用目录确认脚本失败并写日志不制造混乱。ps检查运行脚本时执行ps aux | grep backup确认命令行里没有明文密码。这套清单跑完基本能确认脚本在常见攻击路径上都堵住了。有条件的话我还会在脚本里临时加上trap echo trapped DEBUG来观察异常执行序但这属于高级玩法日常用ShellCheck做静态扫描就足够抓到九成问题。5. 高频踩坑点与排查技巧前面说了这么多实际写脚本时还是有不少和语法、行为相关的坑。这些坑本身不是安全漏洞但在安全加固语境下一个小坑就可能演变成空变量、误删除、错误分支。下面挑几个我自己实测踩过的。5.1 条件判断里的引号与-n/-z陷阱这是一个极其经典且容易翻车的点。看这段代码VAR if [ -n $VAR ]; then echo VAR is not empty fi直觉上-n检查变量是否非空但这里实际输出的是“VAR is not empty”。因为[ -n $VAR ]在展开后变成了[ -n ]而[ -n ]这个单参数形式等价于“字符串非空”结果永远是真。这是set -u出现之前最常见的脚本逻辑炸弹来源之一。而用[[ -n $VAR ]]呢在内部展开上[[ ]]不做分词只有当VAR的值恰好是-n这类特殊字符串时才可能有歧义所以最常见的写法是[[ -n $VAR ]]双确保。我自己的习惯是条件判断里所有变量一律加双引号绝不例外。VAR if [[ -n $VAR ]]; then : # 空值分支 else echo VAR is empty fi5.2 for循环与glob展开陷阱for f in $files这种写法在文件名带空格时会碎成多个元素。比如目录里有一个文件叫report 2024.pdffor f in *.pdf会得到两个词。为了安全遍历文件时应该用“引用变量 [[ -e $f ]]防御”的组合方式for f in $dir/*.pdf; do [[ -e $f ]] || continue echo $f done[[ -e $f ]] || continue这行是处理glob匹配不到任何文件时的情况没有匹配时$dir/*.pdf会保留字面量字符串你需要在循环内判断文件是否存在来跳过这个假目标。如果文件名包含空格或奇怪字符$f的引号保证了整个名字作为整体被处理。对复杂目录树的需求推荐用find ... -print0配合while IFS read -r -d 它能精确处理包含换行符的文件名while IFS read -r -d f; do echo processing: $f done (find $dir -type f -print0)5.3 命令替换与进程状态检查命令替换$(cmd)的结果如果直接拼接到命令里也可能成为注入点。我在实际项目里看到过一个误用TOKEN$(curl -s ...) curl -H Authorization: Bearer $TOKEN https://api.example.com这里如果TOKEN来自攻击者可控的响应TOKEN值里混入引号或分号即使带双引号也可能因为引号内的特殊字符而在HTTP请求中产生语义混乱。更可靠的方案是把TOKEN写入临时文件利用--header file这类从文件读取的功能或者用数组参数传入而不是拼接字符串。任何“从网络/文件读取并马上拼到命令里”的模式都应该按可控输入来对待。5.4 排查案例速查表现象可能原因解决方式空变量导致命令被误执行变量未定义且未使用set -u脚本头加set -u引用前判空[ -n $var ]永远为真单括号内部变量未加引号改用[[ -n $var ]]文件名含空格被拆成多个for循环里未加引号for f in $dir/*glob匹配不到时循环仍执行未加存在性判断[[ -e $f ]] || continue临时文件权限是0644未设置umask或用默认打开方式umask 077或chmod 600目录变量为空造成rm -rf /*变量判空缺失删除前显式[[ -z $dir ]]拦截脚本中sudo命令提示权限不足sudoers配置粒度太粗缩小命令路径、参数或脚本内校验这张表平时贴在手边代码评审时对照着过一遍很多隐患能提前拦下来。6. 工程化扩展把加固做进流程而不是靠记忆脚本安全如果只靠个人记性那迟早会在某个凌晨三点漏掉一条。真正可靠的方案是把加固规则沉淀到工具和流程里让机器帮你盯。6.1 用ShellCheck做静态扫描ShellCheck是目前最成熟的Shell脚本静态分析工具能识别出几百种常见错误和安全风险包括未加引号的变量展开、危险的eval使用等。安装后直接跑shellcheck backup.sh输出会明确指出行号和风险等级。比如未加引号的变量它会提示SC2086: Double quote to prevent globbing and word splitting这类提示直接命中我们前面讨论的核心问题。更好的玩法是把ShellCheck接入CI流水线在GitLab CI或GitHub Actions里对每次提交的Shell脚本做扫描shellcheck: stage: test script: - shellcheck --severitywarning scripts/*.sh这样任何新引入的安全问题在MR阶段就会被拦截而不是等到上线后靠运维救火。我把这个配置写进了团队的工程规范效果立竿见影——新来的同学写脚本时最先学的不是语法而是被ShellCheck教育引号。6.2 定时扫描服务器上的脚本风险除了新代码存量脚本也需要定期体检。我每周跑一次巡检脚本扫描全服务器上的Shell脚本关注几个指标全局可写位其他人可修改你的脚本等于捧着一颗定时炸弹。脚本内部含/tmp硬编码临时路径。脚本内出现eval、反引号、sh -c。脚本以root运行且其他用户可读存在信息泄露风险。一条简单的find命令就能拿到全局可写脚本find / -name *.sh -perm -0002 -type f 2/dev/null之后对命中文件做人工审查该收紧权限的收紧该改写的改写。这样的巡检配合CI的ShellCheck基本能把脚本安全的两个方向——存量与增量——都覆盖住。6.3 把脚本当成真正的工程来管理最后说一点更深层的心得Shell脚本的安全加固本质上不是靠“加几只保险锁”而是把脚本当工程来做。版本管理、代码评审、静态扫描、灰度验证这四个动作缺一不可。我见过很多团队脚本放在服务器上直接改直接跑完全没有版本控制出了事连“哪一行被谁改过”都查不到这样的环境下讨论加固是空谈。从操作层面落地可以先做三件事把所有脚本纳入Git仓库开启MR评审。CI里跑ShellCheck设定为强制门槛。定义最小通行标准脚本头部必须有set -u -e -o pipefail所有外部输入必须白名单校验临时文件必须用mktemp权限必须最小化。做到这三点你就已经超过大多数写脚本的团队了。至于更复杂的容器化隔离、密钥管理系统、特权审计那是随着规模扩大再逐步叠加的事但地基一定是今天拆解的这一套基础规范。写到这里我回头看看自己最早写过的脚本再看看现在写的感触挺深。Shell脚本安全不是一个可以“一劳永逸”的知识点它更像一种贯穿始终的习惯——每次多写一行变量就多问一句“这里能不能被外部控制”每写一条删除命令就多看一眼“目标路径是否可能为空”。这些习惯一开始会觉得繁琐但跑得多了就成了本能。最后分享一个小技巧我在所有脚本开头都会加一行readonly PROG$(basename $0)这行不是装饰它让日志和报错里永远能查到“是谁在说话”排查问题的时候这一句话能省掉无数对着空气猜原因的时间。