给生产服务器做一次安全审计

目录

我有一台 Linux 服务器,上面跑着不止一个公开站点,你正在读的这个博客就是其中之一。机器不大,但它是生产环境,出了问题会有真实的人受影响。

我想弄清楚它到底安不安全。这个问题太大,拆开以后是五个具体一点的问题:谁能登录这台机器,登录之后权限有多大;互联网能摸到它上面的哪些服务;已知的漏洞多久能修掉,修了之后有没有真正生效;出了事我会不会知道,事后能不能还原经过;数据没了能不能找回来,要花多久,会丢多少。

加固的清单网上到处都是,照着改一遍并不难,难的是判断。怎么确定一项设置真的生效了?怎么确定一个「没问题」不是因为你看错了地方?这次踩到的坑几乎都属于后一类,所以这篇不列清单,只讲每个问题该怎么判,以及朴素的做法会在哪里误判。文中的示例输出都是我构造的通用样子,不是这台机器的真实数据;这台机器具体的配置和检查结果,这里也一概不写。

先把自己的手绑起来

给生产机做审计,最坏的结果有两种:把自己锁在门外,或者把服务搞挂。前一种多半发生在改 SSH 配置的时候,后一种多半发生在顺手「修一下」的时候。所以我给这次审计定的第一条规矩是只读。审计阶段只看不改,所有修改留到之后,一次一组,改完逐项复查。

光有这个念头还不够。审计总要以 root 身份跑一堆命令,而一个以 root 运行的工具,本身就是这台机器上最大的风险。后来我把这套检查写成了一个开源工具,叫 server-hardening-audit,最先定下来的原则就是:只读要由代码来保证,而不是靠使用者自觉。

具体的办法是一张默认拒绝的命令白名单。它不是一张「禁止危险命令」的黑名单,而是给每个程序列出它被允许的精确参数语法:哪些子命令,哪些选项,参数长什么样。对不上的一律拒绝,这一项记为 UNKNOWN,拒绝本身也作为证据留存。这和审计防火墙用的是同一个原则:默认拒绝,最小放行,每一条放行都能被审查。黑名单就相当于一台默认放行的防火墙,只要少写一个动词就会失守。白名单本身也要从两头测:每个检查会用到的命令都必须能通过,一长串会改动状态的命令都必须被拒绝。

其次是不经过 shell。命令以参数列表的形式执行,PATH 是固定的,环境变量只保留最少的几个。没有 shell,也就没有管道、重定向和命令替换可以注入,这一整类问题直接消失,需要过滤输出的地方就在 Python 里做。再次,可执行文件必须属于 root,并且组和其他用户都不可写。白名单里写的只是一个名字,名字背后的那个文件如果能被别的账号替换掉,这个名字也就不值得信任了。读文件同样默认拒绝,只有列在白名单里的配置路径可以被原样读取。

敏感的来源要更小心。审计留下的证据包本身就是这台主机的一张地图,如果再把密码哈希、私钥、写着数据库密码的命令存进去,等于第二次泄露。所以这类来源只在读取的那一刻做派生,真正落盘的只有派生出来的结果:哪些账号的密码为空,每把公钥的类型和注释,定时任务的时间表,每天的封禁次数。

还有几条命令是我刻意不跑的,每一条都有替代的读法。apt-get update 会改写软件包列表,所以我读现有的列表,同时报告它有多旧。unattended-upgrade --dry-run 会写日志,还会抢 apt 的锁,所以我读它的配置、定时器、periodic stamp 和日志。lynis audit system 会往 /var/log 里写东西,我实测发现它还会顺手执行一次 apt-get update,所以只读它已有的报告。sysctl 能读也能写,内核参数就直接从 /proc/sys 读。openssl s_client 是一个通用的网络客户端,TLS 检查改用 Python 标准库的 ssl,而且只连本机回环。

配置文件会骗人

第一个问题是谁能登录。最朴素的做法是打开 /etc/ssh/sshd_config,看 PasswordAuthentication 是不是 no。偏偏这就是整次审计里最值得讲的一个坑。

Ubuntu 的 sshd 主配置开头有一行 Include /etc/ssh/sshd_config.d/*.conf。被包含进来的文件按文件名排序,在主配置的其余内容之前读入;而对同一个关键字,sshd 采用它先读到的那个值。所以排序靠前的 drop-in 会盖过主配置,一些云镜像还自带一个重新打开密码登录的 drop-in。另一个细节是这个 glob 只匹配 *.conf。一个被改名成 .bak 的文件,既不算备份,也不算禁用,对 sshd 来说它根本不存在。

sshd 读取配置的顺序:排序靠前的 drop-in 先读到,所以生效;主配置里同一个关键字被忽略;改名为 .bak 的文件不匹配 *.conf,从没被读取

下面是一个构造的例子:

Terminal window
$ grep -i passwordauthentication /etc/ssh/sshd_config
PasswordAuthentication no
$ ls /etc/ssh/sshd_config.d/
50-cloud-init.conf 60-hardening.conf.bak
$ cat /etc/ssh/sshd_config.d/50-cloud-init.conf
PasswordAuthentication yes
$ sudo sshd -T | grep -i passwordauthentication
passwordauthentication yes

主配置里写的是 no,真正生效的是 yes。结论只有一个:只信 sshd -T,它输出的是 sshd 把所有文件解析完之后的生效值。它需要读主机密钥,不以 root 运行时读不到,这时老老实实判 UNKNOWN。配置里如果有 Match 块,还要用 sshd -T -C user=…,host=…,addr=… 按具体的账号和来源分别求值。修的时候,把设置写进一个排序最靠前的 drop-in,比如 00-hardening.conf,先用 sshd -t 校验语法,再 reload 而不是 restart,然后新开一个会话确认自己还能登录,最后才关掉旧的会话。

主机内部的证据只能证明「设置了」,还需要证明「对外生效了」。办法是从另一台机器连一次,不提交任何凭据,只看服务器愿意接受哪些认证方式:

Terminal window
$ ssh -o BatchMode=yes -o PreferredAuthentications=none [email protected]
[email protected]: Permission denied (publickey).

括号里应该只有 publickey。两份证据缺一不可:前一份说明配置写对了,后一份说明互联网看到的也是这样。

sudo 也有类似的陷阱。只看 /etc/sudoers 会漏掉 sudoers.d/ 目录,而 sudo 读这个目录时,会跳过文件名里带点号或者以 ~ 结尾的文件。如果有人把授权写进了一个叫 admin.conf 的文件,它其实从来没有生效过。反方向的漏看更危险:只盯着 sudo 组,就会漏掉 docker 和 lxd 这两个组。能启动容器,就能把整个主机的文件系统挂载进去,这两个组的成员实际上等同于 root。比较可靠的做法是交叉比对两份名单,一份是谁在特权组里,另一份是谁在跑进程和服务。应用账号不应该出现在任何特权组里,而有特权的人类账号必须在事先声明的管理员名单上。检查 authorized_keys 时,我只记录每把公钥的类型、注释、选项名和 RSA 位数,公钥本体一概不存。

最后是多因素认证。很多人以为只用密钥登录就算安全了,但私钥文件本身只是一个因素,属于「你拥有的东西」;passphrase 在客户端解锁,服务器根本验证不了,也算不上第二个因素。要做到真正的多因素,一种办法是 FIDO2 硬件密钥,也就是 ed25519-sk 这类密钥,私钥出不了硬件,每次登录都要物理按一下;另一种是用 AuthenticationMethods publickey,keyboard-interactive 再配一个 TOTP 的 PAM 模块。判定的时候,要么 AuthenticationMethods 要求了两种方式,要么所有的密钥都是 sk- 开头的 FIDO 类型。

从外面看一次

第二个问题是互联网能摸到这台机器上的哪些服务。朴素的做法是看防火墙规则,它会在几个地方看走眼。只写了 IPv4 的规则,服务在 IPv6 上照样暴露。配置里写着应该监听什么,实际监听的可能是另一回事。从外面连不上 443,也不等于证书有问题:源站如果只允许 CDN 回源,别处直连本来就应该超时,所以报告里「连不上」和「不合格」必须是两种不同的状态。最反直觉的一个是 Docker,它发布的端口会绕过主机的防火墙。

Docker 发布端口的时候,会往 iptables 或 nftables 的 NAT 表和 FORWARD 链里插入自己的规则。外面来的连接先在 PREROUTING 阶段被 DNAT 改写成容器的地址,接着走 FORWARD 链转发给容器,根本不经过 ufw 管的 INPUT 链。所以一个在 ufw 里从没放行过的端口,外面照样连得上。

Docker 发布的端口为什么绕过 ufw:连接先被 DNAT 改写成容器地址,再走 FORWARD 链放行,从不经过 ufw 规则所在的 INPUT 链

它在现实里的样子,是主机里看到的和外面探测到的对不上:

Terminal window
$ sudo ufw status
Status: active
To Action From
-- ------ ----
22/tcp ALLOW IN Anywhere
80,443/tcp ALLOW IN Anywhere
$ docker ps --format '{{.Names}}: {{.Ports}}'
analytics: 0.0.0.0:3000->3000/tcp
db: 0.0.0.0:5432->5432/tcp
# 从另一台机器探测
3000/tcp open
5432/tcp open

修法是把端口发布到回环地址上,写成 127.0.0.1:3000:3000,前面放一个反向代理;只给兄弟容器用的服务,比如数据库,干脆不发布任何主机端口。

可靠的检查分内外两层。在主机内部,先读防火墙前端解析之后的策略,不管它是 ufw、firewalld、nftables 还是 iptables,看默认策略和「对任意来源开放」的端口;再用 ss -tlnp 和 ss -ulnp 看实际在监听什么,所有非回环地址上的端口都必须在声明过的公开端口清单里;装了 Docker 的话,再读 docker inspect 里每个发布端口的 HostIp。通过的标准是 IPv4 和 IPv6 都默认拒绝入站,对任意来源开放的端口都在声明清单之内,容器端口全部绑在回环上。

在主机外部,从另一个网络把「应该开」和「应该关」的端口各连一次,IPv4 和 IPv6 分开探测。连上了是 open,被拒绝是 closed,超时是 filtered。只要有一个声明之外的端口是 open,这一项就判 FAIL。

内外两份证据的来源不同,报告里要分开放。主机内部采集的是机器的证据,外部探测的结果则由人或者探测工具以签名陈述的形式补进来。所以在我的报告里,前者显示为 PASS,后者显示为 PASS · attested,还没补上的标成 pending。两者冲突的时候有明确的规则:人的陈述不能把机器判定的 FAIL 改成 PASS;但外部观察如果推翻了机器判定的 PASS,结果就变成 FAIL,因为互联网实际能到达的地方,比配置说它能到达的地方更权威。只有一种情况不算冲突,就是外部检查看到的范围更窄,比如只看得到认证方式,看不到 root 能不能登录,这时它给出的 PASS 并不和内部的 FAIL 矛盾。

没报错不等于通过

第三个问题是补丁。开了自动更新,不代表它真的在跑;补丁装了,也不代表已经生效,内核和库更新之后如果不重启,旧的代码还留在内存里运行。还有一个更隐蔽的错误:为了看到最新状态,先跑一次 apt-get update。这样做的那一刻,审计工具自己就改变了它要观察的那台机器。

我的做法是把四样东西放在一起看:自动更新的配置,定时器的状态,periodic stamp,以及日志里最近一次运行的时间。待装的安全更新从现有的软件包列表里计算,同时报告这份列表有多旧,超过阈值,比如 48 小时,就判 UNKNOWN,而不是替它更新一遍再下结论。要不要重启,则看 /var/run/reboot-required 这个标记在不在,以及它已经存在了多久。通过的标准是:自动更新已经启用,定时器开着,48 小时内运行过,没有待装的安全更新,也没有待重启的标记。

「不知道」比「猜」诚实。这条原则贯穿了整个审计:命令不存在,权限不够,输出解析不了,数据太旧,统统判 UNKNOWN 并写明原因。空输出会作为证据留下,但只有在规则本身要求「为空」的时候才算通过,比如「声明之外没有端口在监听」,而不是「什么也没出错」。

第四个问题是出了事会不会知道。fail2ban 在运行,不代表它在工作,它可能在读一个系统早已不再写入的日志文件。journald 在运行,也不代表日志留得住,如果它的存储模式是易失的,重启一次就全丢了。

判断 fail2ban 有没有在工作,我用的是一个听上去有点反常的标准:公网 SSH 端口上累计封禁次数为 0,直接判 FAIL。互联网不会这么安静,公网上的 SSH 端口一定会被扫描,0 次只能说明它读错了日志。下面这个构造的例子里,jail 盯着一个已经没人写的文件,而系统日志里同一时间段有两千多次失败的登录尝试:

Terminal window
$ sudo fail2ban-client status sshd
Status for the jail: sshd
|- Filter
| |- Currently failed: 0
| |- Total failed: 0
| `- File list: /var/log/auth.log
`- Actions
|- Currently banned: 0
|- Total banned: 0
`- Banned IP list:
$ sudo journalctl -u ssh --since "24 hours ago" | grep -cE "Invalid user|authenticating user"
2316

留存证据时,fail2ban 这边只保留每个 jail 每天的封禁次数,不保存攻击者的 IP。journald 的生效存储模式,要从主配置和所有 drop-in 里算出最终的 Storage=,再看 /var/log/journal 这个目录是否存在。至于审计工具,要求 auditd 在运行,或者装了 AIDE。

有备份文件不等于能恢复

第五个问题是数据没了能不能找回来,这里的误判最多。有备份脚本,却没看最新的一份是什么时候生成的。有备份文件,却没有清理策略,迟早把磁盘写满。备份和主机放在同一个地方,主机没了,备份也跟着没了。连权限都可能看错:一个备份文件的权限是 0644,看上去谁都能读,可如果它的父目录是 0700,别的用户根本进不去。权限要看整条路径,不能只看文件本身。

比较可靠的做法是先看调度,不管是 cron 还是 systemd 的定时器,但证据里只保存时间表和几个关键词标记,不保存命令本身,因为备份命令里常常直接写着数据库密码。然后拿最新一份备份的年龄去和声明的 RPO 比较,再看有没有保留策略。异地副本从主机上看不到,只能由人来证明;看不出保留策略的时候,比如它写在脚本内部,同样让人来证明,而不是去猜。

即便以上都没问题,也只能说明备份「存在」。备份能不能用,只有恢复演练能回答。演练的第一条原则是永远不在生产库上测试恢复。先找到最新的那份备份,记下它的大小、时间和 sha256,体积异常小本身就是一个发现。接着在不恢复的前提下先验证结构,数据库 dump 用 pg_restore --list,tar 归档用 tar -tf,restic 仓库用 restic check。连内容都列不出来的备份不可信:

Terminal window
$ pg_restore --list demo.dump
;
; Archive created at 2026-06-01 03:00:12 UTC
; dbname: demo
; TOC Entries: 214
; Compression: gzip
; Format: CUSTOM
; Dumped from database version: 16.4
;
; Selected TOC Entries:
;
3412; 1259 16385 TABLE public accounts demo
3415; 1259 16402 TABLE public orders demo
3418; 1259 16427 TABLE public events demo
...

然后把它恢复到一个全新的临时目标里,名字要和生产库区分得清清楚楚,比如 demo_restore_test,并且全程计时。恢复时如果报缺少角色或者属主,就用不恢复属主和权限的选项,也就是 --no-owner --no-privileges。

恢复完成后要对账:对源库和恢复出来的库跑同一组查询,统计每张主要表的行数,以及最新一条记录的时间戳。下面是一份构造的结果:

table source_rows restored_rows source_latest restored_latest
accounts 18,402 18,402 2026-06-01 02:58:41 2026-06-01 02:58:41
orders 241,117 241,090 2026-06-01 03:07:55 2026-06-01 02:59:58
events 1,904,332 1,903,871 2026-06-01 03:08:02 2026-06-01 02:59:59

源库的行数比恢复库略多,这是正常的:演练期间生产环境还在持续写入。要做的是记下 dump 的快照时间和这些差值,看差值的方向和量级是否合理,源库只能多、不能少,多出来的量要和写入速率对得上。真正的失败是另外几种:恢复出来的库比 dump 时点的数据还少,有表整张不见了,或者恢复的根本是另一个库。最后一种比听上去常见,所以对完数字还要检查语义,确认恢复的是对的数据,预期的站点、租户和账号都在里面。演练结束后删掉临时目标,删之前先问一声。

有两个指标跟着演练一起出来。RPO 由备份的间隔决定,每天备份一次,最多就可能丢 24 小时的数据;它应当写进声明,由工具检查最新一份备份的年龄有没有超出。RTO 则只能靠演练来测,等于实测的恢复时间,加上现实里的切换时间,比如改 DNS、改配置、验证站点。没有演练过的 RTO 只是猜测。

整个过程还有一条数据纪律:dump 文件和含有个人数据的查询结果都不离开服务器,带出来的只有元数据,也就是大小、校验和、表清单、行数和耗时。

翻译成框架语言,但不夸大

技术检查做完,下一步常常是把结果映射到某个框架上,比如澳大利亚信号局 ASD 的 Essential Eight,或者 ISO/IEC 27001:2022 的附录 A。这一步最容易夸大。

ASD 在 2023 年 11 月版的 Essential Eight maturity model 里写得很清楚:

The Essential Eight has been designed to protect organisations’ internet-connected information technology networks.

它面向的是一个组织的整张网络,而不是单台主机。落到一台 Linux 服务器上,第一级成熟度 ML1 里有很多条款直接点名了工作站、Office、浏览器、邮件客户端和 PDF 软件,对服务器来说都不适用。还有一些条款适用,但从主机上看不到,比如资产发现、漏洞扫描的节奏、权限申请的审批,它们属于组织的流程,主机日志里找不到证据。另外有两处容易看错级别。ASD 在 2024 年 10 月的 Essential Eight assessment process guide 里说明,ML1 的多因素认证聚焦于在线服务,服务器管理登录的多因素认证属于 ML2;ML1 的应用程序控制也只要求覆盖工作站,面向互联网的服务器要到 ML2。所以 SSH 做没做多因素认证,在这个框架里只能算更高一级的证据。

按我的工具的拆法,ML1 一共 48 条要求,其中 22 条不适用于服务器,15 条适用但在单台主机上根本不会留下证据,只有 11 条能从主机上找到证据。ASD 的规则是,一项缓解策略里每一条适用的要求都有效,才算达到这一级;既然大部分适用的条款在主机上看不到,汇总到缓解策略上,诚实的结论多半只能是「未能判定」,而不是「达成」。这和哪台机器管得好不好无关,是一台主机能提供的证据本来就有限。

这里有两个概念必须分开。不适用(NA)是说条款本身和这类主机无关,比如 Office 宏之于 Linux 服务器。未评估(NOT ASSESSED)是说条款适用,但这一次没有证据,比如漏洞扫描的流程。把后者混进前者,覆盖率立刻好看许多,这是这类工具最常见的不诚实。再往前一步,部分证据只能证伪,不能证实:主机上的证据能证明某件事「没做到」,却不能单独证明它「做到了」。

还有一种情况是发现了问题,但暂时不修。这时要写明原因、由谁接受、什么时候复查,报告里显示为 FAIL · risk accepted (review by …),过了复查日期就变成 REVIEW OVERDUE。关键在于 FAIL 永远还是 FAIL,在 Essential Eight 的视图里也依然是 Ineffective,ASD 的评估指南不允许拿风险接受来代替落实控制。什么样的风险适合接受?我的标准是不能被直接利用:攻击者就算知道这个问题存在,也还缺一样他拿不到的东西,才谈得上得手。反过来,如果要公开一份报告,未修复并且可以被利用的问题应当整条隐藏,标注为 withheld,而不是假装它「未评估」。

第一次上真机

方法写在纸上都很顺,第一次拿到生产机上跑,还是学到了几件事。

同一个命令,在不同版本上的行为会不一样,比如 fail2ban 的一些参数查询,并不是每个版本都支持。设计上查不了就报 UNKNOWN,这类问题会当场暴露出来,而不是被默默地当成 PASS。「不知道就说不知道」这条规矩,最值钱的就是这种时候。

在生产机上装任何软件包之前,都要先让 Ubuntu 的 needrestart 别自动重启服务。它的默认行为是装完包就把受影响的服务重启一遍,而生产机上的重启,应该留到计划好的窗口去做。

基线扫描我用了 Lynis,但只把它当作第二意见。它的很多建议对 Web 服务器并不适用,有的甚至有害,比如独立分区、禁用编译器、安装恶意软件扫描器,所以追求 100 分没有意义。我的用法是修复前后各跑一次,看分数怎么变化;挑建议的时候看三样东西:改动小不小,风险降多少,会不会影响服务。不适用的那些,写成已接受的风险。前面说过 Lynis 自己会写日志、跑 apt-get update,所以它属于一次运维操作,不属于只读审计。

最后是修复的节奏。我给自己定的流程是:改之前先备份,一次只改一组,改完立刻复查那一项,确认站点都还正常,再把撤销用的命令记下来。这样很慢,但每一步都能退回去。

总结

回头看,这次审计里真正有用的,并不是哪一条具体的配置,而是几条判断的原则。只信生效状态,不信配置文件:sshd 看 sshd -T,内核参数看 /proc/sys。内部检查证明不了可达性,一定要从外面再看一次。没报错不等于通过:查不了就是 UNKNOWN,公网 SSH 端口上封禁了 0 次就是 FAIL。有备份文件不等于能恢复,没演练过的 RTO 只是猜测。机器采集的证据和人签字的陈述要分开,「不适用」和「未评估」也要分开。

这些原则都落在了 server-hardening-audit 里。它还有一个我很看重的性质:可以复现。每一次读取和执行都连同参数和结果记在 index.json 里,audit --replay 用同一套代码路径、按原来那次运行的时间戳重新判定一遍。这样一来,没有 root 也能测试整个引擎,报告的格式改了可以从旧证据重新生成,第三方也能自己复核每一个判定。发布之前,我用 replay 从第一次运行的原始证据重新生成了样本报告,25 项结果全部一致。它也可以反复运行,下次复查时把两次的结果用 diff 一比,就知道哪里变了。

源码、设计文档和一份脱敏的样本报告都在 GitHub 上,样本在 examples 目录里。仓库里还附了一份给 AI 助手用的操作说明,让它按同样的纪律陪人走完一次审计,任何修改都要先经过人的同意。

审计本身不会让一台机器更安全。它能做的,是让你分清哪些事情你真的知道,哪些事情你只是以为自己知道。