攻击地图上,某一个国家的上方有一片浓重的色块,于是「干脆把整个国家封掉」这条规则自己就冒了出来。它几乎什么都换不来:更换出口国家只需攻击者几分钟,而落入封禁的却是你自己的访客,以及出差在外的你本人。更糟的是,来自单一云平台、本来看得见的爬虫,在被封之后会迁到住宅地址上,从此与真人无从分辨。

下面是在同一台服务器上确实能见效的五个步骤:切断扫描器、限制速率、不要切断必需的东西,以及在确实需要时正确地封锁。全部内容一次就能检查并设置完毕。

第一步:看清究竟是谁造成了这片色块

先找到请求日志。在放了多个站点的服务器上,access.log 往往是空的,记录会进入各个虚拟主机自己的日志:

sudo ls -lS /var/log/apache2/*access*.log /var/log/apache2/*requests*.log 2>/dev/null

后面所有命令里都要换成在这份清单中排在最前面的那个文件。先看一天之内排名靠前的地址,再看排在最上面的那个究竟在请求什么:

sudo awk '{print $1}' /var/log/apache2/access.log | sort | uniq -c | sort -rn | head -20
sudo grep '^203\.0\.113\.' /var/log/apache2/access.log | awk '{print $7}' | sort | uniq -c | sort -rn | head -20

第二条命令回答了关键问题。如果输出里是你自己网站的数十个不同页面,那是内容采集器:无害,只要不把它算作访客就够了。如果里面是 /wp-login.php、/.env、/phpmyadmin 以及其他你根本没有的东西,那就是扫描器,第二步正是用来对付它的。nginx 的日志放在 /var/log/nginx/,字段相同。

第二步:为请求你并不存在之物设一个 jail

请求的路径伪造不了——与地址和 User-Agent 不同,它本身就是意图。过滤器 /etc/fail2ban/filter.d/web-probes.local:

[Definition]
failregex = ^<HOST> .* "(GET|POST|HEAD) /(wp-login\.php|wp-admin|xmlrpc\.php|\.env|\.git/|phpmyadmin|administrator/)
ignoreregex =

jail /etc/fail2ban/jail.d/web-probes.local:

[web-probes]
enabled  = true
port     = http,https
filter   = web-probes
logpath  = /var/log/apache2/*access.log
maxretry = 1
findtime = 1h
bantime  = 1d

这里的 maxretry = 1 是有道理的:没有人会偶然访问别人的后台。应用之后,确认过滤器确实能命中你自己日志里的记录:

sudo fail2ban-regex /var/log/apache2/access.log /etc/fail2ban/filter.d/web-probes.local
sudo systemctl reload fail2ban && sudo fail2ban-client status web-probes

第三步:限制速率

扫描器和爬虫都会因速度暴露自己,与它们来自哪个国家无关。单个地址每分钟三十个页面,是一道不会碰到正在翻看目录的人的门槛:

[http-flood]
enabled  = true
port     = http,https
filter   = apache-overflows
logpath  = /var/log/apache2/site_access.log
maxretry = 30
findtime = 60
bantime  = 1h

关键细节在于 logpath 里写的是哪一个日志。如果这个匹配式把控制面板本身或后台的日志也包含进去,你就会把自己封掉:面板上的一个页面会带出十来个对样式和脚本的请求,三十个点两下就凑齐了。因此要给面板单独一个与 jail 匹配式不相符的日志文件,并给静态文件设置较长的缓存期限,让浏览器不必在每次换页时重新请求。万一还是发生了,症状是:HTML 到了,样式没到,页面卡住。解除封禁:

sudo fail2ban-client set http-flood unbanip 203.0.113.10

第四步:保护那些不能被切断的东西

在开启封锁之前先写好例外——否则第一个落入封锁的恰恰是必需的那些:

[DEFAULT]
ignoreip = 127.0.0.1/8 ::1 203.0.113.0/24 66.249.64.0/19

这一行里必须有什么,以及为什么:

  • 你自己的地址或办公室所在的子网。失去服务器访问权限最常见的原因是自己写的规则,而不是攻击;
  • 66.249.64.0/19 这一段。Google 的搜索机器人可以凭 User-Agent 辨认,但它执行 JavaScript 的那一部分以普通的 Chrome/Linux 身份到来,并不自报家门。任何「封锁来自数据中心的流量」之类的规则,打中的正是它,随后页面就会悄无声息地掉出索引。可疑地址可以用反向查询来核对:dig -x 66.249.66.1 +short 应当返回 googlebot.com 区域内的名称;
  • 支付通知的来源地址。支付系统的 webhook 来自 AWS 和 Azure 的服务器。若把这些云整片关掉,支付确认就再也到不了你这里,而你得知此事的时刻,是客户写信说钱已扣、权限却没有的时候。具体网段取自你所用支付服务商的文档。

第五步:若确实需要封锁,封子网而不是封国家

来自单一网络、持续数日并伴随同一类请求的稳定聚集,说明这是一个照着名单往下走的执行者。这种情况要精确地、限期地封锁:

sudo ufw deny from 198.51.100.0/22 to any
sudo ufw status numbered

用第二条命令记下规则的编号:日后正是要凭它来解除封锁,sudo ufw delete <编号>。和自己约定一个期限——比如一个月——并把解除写进日历。那些「永久」加上去的规则,一年之后会变成谁都不敢动的一份名单,因为谁也记不清这些地址当初是凭什么进去的。

该封哪个子网,可以从第一步得到的地址查起:whois 198.51.100.17 | grep -iE 'route|cidr|netname'。

这五步之后有什么变化

扫描器按意图而非按来源被切断,并且在第一个请求时就被抓住。爬虫撞上速率上限。搜索机器人和支付通知照常通过。唯一还算宽的封锁也是精确的,有明确的期限和清楚的理由。

地图并没有因此消失,但它的角色变了:它的用处不是关闭国家,而是让你察觉到这种聚集本身,并把它与一周前作比较。这需要的是历史记录,而不是当天的一张快照。这样一张地图与封禁日志、开放端口清单摆在一起是什么样子,下面的演示页面可以看到。