服务器提权路径审计实战:SUID 排查、sudoers 白名单陷阱与 chattr 不可变加固

为什么「普通用户拿到 root」往往不靠漏洞靠配置

网站被入侵后最常见的疑问是「我明明打了补丁,他们怎么拿到 root 的」。真实答案大多不是内核提权漏洞,而是你自己留下的合法提权路径:一个属主是 root 且带 SUID 位的脚本、一个 NOPASSWD 的 sudo 条目、一个全局可写却被 root 定期执行的目录、一个丢失了 root 属主检查的 systemd 服务。这些东西在正常运维中完全必要,但它们同时是攻击者的高速公路。

攻击链通常是这样的:网站有个上传漏洞或者弱口令,攻击者拿到 www-data 权限。接着在机器上翻找提权路径——这一步是自动化的,LinPEAS 这类脚本几秒钟就能列出所有 SUID 文件、可写的 cron 脚本、sudo 配置。找到一条路,执行,拿到 root,然后植入持久化后门。整个过程可能不到五分钟。

所以「入侵后加固」这件事的优先级不是「装个杀毒软件」,而是审计并收紧所有提权路径。本文讲三件事:怎么找出机器上所有的提权点、怎么判断哪些是必要的哪些是隐患、怎么用文件属性和权限把危险路径封死。所有操作都是只读排查在前、变更在后,不需要装额外的安全套件。

第一件事:把所有 SUID/SGID 文件列出来

SUID(Set User ID)位让程序以文件属主的身份运行,而不是执行者的身份。如果 /usr/bin/passwd 属主是 root 且带 SUID,普通用户执行它时就有 root 权限——这是它修改 /etc/shadow 所必需的。合理。但如果一个自定义脚本也带了 SUID 且属主是 root,那就是一个无门槛的 root shell。

# 全盘找 SUID 文件(-4000 指 SUID 位,-perm 精确匹配)
find / -xdev -type f -perm -4000 -ls 2>/dev/null | sort -k11

# 找 SGID 文件(-2000)
find / -xdev -type f -perm -2000 -ls 2>/dev/null | sort -k11

# 加上 SGID 目录(-perm -2000 且是目录)
find / -xdev -type d -perm -2000 -ls 2>/dev/null

# 不跨文件系统(-xdev)是关键,否则会扫进 /proc、/sys、挂载的备份盘
# 如果有多个文件系统要扫,分别指定路径
find /var /srv /opt -type f -perm -4000 -ls 2>/dev/null

输出要按文件路径看,重点盯这几类:

  • 非标准路径的 SUID 文件。系统自带的 SUID 程序都在 /usr/bin、/usr/sbin、/bin、/sbin 下,属主是 root 且属于 root 组。如果发现 SUID 文件在 /tmp、/home、/var/www、/opt 下,或者属主是个普通用户,基本可以确定是人为留下的(恶意的或者前人图省事留下的)。
  • 编译语言的解释器。如果 find 结果里出现 python3、perl、ruby、node、php、bash、sh、vim、less、more、find、tar、cp、nano 这类程序带了 SUID,这是致命的。任何一个都能一行命令拿到 root shell,比如 python3 -c 'import os; os.setuid(0); os.system("/bin/bash")'。系统默认绝对不会给它们 SUID,出现就是有人手动加的。
  • 自研的 shell 脚本。注意:Linux 内核会忽略 shell 脚本上的 SUID 位,脚本永远以执行者身份运行。所以看到一个「SUID 但没用」的脚本不是无害的,而是有人试图这么干但失败了,说明这台机器上存在「想用脚本提权」的思路,很可能附近还有别的路径(比如配了 sudo 条目)。看到这种情况要顺着查 sudoers。

发现可疑的,先记录再处理。查一个 SUID 文件的元信息:

# 完整信息:属主、属组、权限位(s 就是 SUID)
stat /usr/bin/suspicious-binary
# 权限字符串解读:-rwsr-xr-x 里的 s(而不是 x)表示 SUID 生效
# 如果是 -rwSr-xr-x(大写 S),说明 SUID 位设了但没有执行权限,是无效的

# 看它是哪个包装的,不属于任何包的高度可疑
dpkg -S /usr/bin/suspicious-binary 2>/dev/null || echo "不属于任何软件包"
rpm -qf /usr/bin/suspicious-binary 2>/dev/null || echo "不属于任何软件包"

# 看创建/修改时间,和你的运维操作对得上吗
ls -la --time-style=full-iso /usr/bin/suspicious-binary

# 看它有没有被替换过(和包里的原始文件对比)
dpkg -V coreutils        # 校验 coreutils 包的所有文件
debsums -c               # 需要 debsums 包,全系统校验

dpkg -S 返回「不属于任何软件包」且文件在系统目录下,是一个强信号——正常的系统文件都归属某个包。这是区分「系统自带」和「后期添加」最快的方法。

第二件事:sudo 配置里的 NOPASSWD 白名单

网站运维为了方便,常常给 www-data 或者运维账号配一些免密命令,比如「重启服务」「清理日志」。问题在于,sudo 的白名单是按命令字面匹配的,很多人写着写着就把一个能执行任意命令的程序放进了白名单。

# 列出当前用户可以执行哪些 sudo 命令
sudo -l

# 以另一个用户身份查看
sudo -l -U www-data

# 直接看配置文件(必须用 visudo 编辑,别用 vim 直接改)
cat /etc/sudoers
ls -la /etc/sudoers.d/
cat /etc/sudoers.d/*

哪些条目是危险的,看这三类:

# ❌ 绝对禁止:通配符当成了安全带
www-data ALL=(ALL) NOPASSWD: /bin/systemctl restart nginx*
# 问题:* 会匹配 "restart nginx; /bin/bash" 这种吗?不会,但会匹配
#       "nginx.service --whatever",而 systemctl 有大量参数可以加载任意 unit
#       更糟的写法是 /bin/systemctl * —— 那等于完全 root

# ❌ 危险:允许编辑器
deploy ALL=(ALL) NOPASSWD: /usr/bin/vim, /usr/bin/less, /usr/bin/find
# 这三个每一个都能逃逸到 shell:
#   vim  → :!bash
#   less → !bash
#   find → find . -exec bash \;

# ❌ 危险:允许解释器和脚本
app ALL=(ALL) NOPASSWD: /usr/bin/python3 /opt/scripts/cleanup.py
# 问题:/opt/scripts/cleanup.py 如果全局可写,攻击者改它就能以 root 执行任意代码
#       而且 python3 的参数没有限制,可以用 -c 直接执行代码

# ❌ 危险:允许 tar / rsync / cp / chmod / chown 这类工具
#   它们都能通过参数实现任意文件读写或执行,等价于 root

# ✅ 相对安全的写法
www-data ALL=(root) NOPASSWD: /bin/systemctl reload nginx.service, /bin/systemctl restart php8.2-fpm.service
# 要点:命令写全路径、写完整参数、unit 名写死不用通配符、不用 shell=True 类的包装

还有一个更隐蔽的坑:sudoers 里用 ALL 匹配命令的路径。比如 app ALL=(root) NOPASSWD: /usr/bin/rsync,看起来只是允许同步文件,但 rsync 支持 -e 参数指定远程 shell:sudo rsync -e 'bash -c "bash -i"' ... 就能拿 shell。类似的「传参数就能执行命令」的程序有一长串:tar、zip、git、apt、dpkg、awk、sed、man、pip、docker(这个直接挂载宿主机根目录就够了)、env、nice、timeout、xargs、gdb。GTFOBins 这个网站专门收录这类程序,审计时逐条对照。

最危险的一类,也是实际入侵中最常见的:允许执行一个「全局可写」或「属主非 root」的脚本。app ALL=(root) NOPASSWD: /opt/deploy.sh 这条本身没问题,但如果你 ls -la /opt/deploy.sh 发现权限是 -rwxrwxrwx 或者属主是 app,那么任何人改一下这个脚本的内容,下次 sudo 执行时就是任意 root 代码执行。

# 检查 sudoers 里引用的所有脚本的安全性
grep -rhoE '/[a-zA-Z0-9._/-]+\.(sh|py|pl)' /etc/sudoers /etc/sudoers.d/ | sort -u | while read -r f; do
    if [ -e "$f" ]; then
        stat -c '%A %U:%G %n' "$f"
    fi
done
# 任何一行显示属主不是 root、或者组/其他用户有写权限(w 出现在第 6 或第 9 位),都是漏洞

正确做法是把脚本的权限收到 0755 且属主 root:root,最好连写权限都不给组和其他用户。改法:

chown root:root /opt/deploy.sh
chmod 755 /opt/deploy.sh
# 更严:只有 root 能读,但 sudo 执行时以 root 身份没问题
# 注意:如果 sudo 条目是 (www-data) 而不是 (root),脚本至少要给 www-data 可读+可执行
chmod 700 /opt/deploy.sh

第三件事:计划任务与开机自启里的可写脚本

cron 和 systemd timer 是另一个高发区。原理很简单:如果 root 的定时任务会执行一个普通用户能改的文件,那么改掉这个文件就等于拿到 root。这类问题比 SUID 更常见,因为「用 cron 跑个备份脚本」是最自然的运维习惯。

# 看所有用户的 crontab
for u in $(cut -d: -f1 /etc/passwd); do
    c=$(crontab -u "$u" -l 2>/dev/null)
    [ -n "$c" ] && echo "=== $u ===" && echo "$c"
done

# 系统级 cron
cat /etc/crontab
ls -la /etc/cron.d/ /etc/cron.hourly/ /etc/cron.daily/ /etc/cron.weekly/ /etc/cron.monthly/
cat /etc/cron.d/*

# systemd timer(现代发行版的等价物)
systemctl list-timers --all
# 看 timer 对应的 service 里的 ExecStart 指向哪个脚本
systemctl cat backup.timer backup.service

把 cron/timer 里提到的每个脚本路径拿出来,逐个检查权限和属主,方法和上面一样。特别要检查脚本所在目录的权限——如果脚本本身是 755 root:root 很安全,但它的父目录是 777,攻击者可以删掉原文件、新建一个同名文件(前提是目录可写),一样能劫持。目录权限检查要连同所有祖先目录一起看:

# 检查一个路径上所有目录的权限,任何一层全局可写都是隐患
p=/opt/scripts/backup.sh
while [ "$p" != "/" ]; do
    p=$(dirname "$p")
    stat -c '%A %U:%G %n' "$p"
done

# 一步到位:找出系统上所有全局可写的目录(world-writable)
find / -xdev -type d -perm -0002 -ls 2>/dev/null | \
  grep -vE '^.*/(tmp|var/tmp|dev/shm|proc|sys)$'
# /tmp 和 /var/tmp 全局可写是正常的(它们有 sticky 位保护)
# 其他目录全局可写就要问一句为什么

关于 /tmp:它全局可写是设计如此,靠 sticky 位(-rwxrwxrwt 最后那个 t)保证安全——sticky 位让用户只能删除自己的文件,不能删别人的。检查它是否还在:

stat -c '%A %n' /tmp /var/tmp /dev/shm
# 必须是 drwxrwxrwt(或 drwxrwxrwt+ 带 ACL),
# 如果显示 drwxrwxrwx(没有 t),任何人都能删别人的文件、替换正在运行的程序,立刻修:
chmod +t /tmp /var/tmp

用 chattr 和 lsattr 做不可变加固

收紧权限之后,还有一层防线:把关键文件设成「不可变」(immutable),这样即使攻击者拿到了 root,也不能修改或删除它们,除非他知道要先 chattr -i。这不是绝对防护(root 能撤掉这个属性),但它能显著提高攻击成本,并且会留下痕迹——很多自动化工具直接对 chattr 失败视而不见,或者根本没试。

# 查看文件属性(i = immutable 不可变,a = append-only 只能追加)
lsattr /etc/passwd /etc/ssh/sshd_config /etc/crontab
# 输出形如:----i---------e------- /etc/passwd
# 最后的 e 表示 extent 格式(ext4 默认,正常),i 是我们要加的

# 给关键配置文件加不可变
chattr +i /etc/passwd /etc/shadow /etc/group /etc/gshadow
chattr +i /etc/ssh/sshd_config
chattr +i /etc/sudoers
chattr +i /root/.ssh/authorized_keys

# 给日志文件设「只能追加」——防止攻击者删掉入侵痕迹
# ⚠️ 注意:set a 之后日志文件不能被轮转(rename 或删除都会失败)
# 所以只对不参与 logrotate 的关键审计日志用
chattr +a /var/log/auth-audit.log

几个必须知道的坑。给 /etc/shadow 加 +i 之后,passwd 命令改密码会失败(报 cannot lock /etc/shadow),必须先 chattr -i /etc/shadow、改完再加回来。同理 /etc/passwd、/etc/sudoers 在需要添加用户或改 sudo 配置时都要先解锁。建议写个包装脚本,避免忘记:

#!/bin/bash
# /usr/local/bin/safe-edit —— 临时解锁、执行编辑、重新上锁
# 用法:safe-edit /etc/shadow vipw
target="$1"; shift
chattr -i "$target"
"$@"                     # 执行传入的命令
rc=$?
chattr +i "$target"
echo "$target relocked (rc=$rc)"
exit $rc

另一个坑:chattr +i 在容器里、在 overlayfs 上、在某些云盘的文件系统上可能不支持,会报 Operation not supported。这时要确认你的 /etc 确实在支持 attr 的文件系统上(ext4 和 xfs 都支持)。另外 Btrfs 支持 chattr +i 但语义和 ext4 略有差别(它作用于子卷级的属性继承),这个用 lsattr -R 确认。

把审计变成定期任务

提权路径审计不是做一次就完了。新装的软件、新加的功能、别人代维时改的配置,都会引入新的提权点。做一份「基线」然后在 cron 里定期比对,是最有效的长期方案。

#!/bin/bash
# /usr/local/bin/priv-audit.sh —— 每天跑一次,有变化就告警
BASELINE=/var/lib/priv-audit/suid-baseline.txt
CURRENT=/var/lib/priv-audit/suid-current.txt
mkdir -p /var/lib/priv-audit

# 生成当前快照:只记录路径、权限、属主、属组、mtime
find / -xdev -type f \( -perm -4000 -o -perm -2000 \) 2>/dev/null | sort | while read -r f; do
    stat -c '%A %U:%G %Y %n' "$f"
done > "$CURRENT"

# 同时记录 sudoers 引用的脚本安全性
{
  echo "--- sudo-whitelisted scripts ---"
  grep -rhoE '/[a-zA-Z0-9._/-]+\.(sh|py|pl|rb)' /etc/sudoers /etc/sudoers.d/ 2>/dev/null | \
    sort -u | while read -r f; do [ -e "$f" ] && stat -c '%A %U:%G %n' "$f"; done
} >> "$CURRENT"

if [ -f "$BASELINE" ]; then
    if ! diff -q "$BASELINE" "$CURRENT" >/dev/null; then
        echo "=== PRIVILEGE BASELINE CHANGED ==="
        diff "$BASELINE" "$CURRENT"
        # 把变化内容通过你惯用的告警通道发出(邮件/钉钉/Telegram)
        diff "$BASELINE" "$CURRENT" | mail -s "[$(hostname)] 提权路径变更" admin@example.com
        # 审阅无误后更新基线
        # cp "$CURRENT" "$BASELINE"
    fi
else
    echo "baseline created at $BASELINE"
    cp "$CURRENT" "$BASELINE"
fi

第一次跑创建基线。之后每天的 diff 会告诉你:新增了哪些 SUID 文件(装新软件会触发,正常的要接受并更新基线)、哪些文件的属性变了(mtime 变了说明文件被改过,权限变了说明有人动了权限)。告警本身不该自动更新基线——应该人工看一眼确认合理后再 cp,否则真正的恶意变更会被自动吸收进基线,审计就失效了。

配合一起跑的还有几条针对性检查,加进同一个脚本即可:

# 1. UID 为 0 的账号不止 root(后门账号的典型特征)
awk -F: '$3==0 {print "UID0: " $1}' /etc/passwd
# 正常只有 root 一行,多出来立刻查

# 2. 空密码账号
awk -F: '$2=="" {print "EMPTY PW: " $1}' /etc/shadow
# 输出为空是正常的

# 3. 可登录的账号(有 shell)列出,删掉不需要的
awk -F: '$7 !~ /(nologin|false)$/ {print $1, $7}' /etc/passwd

# 4. 最近 7 天被修改的系统文件(排除日志、缓存、临时目录)
find /etc /usr/bin /usr/sbin /bin /sbin -type f -mtime -7 2>/dev/null | \
  grep -vE '/(mtab|adjtime|resolv.conf|ld.so.cache)$'

# 5. SSH 授权密钥的指纹与修改时间——后门最常藏在这里
for f in /root/.ssh/authorized_keys /home/*/.ssh/authorized_keys; do
    [ -f "$f" ] && stat -c '%y %n' "$f" && ssh-keygen -lf "$f" 2>/dev/null
done

第 5 条值得单独强调。攻击者拿到任意用户权限后,最省事的持久化方式就是往 authorized_keys 里加一把自己的公钥,之后随时能用密钥登录,而且和你自己的密钥混在一起,肉眼很难分辨。做法是把每个 authorized_keys 里的密钥指纹记录下来,比对已知的。多出来的指纹就是入侵证据。

总结:加固的优先级顺序

如果时间有限,按这个顺序做,收益递减但每一步都有实际效果。第一,找非标准路径的 SUID 文件并去掉 SUID 位(chmod u-s),这是最高危的一类。第二,审计 sudoers 里的 NOPASSWD 条目,把通配符和白名单里的编辑器/解释器全部收紧,把引用到的脚本权限收到 755 root:root。第三,检查 cron 和 systemd timer 里的脚本及其所有祖先目录的权限,把 /tmp 的 sticky 位确认还在。第四,给 /etc/passwd、/etc/ssh/sshd_config、/root/.ssh/authorized_keys 加 chattr +i。第五,建立每周或每天的基线审计和告警。

整个过程不需要装任何商业安全产品,全部用的是系统自带的工具。真正重要的是「知道自己的机器上有哪些提权路径」这件事本身——绝大多数被彻底拿下的服务器,都不是因为攻击者技术多高,而是因为机器上安安静静躺着一个 chmod 4755 的脚本,没人知道它为什么在那儿。

Last modification:September 29th, 2026 at 08:28 pm

Leave a Comment