1. 项目概述与安全检测思路
Shell是Linux系统管理的“万能钥匙”,但很多人只拿它来做日常的文件操作、服务启停,其实它还有个被轻视的能力——充当轻量级的安全体检工具。我之前给一批线上CentOS服务器做过一轮安全自查,全部用纯Shell脚本完成,没有引入任何重量级扫描器,核心就两个方向:一是账户密码强度是否达标,二是是否存在未授权访问的入口。这套东西跑完以后,我直接把发现的问题整理成了一个文档,效果出奇地好,很多同事看了以后才意识到自己机器的安全基线压根没过关。
这个第53章的核心定位不是教你写一个花哨的扫描器,而是围绕“系统安全漏洞”这个目标,用Shell把最容易出问题的两个面——账户密码强度和未授权访问检测——系统性地过一遍。它能解决的问题很具体:哪些账号是空密码或弱密码,哪些账号快过期了还在用,哪些SSH配置项在引狼入室,哪些SUID文件给了普通用户不该有的权限,哪些端口暴露在不该暴露的地方。适合谁看呢?主要是有一定Linux基础、想在不引入商业工具的前提下给系统做快速安全体检的运维人员,也包括安全工程师做基线核查前想先用Shell粗筛一遍的场景。
说个背景:很多中小团队没有专职安全岗,系统部署完以后就那么跑着,密码策略三五年不换,SSH配置用默认值,SUID文件有多少个没人说得清。真等出事再排查,往往已经晚了一步。所以这套Shell安全检测方案的价值在于“低成本、可复现、能落地”——每台机器都可以跑,结果可以直接人工复核,不需要装agent,也不需要编译第三方组件。
在动手之前我需要把概念讲清楚。账户密码强度不是一个单纯的长度问题,它包含了几层意思:密码是否有最低长度和复杂度要求、是否存在空密码或共享默认密码、密码使用周期是否过长、root之外是否还有特权账户在裸奔。未授权访问检测则更宽泛,凡是让无凭证或低权限用户能触及不该访问的资源的行为,都算这一类。比如SSH开了root直接登录且密码简单,等于把大门钥匙挂在了门框上;再比如某个SUID文件被篡改过,普通用户跑一下就能读取shadow文件,这种事Shell完全能查。
我的核心设计原则是:每个检测项都要有输出,有结论,有建议。不能光报“有问题”,得告诉你看完结果后下一步做什么。脚本做到后面会越来越像一个简化版的安全基线检查器,这正是Shell的魅力所在——你能一步步把专业工具里的重点逻辑用几百行脚本复现出来,而且逻辑完全透明,没有任何黑盒。
2. 账户密码强度检测的设计与实现
账户密码这块是安全检测的第一道关卡。Linux账户体系里,口令数据在/etc/shadow,所有密码策略相关参数几乎都围绕这个文件展开。但直接读shadow文件需要root权限,普通用户没法操作,所以在写脚本时第一件事就是检测当前运行状态,如果不是root直接给出提示并结束,这个前置判断非常关键,能避免后面一堆权限类报错。
2.1 空密码账户与弱密码识别
空密码账户是一个几乎稳被拉满的安全漏洞。怎么用Shell检测呢?/etc/shadow文件的每一行格式是这样:用户名:密码哈希:.....,多个字段用冒号分隔。密码哈希字段如果是空,或者是一些特殊的占位符(比如!、*、!!),说明该账户没有可用密码或处于锁定状态。空密码账户会直接让所有持该用户名的人免密登入,这个危害级别是需要立即修复的。
我写脚本时用了一条简洁的awk命令来筛查:
awk -F: '($2 == "" || $2 == "!" || $2 == "*" || $2 == "!!" && $3 != "0") {print $1}' /etc/shadow这段逻辑有个细节容易被忽略——$3 != "0"这个条件。$3是密码最近修改时间距1970年1月1日天数,root账户在绝大多数系统里该值是0,如果直接过滤掉第二字段为特殊字符的行,会把root误报成空密码。我当初第一次跑脚本就踩过这个坑,系统提示root密码安全性为0,一看shadow文件,果然root的密码哈希位置是!!,但这并不代表root没有密码,而是某些系统加固脚本把root的哈希标记为锁定状态。所以判断空密码的真实条件是“第二字段为空”,并且要排除UID为0的系统特权账户,否则误报率会非常高。
弱密码检测没有那么直接。系统本身不会把明文密码存在任何地方,所以在纯Shell层面,我们真正能做的是检测“密码策略是否允许弱密码存在”,而不是直接判断某个密码是否太短。两个核心配置项:PASS_MAX_DAYS是密码最长使用天数,PASS_MIN_LEN是最小长度要求。如果PASS_MIN_DAYS是0,意味着用户可以立刻修改密码,等于密码没有最低使用期限,换密码的实际意义就大打折扣。我用这样的方式读取配置:
grep -E "PASS_MAX_DAYS|PASS_MIN_DAYS|PASS_MIN_LEN" /etc/login.defs然后针对结果做出分级判断:90天以上强制更换属于合格基线;不设限制,或者最小长度低于8位的,直接标记为高风险。很多新手会直接改/etc/login.defs里的参数,但我要提醒一个坑:这个文件里的策略只对新用户和密码修改操作生效,对存量账户的有效性要通过chage命令去具体调整,两者配套使用才能覆盖全部账户。我在检测脚本里还会加一个循环,挨个检查所有非系统账户的密码过期信息:
for user in $(awk -F: '$3 >= 1000 || $3 == 0 {print $1}' /etc/passwd); do expiry_info=$(chage -l "$user" 2>/dev/null | grep "密码过期" ) echo "[账户] $user -> $expiry_info" done2.2 密码复杂度的静态分析与判定标准
有人说要检测密码复杂度,但Linux同时存在两套密码机制:旧式/etc/login.defs中的PASS_MIN_LEN,以及基于pam_pwquality模块的动态策略。前者在很多现代发行版中已经不再是唯一权威,后者才是实际强制检查复杂度的模块。
在Shell层面,我们能做的静态检测包括:
/etc/pam.d/system-auth或/etc/pam.d/common-password中是否启用了pam_pwquality.so模块pam_pwquality相关参数中的minlen、ucredit、lcredit、dcredit、ocredit这些大小写字母、数字、特殊字符的强制要求- 是否启用了
pam_unix.so的remember参数来控制历史密码重用次数
我写了一段检测pam配置的逻辑:
if grep -rE "pam_pwquality\.so" /etc/pam.d/ > /dev/null 2>&1; then echo "[检测] pam_pwquality 模块已启用" grep -rhE "pam_pwquality\.so" /etc/pam.d/ | grep -oE "minlen=[0-9]+|ucredit=-?[0-9]+|dcredit=-?[0-9]+|lcredit=-?[0-9]+|ocredit=-?[0-9]+" else echo "[警告] 未启用 pam_pwquality 模块,复杂度策略可能缺失" fi注意ucredit=-1这类负值的意思,它表示至少要包含1个大写字母;如果是正值则只是“鼓励包含”,不强制。很多配置里写的是ucredit=1,看着差不多,实际作用天差地远。检测结果出来后,我会根据参数情况打一个综合分:
- 90-100分:复杂度策略完整,长度不低于12位,各类字符都有强制要求
- 60-89分:基本可用,但某些维度放宽了要求
- 0-59分:基本等于裸奔,密码大概率是弱口令
这个评分逻辑是我自己调出来的,不一定严格对标等保标准,但至少能让使用者对当前状态有个直观理解。检测结果从来不应该是“非黑即白”,给一个可量化的参考值反而更容易推动后续整改。
2.3 特权账户与用户分组风险排查
密码强度检测如果只停留在策略层面,就漏掉了最重要的一块:特权账户管理。UID为0的账户拥有系统最高权限,这个集合里通常只有root一个,但如果某个系统中多了几个UID=0的账号,那等于系统里有几个“隐形管理员”,任何一个密码泄露都会导致整个系统沦陷。
Shell检测精细之处在于能直接对比passwd和shadow两个文件,找出只存在于其中一个文件的“幽灵账户”。具体思路是先把两个文件的用户名列表取出来,再用comm命令做交集和差集:
awk -F: '{print $1}' /etc/passwd | sort -u > /tmp/passwd_users.txt awk -F: '{print $1}' /etc/shadow | sort -u > /tmp/shadow_users.txt echo "=== 仅在 passwd 中出现的用户 ===" comm -23 /tmp/passwd_users.txt /tmp/shadow_users.txt echo "=== 仅在 shadow 中出现的用户 ===" comm -13 /tmp/passwd_users.txt /tmp/shadow_users.txt这种查法的原理是:正常情况下,每个可以利用密码登录的系统账户都应该同时在passwd和shadow中存在。如果passwd有但shadow没有,通常意味着该账户无法正常使用密码认证,可能是某种伪账户;如果shadow有但passwd没有,则可能是清理用户时残留的数据。两种情况都应该人工确认一遍。
用户分组方面,重点看/etc/group里哪些成员被加入了wheel组(CentOS/RHEL)或sudo组(Debian/Ubuntu)。这两个组默认拥有sudo执行权限,组内每个成员等于都是准管理员。我一般会跑这样一段:
group_list="wheel sudo" for grp in $group_list; do members=$(grep "^${grp}:" /etc/group | cut -d: -f4) if [ -z "$members" ]; then echo "[信息] $grp 组暂无额外成员" else echo "[检查] $grp 组成员: $members" fi done审核组里成员是否都是“该在的人”,是权限治理的基本功。很多时候系统被搞乱就是从多了个不该在sudo组里的人开始的。
3. 未授权访问检测的检测策略
未授权访问是个大类,我挑几个既高频又容易被忽略的点来展开。很多检测逻辑看起来简单,但真正组合起来跑一遍,能发现的惊喜(通常叫惊吓)不在少数。
3.1 SSH服务配置与密钥审查
SSH是服务器最常暴露的服务端口,水也最深。我写检测脚本时,第一步是确认SSH配置文件位置,因为不同发行版路径有差异,但常见的都是/etc/ssh/sshd_config。这个文件里有几个参数直接决定系统对外的安全姿态:
PermitRootLogin:是否允许root直接SSH登录,yes是极度危险的配置,建议改成no或prohibit-passwordPasswordAuthentication:是否允许密码认证,如果你有密钥登录机制,建议直接关掉密码登录Protocol:协议版本,老旧的v1协议因为算法强度问题已经不再被现代OpenSSH支持,但如果看到了相关配置,可以直接标记为严重漏洞AllowUsers和DenyUsers:白名单和黑名单配置,如果为空则等于所有人都能尝试登录MaxAuthTries:单次连接的最大认证尝试次数,默认是6,建议改小到3以内,暴力破解的尝试空间会显著缩小
我做了一个自动提取并转换为可读结果的函数:
sshd_config="/etc/ssh/sshd_config" if [ -f "$sshd_config" ]; then echo "=== SSH 关键安全配置 ===" grep -E "^(PermitRootLogin|PasswordAuthentication|Protocol|MaxAuthTries|AllowUsers|DenyUsers)" "$sshd_config" || echo "[警告] 以上关键配置项缺失,SSH可能使用默认值运行" fi这里虽然用了grep提取配置,但要注意grep在Shell脚本中的常见用法有一个大坑:grep -E和egrep不能混用,另外如果配置文件里同一项出现了多行(比如被条件Include外部配置),那么只查主配置会漏掉实际生效值。更严谨的做法是额外跑一下sshd -T 2>/dev/null来输出实际生效的配置,但考虑到sshd -T在某些老系统上不兼容,脚本里要做个双重判断,如果-T执行成功就以其输出为准。
密钥审查是另一个容易忽视的未授权访问源。~/.ssh/authorized_keys文件定义了哪些公钥可以免密登录,如果这个文件权限不对(比如其他用户可读、可写),那么你设再复杂的密码也没用,黑客直接往里面塞一把自己的公钥就能登进来。检测逻辑:
for home_dir in /home/*; do user=$(basename "$home_dir") auth_keys="$home_dir/.ssh/authorized_keys" if [ -f "$auth_keys" ]; then perms=$(stat -c "%a %U" "$auth_keys") echo "$user 的 authorized_keys 权限与属主: $perms" if [ "$(stat -c %U "$auth_keys")" != "$user" ] && [ "$(stat -c %U "$auth_keys")" != "root" ]; then echo "[高危] $user 的 authorized_keys 属主异常!" fi fi done3.2 SUID与SGID文件扫描
SUID是Linux里一个冷门但高风险的概念。简单解释:当一个可执行文件设置了SUID位,任何用户运行它时,该进程的有效用户ID会临时变成文件属主(通常是root)。这种机制本身是系统某些命令正常工作所必需的(比如/usr/bin/passwd需要临时提升权限来修改shadow文件),但如果有任何多余的普通文件也被赋予了SUID位,那就等于给普通用户发了一张临时root卡。
我用find命令做全盘扫描,这在Shell脚本中的使用频率非常高。核心命令:
find / -perm -4000 -type f 2>/dev/null echo "---SGID---" find / -perm -2000 -type f 2>/dev/null-perm -4000是包含SUID位,注意是“包含”而不是“恰好等于”,因为文件可能同时还有其他权限位。不加2>/dev/null的话,find在扫描/proc等虚拟目录时会输出大量权限拒绝提示,这倒不影响结果,但会淹没正常输出,属于Shell脚本中的常见坑之一。扫描结果出来后,拿它跟系统默认SUID清单做对比。每台机器默认的SUID文件通常就那么二三十个,如果发现冒出了一个奇怪路径下的SUID文件,基本可以断定有问题。
同样重要的还有SGID目录。目录设置了SGID位之后,新创建的文件会自动继承目录所属组,这在共享协作目录里是有意为之,但如果没有配合正确的组权限管理,也可能变成横向渗透的工具。所以我扫描后会特意列出非标准路径下的SGID项,单独提醒。
3.3 系统端口监听与开放状态检查
未授权访问不一定走SSH,其他服务如果监听了不安全的端口,又是另一条攻击面。在Shell层面,我用ss或netstat来做监听状态收集,以ss为主,因为它在现代系统中更高效,而且很多发行版已经不再自带netstat了。
ss -tulnp | awk 'NR>1 {print $5, $6}'这个命令输出的信息包括本地监听地址、端口、进程名和PID。检测逻辑里我重点标记三类情况:
- 监听地址是
0.0.0.0或::的端口,意味着对所有网卡开放,外部网络可达 - 常见管理端口(22、3306、6379等)直接暴露,没有限制来源IP
- 出现未知的高位端口,但又对应着标准系统服务的
端口检查的价值在于它能直接暴露“我的机器比我想象中多开了好多口子”。我有一次跑完这个命令,发现某台测试机竟然监听了一个FTP端口(21),一问才知道是半年前搭了个临时文件服务忘了关。这种安全问题,不通过扫描根本不会被人注意到。
4. 完整检测脚本的集成与运行
前几章都在讲单个检测逻辑,这一章把它们揉成一个可以直接跑的完整Shell脚本。动手之前有个设计问题要想清楚:是追求全面还是追求轻快?我的选择是兼顾,但默认参数偏向快速巡检,只有在加了--full参数时才做全盘扫描。
4.1 整体脚本结构与分段设计
脚本结构大体分四段:
- 环境检测段:确认操作系统类型、当前用户权限、检查所需命令是否存在
- 账户密码检测段:空密码、策略配置、特权账户、过期账户
- 访问控制检测段:SSH配置、authorized_keys、SUID/SGID、端口监听
- 报告输出段:把结果分类整理,输出为带时间戳的文本报告
环境检测段是一个经常被人遗忘的模块。脚本要兼容不同发行版,早期我在Debian系的机器上跑CentOS脚本,单是配置文件路径不同就够喝一壶。所以第一步先做这样的判断:
if [ -f /etc/redhat-release ]; then distro="RHEL/CentOS" elif [ -f /etc/debian_version ]; then distro="Debian/Ubuntu" elif [ -f /etc/alpine-release ]; then distro="Alpine" else distro="Unknown" fi echo "[信息] 当前系统类型: $distro"路径差异方面,Debian系的/etc/pam.d/common-password对应CentOS的/etc/pam.d/system-auth,login.defs都叫这个名字但内容基本一致,SSH相关的groups在CentOS有wheel,在Ubuntu是sudo。适配完这些差异,脚本才真正具备多平台可用性。
4.2 核心函数的实现与输出示例
把重复逻辑封装成函数是Shell脚本工程化的基本功。我定义了一个风险评估函数和一个输出格式化函数。风险评估函数接收一个数值型分数和一个风险等级,输出时带上颜色(如果终端支持的话)。在纯文本环境下则用[高危]、[中危]、[低危]这些醒目前缀代替。
一个典型的检测输出长这样:
=== 账户密码检测报告 === [高危] 用户 guest 存在空密码 [高危] UID=0 的非root账户: tester [中危] PASS_MAX_DAYS 未设置,密码永不过期 [中危] 用户 old_admin 密码将于3天后过期,请尽快处理 [信息] 已启用 pam_pwquality 复杂度策略,minlen=12 [信息] 未找到异常 SUID 文件报告中每条都带修复建议,这一点很重要。光告诉运维“你高危了”是不够的,他们都知道高危,缺的是下一步怎么办。比如空密码账户就直接给出passwd -l 用户名的锁定命令;SSH的RootLogin风险给出建议改成prohibit-password的具体方法。让检测报告变成一份可执行的整改清单,整个项目才有实用价值。
4.3 脚本运行方式与结果留存
脚本的头和尾我做了两个设计:
头部提供一个使用说明,并检查是否以root运行;尾部用tee把完整输出同时打印到屏幕和写入文件。文件名带时间戳,形如security_audit_20250614.log,方便以后追溯对比。我把核心调用逻辑放最后,方便阅读者按章节阅读各模块代码而不被整体执行流程干扰。
关于定时运行,借助cron可以很轻松实现每周一凌晨自动巡检并发送报告到指定邮箱。我一般是把脚本放在/usr/local/bin/security_audit.sh,然后加一条:
0 3 * * 1 /usr/local/bin/security_audit.sh > /var/log/security_audit.log 2>&1但要注意,cron调用常有一个环境变量问题:最小PATH可能不含/usr/local/bin或sbin下的命令路径,所以脚本里所有关键命令最好用绝对路径,或者在脚本开头统一export PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin。这个细节我踩过很多次,提醒大家不要忽略。
5. 脚本运行中的常见问题与避坑经验
这个部分是我最想分享的,因为其中很多坑是文档里不会写的。跑安全检测脚本不同于写个小工具,安全场景下误报比漏报更让人头疼,误报多了没人信,漏报了又等于白跑。
5.1 权限问题与版本兼容性
不以root身份运行脚本会触发大量权限拒绝错误,而且很多检查项根本没权限读文件。我建议脚本开头直接做UID检查,非root用户则打印提示并退出。但特别说明一下,有些检测项(比如端口监听)普通用户也能跑,在一些离线的环境里,运维可能没有sudo权限,这时可以做降级运行模式,只输出可读项,不强制中断。
另外发行版兼容性是我提过的一个大坑。ss命令在旧版CentOS 6上不存在(只有netstat),chage -l在某些精简系统上输出中文还是英文取决于locale。我写脚本时统一用LANG=C设置,保证输出是英文,否则中文环境下grep "密码过期"能匹配,但英文环境的机器匹配不上。跨环境运行前先用一个测试文件跑一遍,是最稳妥的做法。
还有一个隐患是系统自带命令的扩展性。很多生产系统里有安全加固软件(如某主机安全Agent)会在PATH中注入自己的同名命令,比如find、ls的别名或包装器。虽然这种情况不太常见,但也提醒了一句:如果脚本跑出来的结果跟预期严重不符,可以试试用/usr/bin/find这种绝对路径命令绕过别名。
5.2 误报处理与结果复核技巧
安全检测的目标不是追求数字好看,而是如实反映状态。对每一类检测结果,我都保留人工复核的建议操作,比如:
- SUID扫描结果,用
ls -lrt看文件修改时间,判断是否近期新增 - SSH配置检测结果,用
ssh -G实测当前主机实际生效参数 - 端口监听结果,用
lsof -iTCP:端口号精确确认进程路径
我坚持在报告中加一个“复核指引”字段,把每条告警对应的验证命令放在同一行,这样即使自动检测有误报,复核的人也可以快速判断真伪。这个设计看起来很朴素,但在实际操作环境里极大提升了报告的可信度。没这个字段的脚本,给人看的时候总要附带一堆解释,大家不信任;有了这个字段,报告本身就是一份可验证的文档。
5.3 检测脚本本身的安全加固
安全工具本身如果不安全,那才是最大的安全漏洞。这个项目里我还做了一个小加固:脚本文件权限设置成700,属主为root;脚本内部的临时文件统一写入/tmp下的随机目录,并在退出时用trap清理;部分命令行参数做了白名单校验,防止被注入特殊字符。
关于trap的用法:
TMPDIR_SAFE=$(mktemp -d /tmp/security_audit.XXXXXX) trap "rm -rf $TMPDIR_SAFE" EXIT这两行走位很关键,因为审计脚本会处理passwd、shadow这些敏感文件,留下的临时文件如果权限太松,等于把系统的影子文件复制了一份丢在/tmp里等人捡。我见过有审计脚本跑完不清理,直接把带有整个shadow内容的报告留在默认目录,权限还是644,这已经不能叫测漏洞了,你这是制造漏洞。
另外在调用外部命令时,最好对所有包含路径的参数做引号包裹,避免路径中出现空格或特殊字符导致的命令解析异常。虽然服务器路径一般不会那么妖,但find / -name扫到的文件名可能包含空格,不引号包裹的话百分百会在处理时炸锅。
6. 扩展应用与自动化安全巡检体系
单次检测拿到报告只是第一步,安全不是一锤子买卖。那这套Shell脚本体系还能往哪个方向发展呢?我结合实际项目说说扩展方向。
最常见的扩展是把它嵌到CICD流水线的发布环境检查环节。比如每次代码发布前,自动在目标测试机上跑一遍快速巡检,如果新增了高危项就直接拦截发布。这个模式在很多公司已经实际落地了。原理非常简单,就是流水线里加一个执行步骤:security_audit.sh --quick --json,产出JSON结构的结果,然后由流水线去解析关键字段并决定是否放行。
另一个方向是结合监控系统的主动采集。比如通过cron定时跑检测,在出现新的高危告警时用curl推送到企业微信或钉钉群。这样运维不用每天手动登录服务器看报告,系统会自动把风险事件推到负责人面前。推送脚本核心逻辑:
if [ "$high_risk_count" -gt "0" ]; then curl -s -X POST -H "Content-Type: application/json" \ -d "{\"content\":\"发现 $high_risk_count 个高危安全项,请立即登录 $HOSTNAME 查看\"}" \ "https://example.com/webhook-url" fi至于要不要做成一个无限循环的守护进程,我的建议是不要。Shell守护进程的稳定性难以保证,一旦有人误kill进程,反而影响运维判断。更合理的架构是保留被动检测思路,用cron把执行和结果推送做好,这已经能覆盖绝大多数应用场外景。真的需要实时监控的,就去上成熟的HIDS产品,不必在脚本层面硬扛。
从长期维护的角度,我也建议给脚本加上版本号和变更记录,任何安全工具的更新都应该有迹可循。这样每次检测报告出现变化时,能判断是系统配置变了,还是检测逻辑升级了,避免被假报警拖去加班。这个习惯养成了之后,你的安全运维日志会越来越可靠。
这套脚本从开始写到现在,我维护了大半年,每次在真实服务器上跑完,都会顺手补一个之前没考虑到的检测项。很多Shell安全检测的进阶逻辑,其实都源于一次次实际扫描后的复盘。希望这一章的记录也能帮你的排查工作省一些时间。