SSD Nodes Learn 🎉 VPS $5.50/月起
指南 Matt Connor作者: Matt Connor

如何防止注册表单遭遇订阅轰炸

攻击者可将受害者地址同时提交到数百个表单。启用确认式订阅并限制提交频率,让服务器最多发送一封确认邮件,避免持续轰炸。

什么是订阅轰炸?

订阅轰炸是一种利用您的注册表单淹没他人收件箱的攻击。攻击者获取一名受害者的电子邮件地址,然后在很短时间内将其提交到数百或数千个未受保护的表单。每个网站都会向该地址发送欢迎邮件或确认邮件。这些邮件集中在一起,会掩盖受害者真正需要阅读的邮件。

攻击目标是收件箱的所有者。当收件箱被订阅确认邮件塞满时,攻击者可能正在使用该受害者的银行卡消费,或重置其某个账户的密码。银行发出的欺诈警报仍会送达,但它会被同一小时内收到的其他两千封邮件压在下面,因此无人能及时看到。

您的服务器只是发起攻击所使用的工具。您的服务器没有损坏,您的任何账户也没有被入侵。有人将一个地址输入公共表单,而您的软件按设计执行了操作:向该地址发送邮件。这正是此类攻击难以发现的原因。您的日志中不会出现入侵记录,因为实际上没有发生入侵。

从您这边看,攻击是什么样的

攻击通常有两种表现。

明显的一种是短时间内集中爆发。几分钟内,某个表单收到数百个 POST 请求,来源 IP 地址各不相同,提交的地址来自您以前从未发送过邮件的域名。只要查看日志,就很容易发现这种情况。

隐蔽的一种最容易被忽略。攻击者维护着数千个存在漏洞的表单列表,因此您的表单每小时只需贡献一两次提交。Jye Cusch 在其运营的网站上描述过一次完全属于这种形式的攻击:没有流量峰值,只有持续不断的注册,而且发生时间与受众的活跃时间不符。单个表单看起来没有异常,因为它实际上几乎没有执行任何操作。造成的损害,来自攻击者列表中的所有表单之和。

这两种表现之后都会出现同一个特征:后续什么也不会发生。这些地址从不确认订阅,从不打开邮件,也从不点击链接。在确认式选择加入列表中,它们会永远保持 unconfirmed 状态,而这批地址是您能够获得的最明确证据。

首先,在访问日志中统计每分钟的提交次数。

sudo awk '/POST \/subscription\/form/ {print substr($4, 2, 17)}' \
  /var/log/nginx/access.log | uniq -c | sort -rn | head

默认 combined 日志格式中的 $4 是方括号内的时间戳,因此此命令会按分钟统计数量,并按从高到低的顺序显示。一个通常每天收到 4 次注册的表单,如果在 1 分钟内收到 60 次注册,这就不是正常情况。

确认式订阅:效果最大的防护措施

确认式订阅通常称为双重订阅。只有当用户点击发送到该地址的邮件中的链接后,该地址才会成为订阅者。启用此功能后,每个提交的地址最多只会产生一封邮件。该地址不会加入列表,因此不会收到营销活动邮件或欢迎邮件序列。

listmonk 自托管 newsletter 服务器中,这是每个列表独立的设置:列表可以使用单重订阅,也可以使用双重订阅。文档明确说明了两者的区别。在双重订阅列表中,订阅者“必须点击收到的确认邮件,明确接受订阅。在此之前,他们不会收到营销活动邮件。”订阅者首先处于 unconfirmed,点击链接后变为 confirmed;只有处于订阅状态 confirmed 的订阅者才会在订阅列表中收到营销活动邮件。

需要准确理解这项措施的作用。确认式订阅不会将您的贡献降为零,而是将其限制为每个地址一封邮件。受害者仍会收到这封邮件,而来自一千个站点的各一封邮件就足以构成攻击。确认式订阅消除的是后续所有邮件:您的列表保持干净,也不会向从未请求第一封邮件的人发送第二封邮件。

另外还有两个重要设置,而且很容易遗漏。第一,限制确认邮件的重新发送次数。如果同一个地址可以反复提交,并且每次都收到另一封确认邮件,攻击者就不需要一千个表单,因为仅凭您的表单就能发送一千封邮件。对于已经处于该列表中 unconfirmed 状态的地址,至少一天内不应再发送任何邮件。第二,定期删除未确认的记录。一个三十天内未完成确认的地址不是待确认订阅者。保留它只会增加之后因误操作向其发送邮件的可能性。

在反向代理处限制注册表单的速率

应将限制放在应用前面,而不是应用内部。代理阻止请求后,不会打开数据库连接,也不会开始 SMTP(简单邮件传输协议)会话。应用内部的限制要等请求消耗了一个工作进程并执行了一次查询后才生效;在许多技术栈中,消息甚至会在任何滥用检查运行前就进入队列。代理限制也不会因应用升级而失效,因为它不在将被替换的代码中。

下面的示例使用 nginx。无论您在应用前运行哪种反向代理,都可以采用相同思路,但指令名称会有所不同。

将以下内容放入 http 块中,例如保存到 /etc/nginx/conf.d/signup-limit.conf

map $request_method $signup_key {
    POST    $binary_remote_addr;
    default "";
}

limit_req_zone $signup_key zone=signup:10m rate=2r/m;
limit_req_status 429;
limit_req_log_level warn;

map 承担了实际的限制工作。nginx 不会统计键为空字符串的请求,因此只有 POST 请求会进入该区域。用户多次加载注册页面不会消耗配额。如果没有这个 map,用户刷新页面两次后,在真正提交表单前就会耗尽自己的配额。

$binary_remote_addr 是采用紧凑格式保存的客户端地址,因此 10 MB 区域大约可以容纳 160,000 个地址。rate=2r/m 允许每三十秒提交一次。limit_req_status 429 返回 HTTP 429 Too Many Requests,而不是 nginx 默认的 503。这是更准确的状态码,也是客户端库所预期的状态码。

然后在站点的 server 块中加入:

location = /subscription/form {
    limit_req zone=signup burst=3 nodelay;
    proxy_pass http://127.0.0.1:9000;
    proxy_set_header Host $host;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}

burst=3 nodelay 允许用户因双击按钮而产生的请求通过,并会立即拒绝第四个请求,而不是将其加入队列。

sudo nginx -t && sudo systemctl reload nginx

nginx -t 应输出 configuration file /etc/nginx/nginx.conf test is successful。现在快速提交表单五次,并监控错误日志:

sudo tail -f /var/log/nginx/error.log

被阻止的请求会写入一行日志。您要查找的字符串如下:

2026/08/13 09:14:22 [warn] 812#812: *4412 limiting requests, excess: 3.400 by zone "signup", client: 203.0.113.10, server: news.example.com, request: "POST /subscription/form HTTP/1.1", host: "news.example.com"

完全没有日志行,表示限制没有生效。通常的原因是 limit_req 位于请求无法到达的 location 块中。因此,请连续几次使用 curl -si -X POST https://news.example.com/subscription/form 检查,并确认获得 429

在依赖基于 IP 的限制前,还需要了解两个问题。

如果位于 CDN 或其他代理之后,$binary_remote_addr 就是该代理。 所有访问者都会进入同一个配额桶,因此每分钟最先提交的几个人会把其他所有人都拒之门外。请使用真实 IP 模块修复此问题:为 CDN 发布的每个地址范围添加 set_real_ip_from(Cloudflare 在 cloudflare.com/ips 列出了其地址范围),并添加 real_ip_header CF-Connecting-IP。读取访问日志中的 $remote_addr,确认其中是访问者地址,而不是 CDN 地址,以验证修复结果。

IPv6 会使基于地址的限制变得薄弱。 $binary_remote_addr 保存完整的 /128 地址,而住宅 IPv6 分配通常是 /64 或更大的网段。可用地址数量远超攻击者能够尝试的数量,并且每个地址都有独立的可用配额。请再添加一个以常量为键的区域,对该端点设置总上限。无论使用多少源地址,表单都将受到总速率限制:

map $request_method $signup_total_key {
    POST    "signup";
    default "";
}

limit_req_zone $signup_total_key zone=signup_total:1m rate=30r/m;

limit_req zone=signup_total burst=10 nodelay; 添加到同一个 location 中。将速率设置为高于实际最繁忙时段的水平,并预留足够余量。这是一种强硬的控制措施:攻击期间,它也会拒绝正常注册。这是正确的取舍,因为另一种结果是服务器持续发送邮件。

每个地址的限制为何不能放在代理层

电子邮件地址位于 POST 请求体中,而 nginx 不会解析请求体。limit_req_zone 能用于生成键的每个变量都来自请求行、请求头或连接。因此,“该地址每天最多接收一封确认邮件”这类规则,必须放在第一个读取请求体的组件中,也就是您的应用。

不要将地址移到查询字符串中,以此让 $arg_email 可用。这样会将每个订阅者的地址以明文写入访问日志,也会写入后续的任何日志传输器。您会用速率限制换来隐私问题。

有一个例外确实存在。nginx JavaScript 模块 njs 可以读取请求体,并根据其中的内容设置变量。这样就能在代理层构建每个地址的键。这是一个真正可行的选项,但也意味着要在请求路径中引入新代码。对于大多数站点,按地址限制应放在已经能够判断该地址是否存在待确认记录的数据库旁边;代理层则负责处理它擅长的按 IP 和按端点限制。

不要在消息中重复提交的文本

不要让攻击者提供的任何字符串进入您发送的消息。有两个独立原因,而且这两种攻击都曾在实际环境中出现。

如果确认邮件使用表单中的姓名称呼收件人,攻击者就可以将恶意内容写入姓名字段。随后,您的服务器会从您的域名发送这段内容给受害者,并使用您的 DKIM(DomainKeys Identified Mail)密钥签名。这样,您的网站就成了他人实施滥用的投递服务,而接收方也会看到其中使用了您的域名。

第二个原因更严重。如果手动将任何提交字段拼接到邮件头中,字段内的换行符就可以添加攻击者指定的邮件头,包括 Bcc。现代邮件库会拒绝邮件头值中的换行符。但通过 shell 脚本将文本传入 sendmail 的代码通常不会拒绝。

安全的确认消息应包含您的站点名称、一个链接和一句说明。地址本身只应出现在邮件传输代理需要使用的位置,即 To 头中。请进行以下测试:在姓名字段中提交包含换行符和明显链接的内容,然后使用 less 读取收到的原始消息,确认这些内容均未保留。

同时,让成功页面对所有地址显示相同内容。如果某个地址显示“您已经订阅”,另一个地址显示“请检查收件箱”,任何持有地址列表的人都可以利用您的表单检查哪些地址已加入,从而使其变成成员资格探测器。

应使用哪种机器人检查?

在选择有效性的同时,也要同等重视可访问性。盲人无法完成图像选择验证码,普通听力者也很难使用音频备用验证。导致合法用户无法注册的检查机制,既是防御措施,也是用户成本。以下有4种选项,建议按此顺序尝试。

浏览器工作量证明。 浏览器计算一个服务器可低成本验证的哈希,用户无需完成任何操作。listmonk 可在 Settings,然后是 Security 中通过 ALTCHA 启用此功能,且不需要第三方服务。截至 2026年8月,这是 listmonk 自身推荐的方案,优先于已弃用的 hCaptcha 选项。成本由提交次数最多的一方承担,也就是攻击者。

托管式非交互检查。 Cloudflare Turnstile 对大多数访问者完全不显示任何内容,只有在其信号显示风险较高时才会发起验证。它很有效,但会让第三方进入您的注册流程。

蜜罐字段。 这是一个用户看不到、普通机器人却会填写的文本输入框。为它设置一个表单中未使用的名称,并设置 autocomplete="off"tabindex="-1"aria-hidden="true",避免密码管理器自动填写,也避免屏幕阅读器播报。名为 email2address 的字段会被浏览器自动填写,随后您就会拒绝真实用户。

<div style="position:absolute; left:-9999px;" aria-hidden="true">
  <label for="hp_ref">Leave this field empty</label>
  <input type="text" id="hp_ref" name="hp_ref" autocomplete="off" tabindex="-1">
</div>

提交时间检查。 页面渲染时,将带签名的时间戳写入隐藏字段,并拒绝在2秒以内提交的请求。用户不可能在这么短的时间内读完表单并输入地址。必须为时间戳签名,否则机器人只需发送一个旧时间戳即可。

无论选择哪种方案,都要验证一项内容:令牌必须只能使用一次。如果脚本可以完成一次检查,然后将该令牌用于重放到1000个地址,那么该检查只能证明浏览器运行过一次,除此之外什么也没有证明。

如何在收到滥用报告前发现问题?

您需要让自己的图表发出提醒,而不是等托管服务商的滥用处理团队通知您。监控两项指标。

统计整个日志中每个源地址的提交次数:

sudo awk '/POST \/subscription\/form/ {print $1}' /var/log/nginx/access.log \
  | sort | uniq -c | sort -rn | head -20

然后让 fail2ban 读取 nginx 已经写入的相同 limiting requests 行,并封禁重复违规者。fail2ban 为此提供了专用过滤器。创建 /etc/fail2ban/jail.d/nginx-limit-req.local

[nginx-limit-req]
enabled  = true
filter   = nginx-limit-req
port     = http,https
logpath  = /var/log/nginx/error.log
findtime = 600
maxretry = 10
bantime  = 3600
sudo systemctl reload fail2ban
sudo fail2ban-client status nginx-limit-req

状态输出会列出该 jail 的过滤器,以及当前失败和封禁的数量。在流量较少的日期,Currently banned: 0 属于正常情况。如果 jail 完全没有出现,说明 fail2ban 从未加载该文件;sudo fail2ban-client -d | grep nginx-limit-req 会输出它实际解析的配置。自带过滤器会匹配所有 limit_req 区域。要将其限制到注册区域,请在 /etc/fail2ban/filter.d/nginx-limit-req.local[Definition] 部分中设置 ngx_limit_req_zones = signup。jail 文件布局和封禁命令将在Ubuntu 24.04 的 fail2ban 指南中详细介绍。

第二个信号是一个比率,不需要安装新软件:提交数除以确认数。在健康的邮件列表中,大多数提交地址的人都会点击链接,通常明显超过一半。当提交数上升而这个比率骤降时,说明您的服务正在被滥用。按照您现有的报告计划,对比过去一小时创建的 unconfirmed 订阅者数量与 confirmed 订阅者数量。

代价:发件人信誉与阻止列表

这正是一个麻烦变成账单的地方。

用于轰炸的地址列表通常来自采集,而采集到的列表中包含 spamtrap:这些地址从未在任何地方注册过,只用于捕获未经许可发送邮件的发件人。您的确认邮件会发送到其中一个地址。某些阻止列表运营方仅凭这一点就会将您列入列表。

从未请求过您邮件的收件人不会点击“取消订阅”,而是点击“举报垃圾邮件”。Google 的批量发件人规则自 2024 年 2 月起生效,要求每天向 Gmail 发送 5,000 封或更多邮件的发件人,将 Postmaster Tools 中的垃圾邮件举报率保持在 0.3% 以下。发送量较小的发件人不会按这个数值进行评估,但相同的投诉信号仍会影响过滤决策,使您的邮件进入垃圾邮件文件夹。此次运行中使用的虚假地址还会产生大量硬退信,而硬退信率上升本身就是所有大型邮件服务商都会参考的信誉信号。

如果您在 VPS 上使用 mailcow 运行自己的邮件服务器,该列入记录会关联到您的 IP 地址和域名。要请求 Spamhaus 等运营方移除记录,需要填写表单并等待处理。在等待期间,您的账单邮件和密码重置邮件也无法送达。如果您改用共享邮件服务商发送邮件,预计他们会先暂停您的账户,再查看您的说明,因为您的流量会影响该 IP 上的所有其他发件人。

相比之下,处理工作量并不大。今天启用确认订阅,因为每个列表只需设置一次。接着添加代理速率限制,因为只需修改一个文件并重新加载配置。本周内再添加机器人检查和告警即可。

FAQ

双重选择确认能阻止订阅轰炸吗?

它可以防止您的订阅列表被污染,并将您产生的发送量限制为每个提交地址最多一封邮件。这是您能采取的最大单项改进。但它无法阻止受害者的收件箱被填满,因为攻击流量来自一千个网站,每个网站发送一封邮件。您还应在代理上设置按 IP 限速,并限制确认邮件的重发次数,确保同一地址重复提交时不会再次触发邮件发送。

如何区分订阅轰炸和正常的一天真实订阅?

查看提交后的行为。真实订阅通常会确认,而且一般会在几小时内完成确认。订阅轰炸会留下大量永远不确认、不打开也不点击的地址。提交行为通常也会呈现异常聚集:大量来源地址此前从未出现过,收件人域名也不是您通常发送邮件的域名,而且到达时间均匀分布在全天,而不是遵循受众的活跃时段。

我应该删除已提交的地址吗?

应该。删除超过约 30 天仍未确认的记录,并通过定时任务执行,而不要手动处理。不要再向这些地址发送任何内容,包括道歉邮件或“这是您提交的吗?”之类的邮件,因为这会向已经收到大量此类邮件的用户再次发送未经请求的邮件。如果其中有些地址属于 spamtrap,后续发送会成为阻止列表运营方等待的确认依据。

限速会拒绝真实订阅者吗?

按 IP 设置每 30 秒提交 1 次、允许突发 3 次的限制,对于正常填写一次表单的用户是不可察觉的。当许多真实用户共享一个地址时,限速才可能产生影响,例如办公室通过单个 NAT(网络地址转换)网关访问,或者您的代理看到的是 CDN 的地址,而不是访客的地址。收紧限制前,请在访问日志中查看 $remote_addr,并确保端点上限高于真实用户最繁忙的时段。

一轮轰炸后,我的发送 IP 被列入阻止列表。首先应该做什么?

在请求移除之前,先停止使用该 IP 发送邮件。暂停活动队列,修复表单,并删除未确认的地址,因为移除阻止列表后如果继续产生相同流量,重新被列入列表的速度会比第一次更快。然后确认您被列入了哪个列表,因为大多数运营方都提供按 IP 地址查询的页面,并按照其移除流程操作。预计处理时间以天计算。利用这段时间确认您的 SPF(发件人策略框架)记录和 DKIM 签名仍能通过验证。

#email#double-opt-in#rate-limiting#abuse#deliverability