昏暗的数据中心里,一台黑色刀片服务器被一层发光的翡翠绿六边形护盾笼罩,飞来的绿色光粒子撞上屏障后四散碎裂
安全指南

第一小时内加固新 VPS

一个公网 IPv4 地址,在发出第一个数据包后的几分钟内就会被探测——探测它的机器从未听说过你,以后也不会。这其实是好消息:几乎所有针对新服务器的攻击都是通用型的,认真投入的一个小时就能挡住其中几乎全部。但同样是这一个小时,如果顺序搞错了,也会把你自己锁在一台谁都没法替你登录的机器外面。

支付确认后大约 47 秒,我们就会把一个 root shell 交到你手上,然后——刻意地——就此打住。我们不会安装代理程序,不会替你管理防火墙,也不会保留一份你的登录凭证副本——我们手上握有的密钥,就是我们可能被迫交出的密钥,这一原则贯穿整个平台。由此得到的结论很简单,也值得直说:你的服务器有多安全,取决于你在第一个小时里把它留成什么样子。

接下来的内容,就是这一个小时——按照我们自己在 Debian 13 和 Ubuntu 24.04 上实际操作的顺序排列。其中两项设置就完成了大部分工作。这个页面其余的篇幅,都是为了三件看起来正确、能通过任何教程最后一步检查、却在暗中出错的事情而写的:一个被悄悄覆盖掉的 SSH 配置片段(drop-in)、一个被 socket 激活的 sshd 直接无视的端口设置,以及一个会在防火墙下方悄悄发布端口的容器运行时。这三件事,都曾让某个把其他所有事都做对了的人栽过跟头。

究竟是什么在敲门

在一台已经上线一个小时的服务器上查看 journalctl -u ssh,第一次看到那个数量时会让人心里一紧。其实不必如此。你看到的只是互联网的背景辐射:少数几拨扫描活动——有的是学术性质,有的是商业性质,有的纯属犯罪——在持续不断地枚举整个 IPv4 地址空间,并把结果交给猜密码的机器人。你的地址会被碰到,只是因为它按数字顺序确实存在,而且不管你做什么,几个小时后它还会再被碰一次。

这个认识能以一种有用的方式改变问题的性质。你要防的不是一个专门盯上你、还会随机应变的对手,而是一个只有固定套路的脚本——root、admin、ubuntu、test、git、oracle、postgres,外加两万个出现在数据泄露记录中的密码。它没有耐心,没有创造力,对一台第一次尝试就不给回应的机器也毫无兴趣。关掉密码认证,并不会让这个攻击者慢下来,而是把它彻底赶出局。

有两个细节值得了解。第一,IPv6 要安静得多,因为一个 /64 网段没法被扫完——但只要你的 AAAA 记录一公开,或者你机器的地址出现在邮件头或证书透明度日志里,安静就到头了。永远不要把 IPv6 当成藏身之处,只能把它当成一堆小一点的干草垛。第二,一个 IPv4 地址是有前科的。它在到你手上之前有过别的租户,如果那个租户把邮件服务器搞得一团糟,或者托管过被拉黑的东西,你就会继承这个名声,直到它慢慢消退。如果邮件对你很重要,在动手之前先到常见的黑名单上查一下这个地址——这是一次只需五分钟的检查,能省下之后两周排查送达率问题的功夫。

完成九成工作的两项设置

几乎每一起针对小型服务器的真实入侵,起点都逃不开两个地方:一个能被猜中的密码,或者一个本不该在监听、却在监听的服务。对应的修复手段就是 PasswordAuthentication no,以及一个默认策略为 drop 的防火墙。它们毫不起眼,两个加起来只要十五分钟,却比这个页面上其他所有防护措施加在一起还要值钱。

原因在于结构,而不在于概率。这两项设置都是默认关闭的——出错时也是朝着安全的方向出错。一个仅接受密钥的 sshd,不管收到多少次尝试都不可能被暴力破解,因为代码里根本没有一条能接受密码的路径。一个默认拒绝的防火墙,保护的是你还没装上的服务,包括你三个月后会加上、却忘了绑定到 localhost 的那个数据库。加固清单里的其余内容,本质上都是在列举各种坏情况:一份列出该关掉哪些具体东西的清单,而这份清单永远只能和它列出的内容一样完整。

所以,如果你只打算读一节就关掉这个页面,那就读这一节,做完下面的步骤 2 到步骤 5,这一个小时就算没有白花。剩下的内容确实有用,但确实也是次要的。

SSH 配置现在放在哪里,以及它藏着的陷阱

在 Debian 13 和 Ubuntu 24.04 上,/etc/ssh/sshd_config 开头就有一行 Include /etc/ssh/sshd_config.d/*.conf,而云镜像会在这个目录里预置一个文件——通常是 50-cloud-init.conf——其中已经设置了 PasswordAuthentication。直接编辑主文件、把自己的指令追加到末尾,感觉上顺理成章,结果却是一份说一套、做一套的配置。

这条规则是 sshd 里最让几乎所有人都吃惊的一点:对每一个关键字来说,第一个被读到的值胜出。这和你用过的几乎所有其他配置合并方式都相反。Include 那一行就在主文件靠前的位置,所以 drop-in 文件会先于 sshd_config 的正文被读到——而在各个 drop-in 文件之间,则由字母顺序决定先后。一个叫 10-hardening.conf 的文件会赢过 50-cloud-init.conf;而一个叫 60-hardening.conf 的文件则会悄无声息地输给它,重启时系统还会告诉你一切正常。

这正是为什么你永远不该相信自己刚写好的那个文件。要去问守护进程它实际解析出的结果是什么:

sshd -t                 # syntax check — silence means valid
sshd -T | grep -Ei '^(permitrootlogin|passwordauthentication|pubkeyauthentication|kbdinteractive|allowusers|port) '

sshd -T 会打印出所有 include、覆盖和默认值都解析完毕之后的最终生效配置。如果输出里写着 passwordauthentication yes,那密码认证就是开着的,不管磁盘上哪个文件宣称了什么。除此之外的任何检查都不能算数。

第二个陷阱是同一类问题。Ubuntu 24.04 是通过 socket 激活来启动 sshd 的:监听端口归 ssh.socket 所有,sshd_config 里的 Port 会被完全无视。用 systemctl is-enabled ssh.socket 检查一下;如果它是启用状态,而你又想换个端口,就用 systemctl edit ssh.socket 加一条 ListenStream= 覆盖项来改——而不是改 sshd_config,因为那样设置看起来没问题,实际上什么作用都没有。

把 SSH 移出 22 端口:花架子,但便宜的花架子

这一条我们要老实说,因为网上的说法大多不老实。把 sshd 挪到 2222 或 47000 端口,挡不住任何一个像样的攻击者。对单台主机做一次完整的 TCP 扫描只要几秒钟,服务本身还会在 banner 里自报家门,任何真的决定专门盯上的人,会在喝完那杯咖啡之前就把它找出来。

它真正能做到的,是把你的认证日志量削掉大约九成五,而这确实有实际价值:这就是随手扫一眼日志,和真正把日志看进去之间的区别。看得见的信号,胜过被埋没的信号。只要你还在监控点什么,一份清静的 auth.log,就是让异常变得显眼的最便宜的办法。

代价虽小,却是真实存在的,这也是我们把它归为可选、而不是推荐做法的原因。八个月后你再回来时,大概率会忘了自己改过的端口。一个 1024 以上的非标准端口,理论上如果哪次 sshd 没有在监听,就有可能被某个无特权进程抢占。客户端网络上偏严格的出站防火墙会挡掉一些奇怪的端口,于是你偶尔会没法从某个办公室或酒店连回自己的服务器。而在使用 socket 激活的系统上,你还得按上一节说的,在正确的地方去改它。如果清静的日志对你重要,就去做。但做了之后不要就此觉得安全,更不要把它当成仅密钥登录的替代品。

默认拒绝,以及我们实际在用的规则集

一个列出该挡住什么的防火墙,只是一份归档记录。一个列出该放行什么的防火墙,才是真正的安全控制。这个区别就是全部关键所在,因为只有后者才能覆盖到你下个月才会装上的服务、某个周五顺手开的调试端口,以及某个自作主张要对外暴露自己的容器。

在我们的平台上,你有两层防护,而且彼此独立。边缘过滤是可选的,在面板里按服务器单独配置,并在虚拟化层强制执行,因此被拦下的流量根本到不了你的虚拟机——这对于那些你想在内核浪费一个周期去处理之前就拦下来的四层规则很有用,也能在你处理一台已经沦陷的虚拟机时,让外界暂时联系不上它。虚拟机防火墙则完全是你自己的地盘:nftables、iptables、pf,随你的镜像用什么。我们从不插手。如果这台机器很重要,两层都用上;至少后面这一层,任何时候都要用。

步骤 5 的规则集里有两点值得解释一下,因为大多数照抄来的规则集在这两点上都会出错。不要把所有 ICMP 全部丢弃。这样做看起来很干净,却会破坏路径 MTU 发现(path-MTU discovery),进而制造出最难缠的一类 bug:小请求能通,大响应却会卡住,日志里还完全看不出跟防火墙有关。至少要放行 destination-unreachabletime-exceededparameter-problem除非你清楚具体清单,否则不要按类型过滤 ICMPv6。IPv6 依赖 ICMPv6 来做邻居发现和路由器通告;大范围拦截它,会让你的 IPv6 连通性以一种看起来像路由故障的方式死掉。在单台主机上直接放行全部 ICMPv6,是一个合理的取舍,下面的规则集也正是这么做的。

在你执行 flush ruleset 之前有一个提醒:如果装了 Docker,这一行会把 Docker 写入的 NAT 和 filter 规则一并清空,容器网络会中断,直到 systemctl restart docker 把它们放回去为止。先加载你自己的规则集,再重启 Docker,并且在发布任何一个容器端口之前,先读一读下一节。

那些你没意识到是打开着的端口

去问问这台机器自己在监听什么,而不是问你以为自己装了什么——看看现在究竟绑定了哪些端口:

ss -tulpn | grep -vE '127\.0\.0\.1|\[::1\]'

能躲过这个过滤条件留下来的每一项,都能从机器自身以外的地方访问到。在一个原版镜像上,常见的发现有:111 端口上的 rpcbind(你运行的任何东西都用不到它)、基础安装遗留下来的 exim4 监听,以及一个 systemd-resolved 的 stub——只监听 loopback 时无害,一旦不是,就会变成一个开放解析器。真正危险的那些,往往是后来跟着你特意装的软件一起来的:因为某篇教程让你改 listen_addresses、于是监听在 0.0.0.0:5432 上的 PostgreSQL;因为 Redis 大半辈子默认就没有密码、所以没设密码的 Redis;一个 Elasticsearch 节点;一个 Prometheus exporter;一个 Jupyter notebook。这些东西里的每一个,都曾经无数次地充当过真实入侵事件的第一步。

现在来说那个连细心人都会中招的陷阱。Docker 不会征求你防火墙的同意。当你写下 -p 5432:5432 时,Docker 会往 nat 表的 PREROUTING 链里插入 DNAT 规则,而内核对这条链的处理先于你的 input 链看到这个包;随后流量就被转发给了容器。你那个默认拒绝的 input 策略根本没被问过,ufw status 照样显示端口是关闭的,而数据库其实已经在公网上了。这是 Docker 有文档记载的行为,十年来一直如此,也因此暴露过数量惊人的数据库。

修复办法就一行,也是值得养成的习惯:

# wrong — reachable from the entire internet, whatever your firewall says
docker run -p 5432:5432 postgres

# right — bound to loopback, reachable only through an SSH tunnel or a proxy
docker run -p 127.0.0.1:5432:5432 postgres

最后,找个外部视角确认一下。从服务器本机发起的端口扫描,告诉你的是内核怎么想;从别处发起的扫描,告诉你的才是外界实际看到的样子,而这才是唯一重要的数字。从你的笔记本电脑——而不是从那台机器本身——运行 nmap -Pn -p- <your-ip>,把结果和你原本预期的列表对一下。

自动更新:打开它,并且是真心打开

真正让小型服务器沦陷的窗口期,从来不是零日漏洞,而是从一个 CVE 连同可用的概念验证一起公开,到你下一次碰巧登录之间的那四个星期。攻击工具在几天之内就能吃透一个新出现的远程漏洞;而一台只在“有空的时候”才打补丁的机器,会在整段窗口期里持续暴露,这段时间到底有多长,每一个诚实的运维都心知肚明。

反对自动更新的理由是怕把东西弄坏,这一点值得认真对待——然后把范围收窄。把自动化限制在你所用发行版的安全分支上,维护者在那里是把补丁回移植到你正在用的这个版本里,而不是直接推送上游的新版本。Debian 给 openssl 打的安全更新,是同一个版本号的修补版本;它改变行为的风险,比让一个远程漏洞敞开一个月的风险要小得多。功能性升级则继续保持手动,那才是它该待的地方。

大家常常漏掉的部分是重启。磁盘上打好补丁的 libssl,对那个启动时就已经映射了旧版本的进程毫无帮助;内核更新在你真正启动进那个新内核之前,也完全不起作用。要么在你自己选定的时间窗口里接受一次无人值守的重启,要么装上 needrestart,让它告诉你哪些服务还在用已经被删除的库跑着——但这两件事至少要做一件。当 uptime 显示已经运行了 340 天时,还说“自动更新已经打开”,这是一种让人安心的错觉,也是我们见过最常见的一种。

fail2ban、CrowdSec,还是干脆什么都不用

fail2ban 会读取你的日志,发现某个地址反复失败之后把它封禁一段时间。CrowdSec 做的是同一件事,还会把判定结果分享给一整个参与者网络,这样一个在别处已经作恶过的地址,在碰到你之前就可能已经被挡掉。两者都是好软件。但在一台仅密钥登录的 SSH 服务器上,它们做的事,都不是大多数人以为的那样。

一旦 PasswordAuthentication 设成 no,SSH 暴力破解就不可能成功——不是“不太可能成功”,而是根本没有那条代码路径。所以,在失败五次后封禁一个地址,其实什么都防不住;它减少的只是日志量,以及微不足道的一点 CPU。这是一个真实的好处,只是它不是一个安全上的好处,而且这笔交易也不是免费的:一个规则写错、盯错了日志的 jail,把管理员自己锁在服务器外面的次数,比它锁住攻击者的次数还要多。

这类工具真正能派上用场的地方,是再上一层——用在你实际对外暴露的服务上:一个登录表单、一个 WordPress 后台、一个带速率限制的 API、一个开着 SMTP AUTH 的邮件服务器。这些服务确实接受密码,确实可以被暴力破解,一份封禁名单正是这时候对症的控制手段。所以我们的结论范围很窄,也很具体:跳过 SSH 上的 jail,把 CrowdSec 或 fail2ban 放在真正有密码框的东西前面。而且不管选哪一个,都先把你自己的管理地址加入白名单——ignoreip 这个选项存在的意义,就是为了不让你在某个晚上,因为一些不光彩的理由而牢牢记住它。

让机器在发生变化时主动告诉你

预防,是你能在一个小时里做到的事。检测,则是用来告诉你这一个小时够不够的事。它不需要多复杂,在单台服务器上,三件成本很低的事就能覆盖大部分范围。

把日志留住。在不少镜像上,journal 存放在 /run 里,重启就会蒸发,也就是说一次事件的记录,会在紧随其后的那次重启里一并消失。创建 /var/log/journal 只要一行命令,却是这一节里性价比最高的一件事。

让登录动作通知你。/etc/ssh/sshrc 里加一行,让每次会话开始时都触发一次 logger,不花什么成本,却能给你一份干净、可以直接 grep、又独立于 sshd 自身那堆日志之外的记录。有一个容易让人踩坑的地方:如果用户自己有 ~/.ssh/rc,sshd 会运行它,而不是 /etc/ssh/sshrc,于是这份系统级文件,恰恰会在最可能自定义了 dotfile 的那个账号上被跳过。如果你需要一个不会被这样遮蔽掉的钩子,改用 pam_exec

记住文件系统在第一天是什么样子。AIDE 会记录你的二进制文件、库文件和 unit 文件的哈希值,并汇报此后发生了什么变化。这里有一个根本性的、却常被忽视的陷阱:如果数据库就存放在它所监视的这台机器上,那么入侵者可以把它重新生成一遍,之后这项检查就会永远报告“没有变化”。把数据库拷到机器之外,或者至少把它的哈希值记在别处,这个工具花掉的那二十分钟才算值回票价。留在原地不动的话,它就只是一条让人安心的毯子。

这里有一条通用原则,值得单独拎出来说:你无法信任的日志,就是那份留在被攻破的机器上的日志。任何你真心打算依赖的东西——一份 journal、一个完整性数据库、一份备份——都应该在同一个攻击者控制不到的地方留一份副本。在另一个司法辖区放一台小型服务器,是对此合理的答案,五美元一个月的那种就够用。

这套做法做不到什么

以上这一切都是边界层面的工作,值得把这条边界画清楚,免得你把一扇锁好的前门误当成一整个保险柜。

它保护不了正在运行的机器上的数据。对任何拥有虚拟化层访问权限的人来说,一个加固过的内核和一道关闭的防火墙都无关紧要,对机器运行时内存里的内容同样如此。如果你担心的是服务器关机、被查扣或者被淘汰之后的那块磁盘,那是磁盘加密要解决的问题,它是另一套流程,也有另一种失败方式——参见用 LUKS 加密 VPS 磁盘

它不会让你匿名。一台加固得无懈可击的服务器,照样可能通过 WHOIS 记录、一个重复使用的 SSH 密钥、一个分析统计标签、一个从家里直连、没有跳板的 SSH 客户端,或者一张把两个身份绑在一起的证书,把自己的所有者暴露出去。用 Monero 付了款,之后却从你自己的地址登录,这笔付款的匿名性就白费了。这种失败方式有自己单独的一篇:那些会让你现出原形的失误

它修不好你的应用程序。一个 SQL 注入、一个未经身份验证的管理接口,或者一个带后门的依赖包,根本不在乎你的 sshd 设置成什么样。大多数运维良好的服务器遭到入侵,走的都是你自己特意、主动打开的那个端口,通向你自己写的或装的那个服务。

它不是备份。勒索软件、一个变量恰好为空时执行的 rm -rf,还有一次失败的升级,最后都会走向同一个结局。做一份副本,放到别的地方,并在真正需要之前先演练一次恢复——让迁移到另一家主机商也能全身而退的那种纪律,同样能让糟糕的某个星期二全身而退。

这些都不是在说这一个小时不值得花。而是在说,你应该清楚知道这一个小时到底换来了什么。

  1. 用密钥部署,永远不要用密码

    在你自己的机器上生成密钥对,把公钥那一半粘贴进部署表单;镜像会自动装好它,你也就永远不会有一个可能丢失的 root 密码。如果你部署的镜像标了 cloud-init,还可以把整个第一小时的配置直接当作 user-data 传进去——最多 64 KiB——机器启动时就已经是加固好的状态。

    # on your laptop, not on the server
    ssh-keygen -t ed25519 -a 100 -C "$(whoami)@laptop-cvh"
    cat ~/.ssh/id_ed25519.pub        # paste this into the deploy form

    优先用 ed25519,除非你工具链里有什么东西不认它,那种情况下用 4096 位的 RSA 也可以。给私钥加上密码短语保护,并把它加载进一个 agent 里;笔记本电脑上一把没有密码短语的密钥,就等于把密码写在了显示器上。同一套密钥也会被注入救援模式,这正是步骤 4 能够被恢复的原因。

  2. 在动手之前,先打开第二个会话

    这一步不是可选项,也不是过度紧张。从现在开始,你要改动的正是你连接所依赖的那个守护进程,以及让你能连上它的那道防火墙。留一个会话开着、什么都不做,当成一条能把你拉回机器的绳子,所有改动都在另一个会话里进行。如果某个改动出了错,那个开着的会话依然是已认证状态,可以用来撤销它;而一个新连接则会被直接拒绝。

    # terminal A — the rope. Log in and leave it alone.
    ssh -i ~/.ssh/id_ed25519 root@<server-ip>
    
    # terminal B — where every command below runs.
    ssh -i ~/.ssh/id_ed25519 root@<server-ip>

    每做一个改动,就打开第三个连接来测试,永远不要靠重新连接你正在操作的那个会话来测试。如果第三个连接失败了,你手上依然有两个能用的 shell,那只是一个问题,还称不上一场危机。

  3. 创建你真正会用来干活的账号

    通过 SSH 直接用 root,方便是方便,但也就方便这一个小时。过了这个阶段,一个拥有 sudo 权限、带具名的账号,能给你留下审计记录,防止一条打错的命令在完全权限下执行,还能让你在某个账号被攻破时禁用那个账号,而不必禁用整台机器。

    adduser --disabled-password --gecos "" deploy
    install -d -m 0700 -o deploy -g deploy /home/deploy/.ssh
    cp /root/.ssh/authorized_keys /home/deploy/.ssh/authorized_keys
    chown deploy:deploy /home/deploy/.ssh/authorized_keys
    chmod 0600 /home/deploy/.ssh/authorized_keys
    usermod -aG sudo deploy
    
    # a long random password, stored in your manager, used only by sudo
    passwd deploy

    一定要把这个密码设置好。一个从未设置过密码的 sudo 账号是没法用 sudo 的,而如果是在禁用了 root 登录之后才发现这一点,就是明明每个文件都配置对了、却还是把自己锁在外面的经典翻车现场。继续下一步之前先验证一下:开一个新终端,执行 ssh deploy@<server-ip>,再执行 sudo -v。这两步都必须成功。

  4. 写好 SSH 的 drop-in 配置,再去问守护进程它读到了什么

    按照前面说的排序规则,一个编号靠前的 drop-in 文件会赢过云镜像自带的任何设置。先写好它,校验语法,读回实际生效的配置,然后才去 reload。

    cat > /etc/ssh/sshd_config.d/10-hardening.conf <<'EOF'
    PermitRootLogin no
    PasswordAuthentication no
    KbdInteractiveAuthentication no
    PubkeyAuthentication yes
    AuthenticationMethods publickey
    PermitEmptyPasswords no
    AllowUsers deploy
    MaxAuthTries 3
    LoginGraceTime 20
    X11Forwarding no
    AllowAgentForwarding no
    AllowTcpForwarding no      # remove this line if you use ssh -L / -D tunnels
    ClientAliveInterval 300
    ClientAliveCountMax 2
    EOF
    chmod 0644 /etc/ssh/sshd_config.d/10-hardening.conf
    
    sshd -t                                    # silence means the syntax is valid
    sshd -T | grep -Ei '^(permitrootlogin|passwordauthentication|allowusers) '
    systemctl reload ssh

    要在 reload 之前读那份 sshd -T 的输出,而不是之后。它必须写着 permitrootlogin nopasswordauthentication no。如果没有,说明你的文件被某个排序更靠前的文件覆盖了——用 ls /etc/ssh/sshd_config.d/ 看一下,把你的文件改个排序更靠后的名字。现在打开第三个终端,以 deploy 身份登录。只有这一步能成功,你才可以放开那条绳子。

  5. 加载一个默认拒绝的防火墙

    两个发行版都自带 nftables,它会用一份可读性很好的单一文件取代原来的 iptables 规则集。把放行端口改成你实际在跑的服务——下面这份列表假定只有 SSH 和一个 Web 服务器,别的什么都没有。

    apt install -y nftables
    cat > /etc/nftables.conf <<'EOF'
    #!/usr/sbin/nft -f
    flush ruleset
    
    table inet filter {
      chain input {
        type filter hook input priority filter; policy drop;
    
        ct state established,related accept
        ct state invalid drop
        iif lo accept
    
        # ICMP must live: dropping it blackholes path-MTU discovery.
        ip protocol icmp icmp type { echo-request, echo-reply, destination-unreachable, time-exceeded, parameter-problem } accept
        ip6 nexthdr icmpv6 accept
    
        tcp dport 22 ct state new accept
        tcp dport { 80, 443 } ct state new accept
    
        limit rate 5/minute burst 5 packets log prefix "nft-drop: " level info
      }
      chain forward { type filter hook forward priority filter; policy drop; }
      chain output  { type filter hook output  priority filter; policy accept; }
    }
    EOF
    
    nft -c -f /etc/nftables.conf              # check before you commit to it
    systemctl enable --now nftables
    nft list ruleset | head -40

    如果装了 Docker,flush ruleset 也会把 Docker 的规则一起清掉:之后要执行 systemctl restart docker,并确认你的容器仍然能正常响应。然后,从你的笔记本电脑上执行 nmap -Pn -p- <server-ip>,检查开放端口列表和你刚放行的端口是否一致——不多,也不少。

  6. 打开无人值守的安全更新

    只针对安全分支,并且要有一个你自己选定、而不是不断往后拖的重启窗口。挑一个对你的用户来说比较清闲的时段,而且不要挑整点,免得和其他人的 cron 撞在一起。

    apt install -y unattended-upgrades needrestart
    cat > /etc/apt/apt.conf.d/51-cvh-auto <<'EOF'
    // Debian. On Ubuntu, replace the two Origins-Pattern lines with:
    //   "${distro_id}:${distro_codename}-security";
    Unattended-Upgrade::Origins-Pattern {
      "origin=Debian,codename=${distro_codename}-security,label=Debian-Security";
      "origin=Debian,codename=${distro_codename},label=Debian-Security";
    };
    Unattended-Upgrade::Automatic-Reboot "true";
    Unattended-Upgrade::Automatic-Reboot-WithUsers "false";
    Unattended-Upgrade::Automatic-Reboot-Time "04:17";
    Unattended-Upgrade::Remove-Unused-Kernel-Packages "true";
    APT::Periodic::Update-Package-Lists "1";
    APT::Periodic::Unattended-Upgrade "1";
    EOF
    
    unattended-upgrade --dry-run --debug | tail -25
    systemctl status unattended-upgrades --no-pager

    如果无人值守的重启确实无法接受,就把它设成 "false",改让 needrestart -b 来报告哪些服务还在用已经被删除的库运行,以及是否已经装好了更新的内核——然后针对报告采取行动。真正不能接受的,是两件事一件都不做。

  7. 关掉那些还在监听的东西,并从外部核实

    列出所有绑定在 loopback 以外的东西,删掉你用不到的部分,再从另一台机器上核实结果。

    ss -tulpn | grep -vE '127\.0\.0\.1|\[::1\]'
    
    # the usual leftovers on a base image
    systemctl disable --now rpcbind.socket rpcbind 2>/dev/null
    apt purge -y exim4-base exim4-config exim4-daemon-light 2>/dev/null
    apt autoremove --purge -y
    
    # what each running unit is allowed to reach
    systemd-analyze security --no-pager | head -20

    对于那些必须继续运行、却不需要任何观众的东西——一个数据库、一个缓存、一个管理面板、一个指标 exporter——在它自己的配置里把它绑定到 127.0.0.1,再通过一条 SSH 隧道去访问它:ssh -L 5432:127.0.0.1:5432 deploy@<server-ip>。这严格来说都要好过开一个端口、然后寄望于那个服务自己的认证机制靠得住,也是默认情况下就该采用的模式。

  8. 记录基线状态,并给警报上膛

    这十分钟只有在出问题的那一天才会回本——而那一天,你光靠脑子是拼不出事情经过的。

    # persistent journal, so a reboot stops erasing the evidence
    mkdir -p /var/log/journal
    systemd-tmpfiles --create --prefix /var/log/journal
    systemctl restart systemd-journald
    
    # a clean, greppable line per SSH session
    cat > /etc/ssh/sshrc <<'EOF'
    logger -t ssh-login "user=$USER from=${SSH_CONNECTION%% *} tty=${SSH_TTY:-none}"
    EOF
    
    # file-integrity baseline — then get the database off the machine
    apt install -y aide
    aideinit
    mv /var/lib/aide/aide.db.new /var/lib/aide/aide.db
    sha256sum /var/lib/aide/aide.db     # copy this hash somewhere else
    
    last -n 20 ; lastb -n 20            # who got in, and who tried

    最后把四样东西记在服务器之外:用 ssh-keygen -lf /etc/ssh/ssh_host_ed25519_key.pub 得到的 SSH 主机密钥指纹、上面那个 AIDE 哈希值、你特意打开的那些端口,以及私钥存放的位置。正是这份记录,能让一个糟糕的早晨变成按清单排查,而不是一场调查。

对比

这些防护手段,按它们实际换来的东西排序

把第一小时里的每一项防护手段,放到那两个决定它是否配得上一席之地的问题面前:它真正挡住了什么,又挡不住什么——不管教程里怎么暗示。
防护手段能挡住什么挡不住什么成本结论
仅密钥登录的 SSH(PasswordAuthentication no)网上所有靠猜密码过日子的机器人,从原理上就被永久挡在外面持有你私钥的人,或者 sshd 自身的漏洞5 分钟,一次性没得商量
默认拒绝的防火墙(nftables)你忘了关掉的监听服务,以及以后不小心装上的每一个服务针对你自己特意打开的端口发起的攻击10 分钟,一次性没得商量
无人值守的安全更新n-day 窗口期——漏洞利用公开之后,到你下一次登录之间的那几周零日漏洞,以及任何需要重启、而你从不安排重启的问题5 分钟,外加一个重启窗口没得商量
用具名 sudo 账号代替 root带着完整权限执行的打错的命令,以及那些没理由却以 root 身份运行的进程落在已经混进来的人手里的本地提权漏洞3 分钟值得做
文件完整性基线(AIDE)对系统二进制文件、库和 unit 文件的悄悄改动只要数据库还留在它监视的那台机器上,就什么都挡不住20 分钟,外加一处机外存储机器重要的话就值得做
把 SSH 移出 22 端口大约九成五的认证日志量任何会做端口扫描的人——而这基本就是所有真正需要防的人2 分钟,外加将来某个忘记端口号的夜晚可选,为了清静的日志
在 sshd 上用 fail2ban / CrowdSec惯犯,以及他们在你身上浪费掉的 CPU一旦密码认证已经关闭,就什么都挡不住10 分钟,外加真实存在的锁死风险SSH 上跳过;用在你的应用上
FAQ

常见问题解答

部署之后多久会开始被扫描?

通常只要几分钟。覆盖全网的扫描器在持续不断地扫描整个 IPv4 地址空间,所以不管这台机器上跑着什么,下一轮扫描就会碰到这个新地址。从机器应答第一个数据包的那一刻起,就该认定自己正在被探测——这也是为什么加固要在第一个小时里完成,而不是拖到第一个周末。

如果已经禁用了密码登录,还需要 fail2ban 吗?

在 SSH 上,不需要。设了 PasswordAuthentication no 之后,暴力破解就没有任何一条能赢的代码路径,所以失败五次后封禁,什么都防不住——它减少的是日志噪音,这有一定价值,但不是安全价值。这类工具真正该待的地方,是任何真正接受密码的东西前面:一个网页登录表单、一个管理面板、SMTP AUTH。如果你要装一个,先把自己的地址加入白名单。

该用 ufw、firewalld,还是 nftables?

三个都可以,只要默认策略是 drop,而且你清楚自己部署的到底是什么。ufw 最友好,在当前的 Debian 和 Ubuntu 上,它底层写的其实也是 nftables 规则。纯 nftables 就是一份可读性很好的单一文件,方便你做 diff 和版本管理,这也是我们用它的原因。具体选哪个,重要程度远不如默认策略——而且不管选哪一个,都改变不了 Docker 会在它们底下发布端口这个事实。

应不应该把 SSH 移出 22 端口?

只是为了让日志清静一些才值得做。它挡不住任何一个像样的攻击者——完整扫描一遍只要几秒钟,banner 也会直接暴露服务身份。它确实能去掉认证日志里大部分的噪音,让真正的异常变得显眼。把它当成日志卫生来看待,而不是一项安全控制;改之前先确认你的 sshd 是不是通过 socket 激活的:在 Ubuntu 24.04 上,sshd_config 里的 Port 指令会被无视,端口归 ssh.socket 管。

自动更新会不会在凌晨四点把我的网站搞挂?

只限制在安全分支的话,非常少见。那些包都是给你正在运行的版本打的回移植补丁,不是上游的新版本。现实中的风险在于重启,而不是补丁本身——所以自己选定重启窗口,把 Automatic-Reboot-WithUsers 设成 false,让它在有人登录时先等一等;如果某个服务真的没法无人值守地重启,就定期跑一下 needrestart -b,并根据它的报告采取行动。唯一站不住脚的做法,是开着自动更新,却让 uptime 以年为单位增长。

我把自己锁在外面了,还有什么办法?

从面板把服务器启动进救援模式。它会在内存里启动一个 Alpine 环境,带着和你账户一样的 SSH 密钥,你的磁盘则以 /dev/vda 的形式处于未挂载状态,你可以把它挂载上去,修好 sshd 的 drop-in 文件或者防火墙配置文件,再卸载、重启。救援启动本身不会碰磁盘上的任何东西。救援环境的主机密钥指纹和你平时那个不一样——这是正常现象,不是被人截获了。

用 Lynis 或者 CIS 加固脚本代替这篇指南,是个好主意吗?

当成审计工具,是;当成替代品,不是。Lynis 确实是一个有用的第二意见,能找出这个页面没提到的东西。自动化的 CIS 修复脚本则是另一回事:它们会应用成百上千条针对企业工作站机群设计的改动,其中好几条会以几周后很难追查的方式,把一台服务器搞坏。先自己动手把这一个小时做一遍,弄清楚你的机器到底在做什么,然后再跑一个审计工具,把它给出的结果一条一条读过去。

加固之后,我的服务器就匿名了吗?

不会,把这两件事混为一谈是一个常见、而且代价不小的错误。加固控制的是谁能进得来;匿名控制的是谁能看出这是你的。一台锁得再严密的服务器,照样可能通过 WHOIS 记录、一个重复使用的 SSH 密钥、一个分析统计标签,或者一次从家庭地址发起的管理员登录,把所有权泄露出去。用 Monero 付了款,之后却直接用自己平时的网络连接过去,这笔付款的匿名性就彻底作废了——这一点在那些会让你现出原形的失误里有详细说明。

Deploy your offshore server.

选择地区。选择套餐。粘贴密钥。付款。接下来的47秒由我们负责。