不带特权的运维账号:白名单、受限 Shell 与 SSH

一个账号搞定备份、部署、抓日志

小团队和个人站长的服务器上,几乎都存在一个「万能账号」:它拥有 sudo 权限(通常还是 NOPASSWD),用来跑备份脚本、拉代码、重启服务、抓日志。它可能是 root,也可能是当初为了省事创建的 deploy 用户。部署密钥存在它家目录里,备份密码写在它的 shell 脚本里,日志轮转后需要它去删旧文件。看上去很方便,实际上它意味着:只要这个账号的 Shell 被碰到一次,整台机器就是别人的。

很多人对「运维专用账号」的理解停留在「用 useradd -m deploy 建个用户,然后 echo 'deploy ALL=(ALL) NOPASSWD:ALL' >> /etc/sudoers.d/deploy」。这不是权限分离,这只是给 root 换了个名字。真正的不带特权的运维账号,需要满足四个条件:不能以 root 身份执行任意命令、不能让它在任何路径下写入并执行、不能让它的密钥被复制走、不能让它的会话在无人管理时保持活跃。

这篇文章给出一套在 Debian 12 上可落地的方案,核心工具都是系统自带的:sudo 的精细化规则、rbash 受限 Shell、systemd-run 临时授权、以及 SSH 的 command= 强制命令。

原则先行:把「需要 root」拆成三类

在动手配置之前,先做一次盘点。把所有需要高权限的操作列出来,然后归入三类,每一类的处理方式完全不同:

  • 类型 A:固定的、可枚举的命令。例如 systemctl restart php8.2-fpm、nginx -t、certbot renew。这类最适合写进 sudoers 白名单,参数写死或用正则约束。
  • 类型 B:需要读取受保护文件。例如读 /var/log/nginx/*.log、读数据库备份目录。这类不该给 sudo,而应该用用户组 + ACL 授权,让普通权限就能读。
  • 类型 C:真正需要任意 root 的交互式运维。例如内核问题排查、磁盘救援。这类不应该被「自动化账号」持有,而应该保留给人在需要时用 sudo -i 并留下审计记录。

绝大多数「万能账号」的权限需求,其实 80% 属于 A 和 B,却按 C 配了。把它们拆开后,账号的特权面会小一个数量级。

第一层:读日志不要用 sudo,用组和 ACL

最典型的错误是让运维账号用 sudo cat /var/log/nginx/access.log。日志是只读需求,完全可以用 Posix ACL 精确授权,不涉及任何提权:

# 方案 A:把账号加入 adm 组(Debian 默认让 adm 可读 /var/log 下多数文件)
usermod -aG adm ops

# 方案 B:用 ACL 精确授予单个目录的读权限与默认继承
apt-get install -y acl
setfacl -R -m u:ops:rX /var/log/nginx
setfacl -R -d -m u:ops:rX /var/log/nginx   # 新建文件自动继承

# 验证
getfacl /var/log/nginx/access.log | grep ops

这里 rX 中的大写 X 有特殊含义:只对目录和已有执行位的文件赋予执行权限。用 rx 会给普通文件也加上 x,日志文件被标记为可执行在某些安全扫描里会被判为风险。这是 ACL 最容易写错的一个字母。

注意 logrotate 有个坑:轮转后新建的文件是否继承 ACL,取决于配置里是否有 create 参数以及是否启用了默认 ACL。如果在 logrotate 配置里写了 create 0640 www-data adm,新文件不会带自定义 ACL(除非目录上有默认 ACL,且 logrotate 创建时不覆盖权限)。稳妥做法是给目录加默认 ACL(上面第二条),并在 logrotate 里避免对已设 ACL 的文件使用 create,或改用 copytruncate。

第二层:sudoers 白名单,把参数约束死

很多人以为 sudoers 写 ops ALL=(root) /usr/bin/systemctl restart nginx 就安全了。实际上这条规则允许 sudo systemctl restart nginx any-other-unit 吗?不允许,但允许了参数里出现任何内容——包括被注入的场景。当 sudoers 里的路径可以通过参数被「跳出去」时,提权就发生了。典型的危险写法:

# 危险:允许任意参数的编辑器、解释器、文件复制工具
ops ALL=(root) NOPASSWD: /usr/bin/vim
ops ALL=(root) NOPASSWD: /usr/bin/find
ops ALL=(root) NOPASSWD: /bin/systemctl

# vim 里执行 :!sh 就能拿到 root shell
# find 的 -exec /bin/sh \;
# systemctl 的绝对路径引用任意 unit,或 systemctl edit 打开编辑器

正确的白名单要把子命令和参数都约束住,用引号或正则:

# /etc/sudoers.d/ops-readonly
Cmnd_Alias OPS_READ = /usr/bin/systemctl status *, \
                      /usr/bin/systemctl is-active *, \
                      /usr/bin/journalctl -u *, \
                      /usr/sbin/nginx -t, \
                      /usr/bin/du -sh /var/www/*, \
                      /usr/bin/df -h, /usr/bin/free -m, /usr/bin/uptime

Cmnd_Alias OPS_MAINT = /usr/bin/systemctl restart php8.2-fpm.service, \
                       /usr/bin/systemctl reload nginx.service, \
                       /usr/bin/certbot renew --quiet

ops ALL=(root) NOPASSWD: OPS_READ, OPS_MAINT
# 默认拒绝一切未列出的命令
Defaults:ops !requiretty

几个要点:

  • /usr/bin/systemctl status * 里的 * 仍然可以放任意字符串,但 status 子命令本身不能执行命令,风险有限;凡是允许 * 的子命令,必须人工确认它不能被用来读任意文件或执行任意程序。例如 systemctl status 可以指定任意 unit 名,但不会加载并执行任意路径的 unit(除非该 unit 已安装)。
  • 写完整的 .service 后缀,避免 restart * 这种范围过宽的规则。
  • 规则里的路径必须是绝对路径,且不能是符号链接指向的;如果 /usr/bin/systemctl 本身可被普通用户替换(权限错了),sudoers 白名单就形同虚设。检查:ls -l /usr/bin/systemctl 应为 root:root 755。
  • 用 visudo -c -f /etc/sudoers.d/ops-readonly 校验语法,不要直接改 /etc/sudoers。

配置完成后,用 sudo -l -U ops 复核真正生效的权限清单,这比翻文件可靠:

sudo -l -U ops
# 输出应只有你写的两条 Cmnd_Alias 展开内容,没有任何 ALL

第三层:受限 Shell,把「意外落地」的空间压到最小

运维账号如果只用来跑固定任务,就不该给它一个完整的交互式 bash。Debian 自带 rbash(restricted bash),开启后禁止 cd、禁止修改 PATH、禁止用 / 开头的路径执行命令、禁止重定向输出。它不能当安全边界,但能挡住大量自动化脚本被注入了参数之后的横向移动。

# 建立专用账号
useradd -m -s /bin/rbash ops
passwd -l ops   # 禁用密码登录,只允许密钥

# 只允许它执行一份白名单命令:新建 ~/bin 并写入 PATH
mkdir -p /home/ops/bin
# 注意 rbash 下不能改 PATH,所以要通过 /etc/profile 或 ~/.bash_profile 设定
cat > /home/ops/.bash_profile <<'EOF'
PATH=$HOME/bin
export PATH
chown root:root /home/ops/.bash_profile
chmod 555 /home/ops/bin

然后在 /home/ops/bin 里放软链接,只链接受允许的工具:

for c in ls cat tail grep df free uptime systemctl journalctl; do
  ln -s "$(command -v $c)" /home/ops/bin/$c
done

这里的关键是 ~/bin 必须是 root 所有且 555,否则用户可以自己往里面放脚本,等于绕过白名单。~/.bash_profile 同样要 root 所有且不可写,否则改掉 PATH 就能用系统里的全部命令。

要提醒的是:rbash 不是牢笼。它会尝试阻止 cd,但通过某些程序(编辑器、分页器、解释器)可以逃出去。所以 rbash 的正确位置是「纵深防御的一层」,前提是前面已经做过 sudo 白名单收敛——两者的顺序不能反。

第四层:用 systemd-run 做临时提权,而不是长期授予

有些任务是「偶尔需要 root,但每次只需要一条命令」。这类需求用 sudoers 白名单会越写越长,不如给账号一条统一的入口:通过 systemd-run 以临时 unit 的形式执行,并设置资源上限与超时,执行完即焚。

# 允许 ops 通过 systemd-run 执行任意命令 → 这等于 root,不能这么给
# 正确做法:让 ops 无权直接 sudo,而是调用一个固定的 wrapper
cat > /usr/local/sbin/ops-task <<'EOF'
#!/bin/bash
set -euo pipefail
case "${1:-}" in
  backup)
    exec /usr/local/bin/site-backup.sh ;;
  purge-cache)
    exec /usr/bin/systemd-run --pipe --quiet --collect \
         --property=MemoryMax=512M --property=TimeoutStartSec=600 \
         /usr/bin/find /var/cache/site -type f -mtime +7 -delete ;;
  *)
    echo "unknown task: ${1:-}" >&2; exit 64 ;;
esac
chmod 750 /usr/local/sbin/ops-task
chown root:root /usr/local/sbin/ops-task
# sudoers 只给这一条
ops ALL=(root) NOPASSWD: /usr/local/sbin/ops-task backup, /usr/local/sbin/ops-task purge-cache

这个 wrapper 的价值在于:审计面收敛为一个文件。所有提权操作都从这里走,ops 账号被入侵后,攻击者能做的只有 wrapper 里列出的两个 case。加任务时只改 wrapper,不用碰 sudoers。set -euo pipefail 保证脚本里出错不会静默继续,--collect 让临时 unit 结束后自动回收,避免残留。

第五层:SSH 侧的限制,比主机内的一切都重要

上面所有的权限收窄,前提是「攻击者已经登录进来了」。如果 SSH 本身就是敞开的,这些努力会被一次弱口令爆破抹平。针对运维账号,SSH 侧至少做四件事:

# /etc/ssh/sshd_config.d/ops.conf
Match User ops
    PasswordAuthentication no
    PubkeyAuthentication yes
    PermitTTY yes
    AllowTcpForwarding no
    PermitTunnel no
    X11Forwarding no
    AllowAgentForwarding no
    ForceCommand /usr/bin/logger -t ops-ssh "session opened from $SSH_CLIENT"

AllowAgentForwarding no 是一个常被忽略的高价值配置:如果运维账号做 agent forwarding,而账号被入侵,攻击者可以通过你本地机器的 ssh-agent 拿走你所有主机的访问权,而不需要任何私钥文件。关掉它,用 ProxyJump 代替。

更彻底的做法是给自动化任务专用一条「只能执行单个命令」的密钥。这在 authorized_keys 里用 command= 前缀实现,且必须在密钥前面加上限制选项:

# /home/ops/.ssh/authorized_keys
command="/usr/local/sbin/ops-task backup",no-agent-forwarding,no-port-forwarding,no-pty,no-user-rc,from="203.0.113.7" ssh-ed25519 AAAAC3... backup@ci

这条密钥即使泄漏,也只能从指定 IP 发起、只能执行 backup 这一个任务、拿不到 shell、转发不了端口。这就是「凭据不可升级」——密钥泄漏不再是全盘失守。

验证:真的收窄了吗

配置完成后不能只看文件。用一个测试账号(或直接 su 到 ops)做六项验证:

sudo -l -U ops                                  # 1) 权限清单只有白名单
sudo -u ops bash -c 'cat /etc/shadow'           # 2) 应 Permission denied
sudo -u ops bash -c 'sudo vim /etc/hosts'       # 3) 应 not allowed(若通过,说明白名单有洞)
sudo -u ops bash -c 'echo x > /home/ops/bin/a'  # 4) 应 Permission denied(目录 555)
ssh -o BatchMode=yes ops@host 'id; cat /etc/shadow'  # 5) 强制命令应只输出 backup 日志
lastlog -u ops; grep ops /var/log/auth.log | tail # 6) 有审计记录

第 3 项是最容易被忽略的:很多人写白名单时留下了某个「万能」程序(比如允许 sudo tee、sudo cp、sudo chmod),结果一条 sudo cp /bin/bash /tmp/x && sudo chmod +s /tmp/x 就能提权。经验法则是:任何允许写文件、调用子进程、打开交互式 Shell 的命令,都不能出现在 NOPASSWD 白名单里——包括 vim、less、find、awk、tar(--to-command)、rsync(-e)、docker、systemctl(edit)、cp、tee、dd。

小结

不带特权的运维账号,本质上是把「一个账号做所有事」拆成五层互相独立的限制:日志用 ACL 而不是 sudo,命令用白名单而不是全权,Shell 用受限模式而不是完整 bash,偶发提权走 wrapper 而不是长期授权,SSH 用强制命令加来源限制而不是裸密钥。每一层单独看都不是铁桶,但攻击者需要同时穿透五层——这在实际攻击成本上是质变。配置一次大约需要半小时,之后每新增一个运维任务,只需要往 wrapper 里加一个 case、往 sudoers 加一行、再跑一次上面的六项验证。

Last modification:October 19th, 2026 at 10:23 pm

Leave a Comment