news 2026/10/17 7:13:09

Linux用户安全审计Shell脚本实战,揪出服务器高危账号

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Linux用户安全审计Shell脚本实战,揪出服务器高危账号

做运维这些年,我接过不少“服务器被入侵”的烂摊子。查到最后,十有八九是用户账号层面出了问题:离职员工的账号没清理、某个测试用户设了弱密码、系统账号居然能直接登录。今天这篇,我就用一次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系统或者直接发到告警群,让整改有跟踪,不然审计完就完事了,下一个季度还是同样的问题。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/17 7:13:08

D2D信道仿真MATLAB实战:从路损建模到SINR计算与资源分配接口

简介&#xff1a;这份资源面向无线通信方向的学生与研究人员&#xff0c;聚焦 Device-to-Device 通信中的信道仿真与频谱资源分配问题&#xff0c;适合具备一定 MATLAB 基础、希望深入理解 D2D 系统建模与启发式算法实现的读者。压缩包共 22 个文件&#xff0c;以 20 个 .m 脚本…

作者头像 李华
网站建设 2026/10/17 7:12:56

微信直连AI编程助手QClaw实测:从对话写代码到代码仓库问答

这几天技术圈里最热的一件事&#xff0c;就是腾讯 AI 编程助手 QClaw&#xff08;内部代号“小龙虾”&#xff09;的内测消息。说实话&#xff0c;最开始我以为又是什么 IDE 插件或者命令行工具换了个名字&#xff0c;直到看到那句“微信直连”我才来了兴趣——用微信对话框直接…

作者头像 李华
网站建设 2026/10/17 7:11:46

Git合并模式:Fast-Forward与--no-ff的抉择及回滚陷阱

我记得很清楚&#xff0c;某个周五的下午&#xff0c;我在一个仓库里把做了将近两周的大功能分支往主干上合。命令敲下去&#xff0c;Git 只回了一行字&#xff1a;Fast-forward当时我还觉得挺顺利&#xff0c;没多想就推上去了。结果两天后另一位同事在 code review 的时候问我…

作者头像 李华
网站建设 2026/10/17 7:11:36

基于YOLOv8的超市商品识别检测系统:从数据集构建到UI部署全流程拆解

之前拿COCO预训练模型直接跑超市货架识别&#xff0c;效果其实有点惨。通用模型能告诉你这里有“瓶子”有“盒子”&#xff0c;但具体到“这瓶是可乐还是无糖可乐”、“这包薯片是原味还是青柠味”&#xff0c;它就完全懵了。而超市商品识别系统的价值恰恰就在这个粒度上——它…

作者头像 李华
网站建设 2026/10/17 7:11:34

YOLOv11医学影像脑瘤检测识别系统:从训练到部署全流程解析

“毕设开源 yolov11医学影像脑瘤检测识别系统”——看到这个题目&#xff0c;心里其实是有点复杂的。一方面&#xff0c;医学影像AI是个大方向&#xff0c;毕设选这个题有辨识度&#xff0c;答辩时也好讲故事&#xff1b;另一方面&#xff0c;很多同学拿到题之后第一反应是“从…

作者头像 李华
网站建设 2026/10/17 7:10:18

AI GPU驱动开发入门:UMD的定义、定位与职责边界

1. 一段让我重新审视UMD的排查经历如果你做过AI GPU驱动相关的开发&#xff0c;大概率遇到过这种场景&#xff1a;某个模型明明在训练框架层看起来很健康&#xff0c;跑起来却总是崩溃或者莫名其妙慢半拍。刚开始接触的人会习惯性怀疑是内核态驱动出了问题&#xff0c;于是盯着…

作者头像 李华