做运维这些年,我接过不少“服务器被入侵”的烂摊子。查到最后,十有八九是用户账号层面出了问题:离职员工的账号没清理、某个测试用户设了弱密码、系统账号居然能直接登录。今天这篇,我就用一次shell编程实战,聊聊怎么把系统用户安全审计这件事做成一个靠谱的脚本,把这些隐患一次性筛出来。这套思路不是教材里的示例,而是我实际用在多台Linux服务器上的审计逻辑。只要你有一台Linux机器、一点bash基础,照着写就能把账号家底盘清楚。适合刚接触shell的运维新手,也适合想把日常检查逻辑固化成工具的进阶同学。
1. 系统用户安全审计到底在查什么
1.1 高危账号:空密码、UID=0、异常Shell
先讲一个我遇到过的场景。某个客户反馈业务数据异常,应用层查了一圈没找到漏洞,最后翻登录日志才发现,凌晨有一台旧账号通过ssh登进服务器执行了定时任务。那个账号属于两年前离职的员工,当时只回收了应用权限,系统账号忘了删。就是这种不起眼的口子,最后变成了整个事故的入口。从那之后,我养成了一个习惯:每个季度固定对服务器做一次系统用户安全审计,把“不该存在”和“配置不规范”的账号全部筛出来。
Linux用户的账号信息主要放在三个文件里:/etc/passwd保存用户名、UID、GID、主目录和登录shell;/etc/shadow保存加密密码和密码策略;/etc/group保存组关系。审计时,用户分成两类:系统用户(UID小于1000)和普通用户(UID大于等于1000,具体阈值随发行版略有差异)。
第一类高危风险就是账号本身的漏洞。空密码账户是最严重的,相当于大门不设防,任何人拿到用户名就能登进来。判断依据是/etc/shadow中密码字段为空,正常应该是一长串以$6$或$5$开头的hash。第二是UID=0的账户,Linux的权限体系里UID为0就是超级用户,如果除了root之外还有别的账户是0,那基本意味着被人留了后门。第三是Shell异常,系统用户本来应该用nologin或false禁止登录,如果被改成/bin/bash,这个服务账号就有了交互登录的能力,风险极高。
1.2 权限过宽:主目录、sudo、SSH密钥
第二类风险属于权限配置问题,比账号本身更隐蔽。普通用户的主目录如果权限开成777,其他人就能随意读写这个用户目录下的文件,比如.ssh目录、脚本、数据文件都会被顺手牵羊。sudo授权范围过宽也很常见,某个低权限账号被加进sudo组或者sudoers文件里配置成了NOPASSWD,那基本等于给了第二个root。SSH密钥这一项容易被忽略,authorized_keys里多出来一个公钥,意味着某个人不用密码就能直接免密登录,这往往是外部人员留下的通道。
举个例子,很多团队习惯把公钥统一配置到部署账号下,方便自动发布,但人员变动后没有及时从authorized_keys里移除失效公钥。审计的时候就得把所有普通用户目录下的authorized_keys列出来,逐条确认里面每一把公钥的使用者是谁、是否还在职。
1.3 记录异常:登录日志与密码策略
第三类是运行痕迹。暴力破解攻击的痕迹会留在登录日志里,典型表现是同一IP短时间内出现大量Failed password记录。密码策略方面,很多服务器的shadow文件里max字段是99999,意味着密码永不过期,这种账户一旦密码泄露,可能几年都不会被发现。还有一类账户长期未登录但并未禁用,比如上一个季度审计时发现某账号已经有200天没活跃,这种账户要么联系使用者确认用途,要么直接锁定。
把这些问题归类之后,审计目标就很清晰了:用一套脚本自动化扫描以上所有维度。这也回答了为什么要做这件事:人工查,几十台机器根本查不过来,而且容易漏;用shell脚本,一次跑完,输出统一格式的报告,后续整改也有依据。
2. 审计脚本的模块拆分与兼容性设计
2.1 风险分级与输出方式
写脚本之前,先定风险等级和报告格式。我把审计结果分成三档:高危、中危、低危。高危包括空密码、非root的UID0账户、其他用户可写主目录;中危包括系统用户使用非nologin shell、密码永不过期、未知SSH密钥;低危包括长期未登录账号、主目录不存在等。
输出方式上,我坚持用颜色分级加文件落地。终端直接输出红、黄、绿颜色,一眼就能看到重点;同时用tee把全部内容追加到报告文件,方便事后追溯和邮件转发。这里有个细节:报告路径带上日期时间,比如/tmp/user_audit_2025-01-15_14-30-00.log,避免多次执行时互相覆盖。
2.2 函数化的“单一职责”
脚本主体不写成一条长命令,而是按审计点拆成不同的函数。每个函数只做一件事,比如audit_empty_password只处理空密码检测,audit_uid_zero只处理超级用户检测,main函数按顺序调用所有模块。这样做的好处很明显:某一个模块执行出错不会拖垮其他模块,后期要加新的审计项,只需要再写一个函数然后在main里加一行,不用动主逻辑。
这个思路其实就是shell版的“单一职责原则”。我在项目里还要求每个函数开头的分隔线输出统一格式,报告读起来才不像一团乱麻。
2.3 不同发行版的差异处理
在实际工作中,审计脚本要同时兼容CentOS、Ubuntu、Debian这些主流发行版。它们之间的差异主要集中在三处。第一是登录日志路径不一样,CentOS的认证日志在/var/log/secure,而Ubuntu在/var/log/auth.log,脚本里要做一个优先级判断,哪个存在就用哪个。第二是sudo管理组不一样,有的用wheel组,有的用sudo组,要同时检查。第三是shell路径有差异,有些系统的nologin在/usr/sbin/nologin,有些在/sbin/nologin。
所以我在脚本里用变量和条件判断来兼容环境差异,不硬编码发行版名称。这样同一份脚本推到不同系统都能跑,维护成本低得多。
3. 核心检测函数实现与逐行解析
3.1 初始化与输出框架
所有脚本的第一步是定义环境变量和输出函数。这里有几个关键点:必须检查是否以root运行,因为/etc/shadow只有root能读,普通用户执行会直接报错;日志文件路径要带日期时间;输出函数统一带上颜色并同时写文件。
#!/usr/bin/env bash # 系统用户安全审计脚本 # 适用环境: Linux (CentOS/RHEL/Ubuntu/Debian) # 运行权限: root AUDIT_DATE=$(date +%F) AUDIT_TIME=$(date +%T) HOSTNAME=$(hostname) REPORT="/tmp/user_audit_${AUDIT_DATE}_${AUDIT_TIME}.log" C_RED='\033[0;31m' C_YELLOW='\033[0;33m' C_GREEN='\033[0;32m' C_NONE='\033[0m' if [ "$(id -u)" -ne 0 ]; then echo "请使用root或sudo运行此脚本" exit 1 fi rm -f "$REPORT" log_info() { echo -e "${C_GREEN}[INFO]${C_NONE} $*" | tee -a "$REPORT"; } log_warn() { echo -e "${C_YELLOW}[WARN]${C_NONE} $*" | tee -a "$REPORT"; } log_error() { echo -e "${C_RED}[ERROR]${C_NONE} $*" | tee -a "$REPORT"; } separator() { echo "========================================" | tee -a "$REPORT"; }脚本开头清空报告文件很关键,避免上一次运行的结果混进来。颜色定义用的是ANSI转义序列,在终端里显示红绿黄,写到文件时颜色码也会被tee写进去,不过问题不大,毕竟日志文件主要用来检索,不是给别人看的。
3.2 账号盘点与空密码检测
账号总览是审计的开胃菜,把/etc/passwd里的用户名、UID、主目录、shell列出来。用awk处理passwd文件非常顺手,字段分隔符是冒号,直接按序号取值。
audit_account_overview() { separator log_info "1. 账号总览" awk -F: 'BEGIN {printf "%-12s %-8s %-25s %-20s\n", "用户名", "UID", "主目录", "Shell"} !/^#/ {printf "%-12s %-8s %-25s %-20s\n", $1, $3, $6, $7}' /etc/passwd | tee -a "$REPORT" }空密码检测是整套审计里优先级最高的项目。密码字段在shadow文件里,格式是用户名:密码:上次修改时间:最短天数:最长天数:提醒天数:宽限天数:过期天数。正常账户的密码字段是一串hash,以$开头。如果字段完全为空,说明这个用户没有密码,可以直接登录。
audit_empty_password() { separator log_info "2. 空密码账户检测" local empty_users=0 while IFS=: read -r user pass rest; do if [ -z "$pass" ]; then log_error "发现空密码账户: $user" empty_users=$((empty_users+1)) fi done < /etc/shadow if [ "$empty_users" -eq 0 ]; then log_info "未发现空密码账户" fi }这里有个shell编程细节:while循环后面的< /etc/shadow是重定向到循环整体,不是重定向到read命令,这样变量可以在循环内正常更新。如果写成管道形式cat /etc/shadow | while read ...,循环体会在子shell里执行,循环外部的计数器变量就更新不了了。这个坑我踩过不止一次,在第五部分还会专门展开。
3.3 超级用户与Shell合规性检查
UID=0检测负责揪出隐藏的超级用户。遍历passwd文件,检查第三列是不是0。正常只有root一个,发现其他账户时要立刻标红。
audit_uid_zero() { separator log_info "3. UID=0 账户检测" while IFS=: read -r user pass uid gid comment home shell; do if [ "$uid" -eq 0 ]; then if [ "$user" != "root" ]; then log_error "发现非root的UID0账户: $user" else log_info "root账户正常" fi fi done < /etc/passwd }Shell合规性检查分成两条规则。系统用户(UID小于1000)不应该有交互式shell,正常情况是/sbin/nologin或/bin/false,发现其他shell就报警。普通用户的shell则应该在/etc/shells里,这个文件记录了系统中所有合法的登录shell。
audit_user_shell() { separator log_info "4. 用户登录Shell合规性检查" while IFS=: read -r user pass uid gid comment home shell; do if [ "$uid" -lt 1000 ]; then case "$shell" in *nologin|*/false) ;; *) log_warn "系统用户 $user 使用了非nologin shell: $shell" ;; esac else if ! grep -Fxq "$shell" /etc/shells 2>/dev/null; then log_warn "普通用户 $user 的shell不在/etc/shells中: $shell" fi fi done < /etc/passwd }注意case匹配里我用的是*nologin和*/false,这样可以同时覆盖/sbin/nologin和/usr/sbin/nologin这类路径差异,也兼容/bin/false。这种通配符写法比逐一列举路径省事得多。
3.4 主目录与sudo权限检测
主目录权限检测针对普通用户。思路是先取home路径判断目录是否存在,再用stat取权限数字。Linux权限数字是三位八进制,个位代表other用户权限,2表示写权限,所以只要对10取余的结果大于等于2,就说明其他用户能写这个目录,这是高危风险。
audit_home_dir_perm() { separator log_info "5. 普通用户主目录权限检测" while IFS=: read -r user pass uid gid comment home shell; do [ "$uid" -lt 1000 ] && continue if [ ! -d "$home" ]; then log_warn "用户 $user 主目录不存在: $home" continue fi local perm perm=$(stat -c "%a" "$home") local other_perm=$((perm % 10)) if [ "$other_perm" -ge 2 ]; then log_error "主目录 $home 其他用户可写 (权限: $perm)" elif [ "$((perm / 10 % 10))" -ge 5 ]; then log_warn "主目录 $home 组用户权限偏高 (权限: $perm)" fi done < /etc/passwd }sudo权限检查要兼顾两种配置来源:/etc/sudoers文件和/etc/sudoers.d目录。常规做法是grep出sudoers里以用户名开头的授权行,然后检查sudo组和wheel组的成员。
audit_sudo_privilege() { separator log_info "6. sudo权限账户检查" grep -rhE "^[a-zA-Z0-9_-]+\s+ALL=" /etc/sudoers /etc/sudoers.d/ 2>/dev/null \ | awk '{print $1}' | sort -u | while read user; do log_warn "sudoers授权用户: $user" done for group in sudo wheel; do members=$(grep "^$group:" /etc/group 2>/dev/null | cut -d: -f4) if [ -n "$members" ]; then log_warn "$group 组成员: $members" fi done }需要说明的是,sudoers里的配置非常灵活,支持用户别名、组前缀、Cmnd_Alias等语法,简单的grep会有误报漏报。这个函数定位是“初步筛查”,把直接写了ALL=的用户先列出来,真正严谨的授权变更仍然需要管理员逐条确认。
3.5 登录日志与SSH密钥审计
登录日志审计的目标是找到暴力破解指纹。第一步确定日志路径,CentOS优先用/var/log/secure,Ubuntu用/var/log/auth.log,哪个存在用哪个。第二步用last命令查看最近成功登录,第三步用grep统计Failed password的源IP。
audit_login_log() { separator log_info "7. 登录日志审计" local logfile="/var/log/secure" [ -f "$logfile" ] || logfile="/var/log/auth.log" if [ ! -f "$logfile" ]; then log_warn "未找到登录日志文件" return fi log_info "最近5条成功登录记录" last -n 5 2>/dev/null | tee -a "$REPORT" log_info "失败登录次数TOP10源IP" grep "Failed password" "$logfile" 2>/dev/null \ | grep -oE "[0-9]{1,3}(\.[0-9]{1,3}){3}" \ | sort | uniq -c | sort -rn | head -10 \ | tee -a "$REPORT" }这里的grep -oE提取IP的正则只匹配IPv4地址,不会误伤端口号。auth.log里如果遇到IPv6地址,这个正则抓不到,但绝大多数办公网的攻击源都是IPv4,够用了。
SSH密钥审计遍历普通用户主目录,检查.ssh/authorized_keys是否存在,存在就把内容原样输出。审计人员需要逐条确认每个公钥的归属。
audit_ssh_keys() { separator log_info "8. SSH authorized_keys检查" while IFS=: read -r user pass uid gid comment home shell; do [ "$uid" -lt 1000 ] && continue local ak="$home/.ssh/authorized_keys" if [ -f "$ak" ]; then log_info "用户 $user 存在SSH授权密钥:" cat "$ak" | tee -a "$REPORT" fi done < /etc/passwd }密码策略这块要理解shadow字段含义。用max字段判断密码是否永不过期,99999就表示基本永久有效,应该标记为中危风险。第二个判断是lastchg字段为空,说明这个用户从未改过初始密码,典型场景是新建账号后忘记设置密码策略。
audit_password_policy() { separator log_info "9. 密码过期策略检查" while IFS=: read -r user pass lastchg min max warn inactive expire; do case "$pass" in \!*|*) continue ;; esac if [ "$max" -eq 99999 ] || [ -z "$max" ]; then log_warn "用户 $user 密码永不过期 (max=$max)" fi if [ -z "$lastchg" ]; then log_warn "用户 $user 从未修改过初始密码" fi done < /etc/shadow }锁定账户的密码字段通常以!或*开头,这类账户本身不能登录,密码策略对它们没有意义,直接跳过,避免误报。
4. 完整脚本、运行效果与定时落地
4.1 完整可运行脚本
把上面所有函数拼起来,加上main入口,就是一个可以直接运行的审计脚本。main函数按顺序调用,每个模块输出分隔线,最后打印报告保存路径。完整结构如下:
main() { audit_account_overview audit_empty_password audit_uid_zero audit_user_shell audit_home_dir_perm audit_sudo_privilege audit_login_log audit_ssh_keys audit_password_policy separator log_info "审计完成,报告已保存: $REPORT" } main "$@"我给脚本加执行权限后,用root身份跑一遍。在测试环境里执行sudo ./user_audit.sh,输出节选类似下面这样:
[INFO] 1. 账号总览 demo UID 主目录 Shell root 0 /root /bin/bash daemon 1 /usr/sbin /usr/sbin/nologin testdev 1001 /home/testdev /bin/bash [INFO] 2. 空密码账户检测 [INFO] 未发现空密码账户 [WARN] 系统用户 mysql 使用了非nologin shell: /bin/bash [ERROR] 主目录 /home/testdev 其他用户可写 (权限: 777) [WARN] sudoers授权用户: deployuser [INFO] 失败登录次数TOP10源IP 198 192.0.2.37 56 203.0.113.9注意上面输出是模拟示例,实际内容取决于机器上的用户配置。重点是你能从这份报告里迅速定位最危险的问题:一个非nologin的mysql系统账号,一个777的主目录,一个sudoers授权用户,以及一堆可疑的失败登录源IP。
4.2 运行效果与报告解读
拿到报告后怎么解读?我的处理优先级是这样的:先处理红色项,空密码和UID0账户要立即修复,前者用passwd命令设置密码或锁账户,后者确认是否后门,直接usermod -L或者删除异常账户。然后是黄色项,系统账号的shell改回nologin,主目录权限收敛到755或750,sudo授权逐个确认。最后是绿色项,密码策略调整和SSH密钥清理可以放到月度维护窗口统一处理。
报告文件里的颜色码在纯文本编辑里会显得杂乱,可以用正则把它剥掉再通过邮件发送。例如sed -r "s/\x1B\[[0-9;]*[mK]//g" "$REPORT",这样生成的纯文本报告更适合归档和做后续统计。
4.3 定时任务与报告归档
审计脚本最常见的落地方式是crontab定时执行。我习惯每天凌晨2点跑一次,避开业务高峰期。cron里执行shell脚本要注意PATH问题,脚本内如果用到绝对路径的命令,不受影响,但建议在脚本开头显式声明export PATH=/usr/local/sbin:/usr/local/bin:/sbin:/bin:/usr/sbin:/usr/bin。
报告目录单独划分,比如/var/log/user_audit/。可以顺手加一个find定期清理,只保留90天内的报告,避免日志无限堆积:
find /var/log/user_audit/ -name "user_audit_*.log" -mtime +90 -delete将这个清理命令放到cron的每周固定任务里,和审计任务放在一起,循环执行,基本就不用管了。
5. 踩坑总结与常见问题速查
5.1 高频报错与排查思路
先说几个我在实际运行中遇到过的问题,做成速查表方便对照。
| 现象 | 原因 | 解决办法 |
|---|---|---|
| 空密码检测没有输出 | /etc/shadow无权限读取 | 确认使用root或sudo执行 |
| lastb命令提示找不到btmp | 系统没启用btmp记录或已轮转 | 用lastb -f /var/log/btmp.1读取历史 |
| 报告文件里颜色码很乱 | tee把ANSI转义序列写进了文件 | 用sed去掉颜色码后再归档 |
| sudoers检测报错 | sudoers文件含非法语法 | 先visudo -c校验语法再跑脚本 |
| 普通用户shell误报 | 自定义shell没写入/etc/shells | 确认该shell是否合法,补写或忽略 |
| 日志文件不在默认路径 | 发行版差异或自定义配置 | 手动指定logfile或增加rsyslog配置检查 |
最坑的一次是在某台机器上,空密码检测一直报0,排查半天发现脚本被非root用户执行了,而/etc/shadow的权限是600,普通用户根本读不到。从那以后我把root权限检查放在了脚本最前面,不给运行者任何误解的空间。
5.2 误报与漏报的取舍
审计脚本跟安全平台一样,逃不过误报和漏报的权衡。空密码检测是硬性指标,不存在误报。但系统用户shell检查很容易出现虚惊:某些发行版把mysql用户的shell设置成/bin/false,恰好不在我case匹配范围里,于是报了警。后来我把所有非登录shell路径统一用通配符匹配,比如*nologin、*/false、/bin/true,误报率才降下来。
反过来,漏报最常出现在sudo授权检查上。sudoers里允许写%wheel这样的组授权,我的grep只抓用户直接授权,所以组内的成员通过组授权获得sudo权限时,脚本可能没抓到。我的处理方式是同时检查wheel和sudo组的成员列表,把两个维度结合起来看,基本能覆盖主流场景。
5.3 自动化之前的最后检查
最后分享几条安全底线。第一,审计脚本只负责报告,不自动修复。不要在脚本里直接删用户、改密码或者改权限,这些动作一旦自动化出错,后果比安全问题本身还严重。所有整改动作都应该由人工确认后单独执行。第二,脚本上线前先在测试环境跑一遍,记录基线输出,再到生产环境执行,否则你可能被大量历史遗留问题淹没。第三,配合新服务器初始化流程,把审计脚本纳入装机脚本,让每一台新机器上线第一天就留一份安全基线报告。
另外,对于长期未登录账号,我习惯在审计脚本后面接一个低危检查,用last -1统计每个用户的最后登录时间,超过90天就报警。这个功能我在实际项目里加过,效果不错,推荐有需要的人自行扩展:
audit_inactive_users() { separator log_info "10. 长期未登录账户检查" local threshold_days=90 while IFS=: read -r user pass uid gid comment home shell; do [ "$uid" -lt 1000 ] && continue local last_login last_login=$(last -1 "$user" 2>/dev/null | awk 'NR==1 {print $4, $5, $6, $7, $8}') log_warn "用户 $user 最近登录: ${last_login:-从未登录}" done < /etc/passwd }我个人的体会是,审计脚本的技术含量并不高,真正难的是让它变成运维习惯。项目里我把它和服务器初始化流程绑在一起,新机器上线自动跑一遍,平时靠cron每天巡检,节假日再手动抽查核心服务器。这种自动化加人工抽查的双层节奏,比单次做一次深度审计要实在得多。最后再提一个小技巧:报告里发现的问题建议顺手丢进一个ticketing系统或者直接发到告警群,让整改有跟踪,不然审计完就完事了,下一个季度还是同样的问题。