服务器上的管理面板几乎总会撞到同一面墙:Web 进程需要做一件它没有权限做的事。封一个地址、开一个端口、读系统日志。最先想到的答案,就是在 sudoers 里给出一行窄窄的规则,别的什么都不给:

www-data ALL=(root) NOPASSWD: /usr/bin/fail2ban-client set * banip *

这一行读起来像「只能封禁」。实际上它的意思是「以 root 身份做任何事」。下面讲的是:为什么会这样,如何在自己的服务器上用五分钟查清,以及这类规则该用什么来替换。

* 实际上做了什么

关键细节:sudo 比对的不是一个个参数,而是整条命令行,而模式里的 * 会轻松跨过空格——也就是跨过参数的边界。因此位于规则中间的字面词 banip 什么都限制不了:只要 banip 这个词出现在命令的某处就够了,它前面和后面都可以传入任何东西。

接下来要找的,是一个可以交给它一条命令去执行的程序。在我们这里,它就写在规则本身里。fail2ban 中封禁动作是以文本形式设定的,而这段文本可以在运行中被重新定义:

fail2ban-client get portscan actions
sudo fail2ban-client set portscan action nftables-multiport actionban "<命令>" banip 192.0.2.10
sudo fail2ban-client set portscan banip 192.0.2.11

第一次调用替换掉某个真实动作的动作文本,第二次让该 jail 触发——于是以 root 身份运行的 fail2ban 就执行了被塞进去的内容。我们在 7 月 4 日于自己的服务器上做了这件事:/root 下以 root 权限生成了一个文件,也就是说,Web 进程从一条看上去很窄的规则里拿到了这台机器的全部权限。

过程中浮现出三个细节,它们在别人的分析里通常见不到:

  • set 之前的 -c 选项会被 sudo 截掉:在模式里 set 紧贴着可执行文件名,前面插不进任何东西;
  • 不带 banip 这个词的 addaction 和 action 同样通不过——但所需的一切都能装进一条 set … banip … 命令里,模式于是得到满足;
  • 动作的名字必须是真实存在的,否则没有东西可替换:fail2ban-client get <jail> actions 会把它列出来。

同一份名单上还有谁

fail2ban 在这里既无过错,也不是孤例。危险的是 NOPASSWD 与星号的组合本身。我们在自己的服务器上在它旁边发现了什么:

  • journalctl *——日志通过分页器打开,而从分页器里可以启动一个 shell。这条规则看着是关于读日志的,实际上是一个 root shell。解法不是把模式收窄,而是 systemd-journal 用户组:那样 journalctl 完全不需要 sudo 就能工作;
  • grep * /var/log/fail2ban.log——grep 的第一个参数是模式,但星号允许再给出第二个路径,而 grep 以 root 身份执行。用这条规则读取 /etc/shadow 只需一条命令。替代方案也一样:用 adm 组来读日志;
  • ufw --force * 以及赤裸的 ufw allow/deny/delete *——Web 进程可以把防火墙整个关掉。尤其令人不快的是:这两处面板根本没用过这些规则,它们是安装程序早期版本留下的死授权;
  • lynis-scan.sh *——把 "$@" 转交给 root 进程的包装脚本,等同于一条毫无限制的规则。

怎样看清你这里有什么

该看的不是文件,而是某个具体用户的实际权限——也就是 PHP-FPM 进程池真正运行所用的那个用户(并不总是 www-data;我们有一台机器上,面板是以 admin 身份运行的):

ps -o user= -C php-fpm | sort -u
sudo -l -U www-data
sudo -l -U admin

接着是文件清单本身,这里有两个让我们花过时间的陷阱:

sudo grep -rn 'NOPASSWD' /etc/sudoers /etc/sudoers.d/
ls -la /etc/sudoers.d/

第一个:规则会同时活在两个地方。我们这里那条危险的规则既在 /etc/sudoers.d/monitor 里,也在主文件 /etc/sudoers 里——在主文件里大约有八份副本,是安装程序的不同版本累积下来的。清理一处就安心,是把窟窿继续留着不补的惯常做法。

第二个:sudo 会忽略文件名中带点的文件。/etc/sudoers.d/ 下名为 www-data.bak3 的文件看着像一条生效的规则,读起来也像一条生效的规则,却不起作用。这在两个方向上都成立:「规则在,权限不在」,以及配置副本「就放在旁边」所带来的虚假安心。

用什么来替换

收窄模式没有意义——星号出现在这一行的任何位置,都会把问题打回起点。站得住脚的办法只有一个:一个 root 包装脚本,接收位置固定的参数,自己校验它们,并在没有任何余地添加内容的情况下调用程序。

#!/bin/sh
# /usr/local/sbin/arciveo-f2b — ban|unban <jail> <ip>,别的都不行
set -eu
action="$1"; jail="$2"; ip="$3"
case "$action" in ban|unban) ;; *) echo "invalid action"; exit 1 ;; esac
case "$jail" in *[!a-zA-Z0-9_-]*|'') echo "invalid jail"; exit 1 ;; esac
printf '%s' "$ip" | grep -Eq '^[0-9a-fA-F:.]+$' || { echo "invalid ip"; exit 1; }

case "$action" in
  ban)   exec /usr/bin/fail2ban-client set "$jail" banip   "$ip" ;;
  unban) exec /usr/bin/fail2ban-client set "$jail" unbanip "$ip" ;;
esac

包装脚本归 root 所有,权限 755,而且——这一点没有商量——它不能从 Web 侧写入,否则整套构造就失去了意义。sudoers 里只剩下它:

www-data ALL=(root) NOPASSWD: /usr/local/sbin/arciveo-f2b

规则里不再列出参数:参数由脚本自己校验,而脚本内部的位置是固定的,因此没有地方可以塞进动作文本。只读的规则——各类状态、ss、ipset list——单独成行,在可能的地方同样不带星号。

另外说说用用户组替代 sudo。思路是对的:日志用 systemd-journal、日志文件用 adm,比任何 sudo 规则都更便宜也更安全。但不能盲目地把 Web 用户从用户组里拽出来。我们在一台带面板的主机上把 www-data 从某个组里移除,结果所有站点同时给出 403:那里的 Apache 以 www-data 身份运行,站点文件归另一个用户所有,而读取权限恰恰来自那个组成员身份。恢复需要完整重启 Apache 和 PHP-FPM——不是重载——因为旧的工作进程仍保留着先前的用户组集合,会造成「能用,然后又 403」的状况。

改完之后的检查

替换 sudoers 文件之前要先检查它的语法——否则可能落到完全没有 sudo 的地步:

sudo visudo -cf /etc/sudoers.d/monitor

之后要确认旧路径已经死掉,而且要以那个用户的身份来确认:

sudo -k
sudo -u www-data sudo -n fail2ban-client set portscan banip 192.0.2.10

回答应当是 a password is required,而用垃圾代替地址去调用包装脚本,则应当得到 invalid ip。这里潜伏着它自己的陷阱:sudo -v 会把确认缓存十五分钟,之后任何一次 sudo -n 的检查都会「顺利」通过。我们当年正是这样确认了一条服务器上根本不存在的规则。所以检查前的 sudo -k 是必须的。

观察的意义

sudo 规则很少变动,但变动时悄无声息:安装程序往里追加,主机面板补上自己的,被卸载的软件包留下自己的行。一次性的审查只能关掉今天存在的东西,对下一次更新会带来什么则一无所言。实用的结论很简单:谁能变成 root,这份名单该和服务器其余状态一起摆在眼前,而不是在事故之后才想起来。这些东西汇总起来是什么样子,下面的演示页面可以看到。