如何防止註冊表單遭訂閱轟炸
攻擊者可同時將受害者地址提交至數百個表單。了解 confirmed opt-in 與 rate limits 如何限制郵件寄送,避免你的伺服器成為攻擊工具。
什麼是訂閱轟炸?
訂閱轟炸是一種利用註冊表單淹沒他人收件匣的攻擊。攻擊者取得一名受害者的電子郵件地址,並在短時間內將該地址提交至數百或數千個未受保護的表單。這些網站都會向該地址傳送歡迎訊息或確認訊息。大量訊息會掩蓋受害者實際需要閱讀的郵件。
攻擊目標是收件匣的擁有者。收件匣充斥訂閱確認訊息時,攻擊者可能正使用該人士的信用卡消費,或重設其某個帳戶的密碼。銀行傳送的詐騙警示仍會送達,但它會埋在同一小時內抵達的 2000 封其他訊息之下,因此無人能及時看到。
你的伺服器是發動這類攻擊所使用的工具。你的主機沒有故障。你的任何帳戶也沒有遭到入侵。有人將一個地址輸入公開表單,而你的軟體依照原本的設計,向該地址傳送郵件。這正是此類攻擊難以察覺的原因。日誌中不會出現入侵紀錄,因為根本沒有發生入侵。
從你的角度看攻擊的樣貌
攻擊會以兩種形式之一出現。
明顯的形式是一陣突發流量。在幾分鐘內,數百個 POST 請求命中同一個表單,來源 IP 位址各不相同,提交的地址則屬於你之前從未寄送過的網域。只要開始查看,這種情況很容易發現。
不明顯的形式最容易被忽略。攻擊者持有數千個有漏洞表單的清單,因此你的表單每小時只需收到一或兩次提交。Jye Cusch 描述過完全相同形式的攻擊,目標是他管理的網站:沒有流量尖峰,只有在與受眾作息不符的時段持續出現註冊。單一表單看起來沒有異常,因為它實際處理的請求很少。損害來自攻擊者清單中所有表單的累積結果。
兩種形式之後都會留下相同的特徵:接下來什麼都沒有發生。這些地址從未完成確認,也從未開啟郵件或點擊連結。在 confirmed opt-in 清單中,它們會永遠維持 unconfirmed 狀態,而這批資料是你能取得的最明確證據。
先在 access log 中統計每分鐘的提交次數。
sudo awk '/POST \/subscription\/form/ {print substr($4, 2, 17)}' \
/var/log/nginx/access.log | uniq -c | sort -rn | head在預設的 combined log format 中,$4 是方括號括住的時間戳記。因此,這會依每分鐘列出計數,並按照數量由高到低排序。平常每天只收到 4 次註冊的表單,若在 1 分鐘內出現 60 次,就不是正常情況。
確認式選擇加入:效果最大的防禦措施
確認式選擇加入通常稱為 double opt-in,表示使用者必須點擊寄送至該地址的郵件中的連結,該地址才會成為訂閱者。啟用後,每個提交的地址最多只會產生一封郵件。該地址不會加入清單,因此也不會收到行銷活動或歡迎序列。
在 listmonk,自架電子報伺服器 中,這是每個清單的設定:清單可使用 single opt-in 或 double opt-in。文件明確說明兩者的差異。在 double opt-in 清單中,訂閱者「必須點擊收到的確認電子郵件,明確接受訂閱。在此之前,他們不會收到行銷活動郵件。」訂閱者會先處於 unconfirmed,點擊後移至 confirmed;只有 opt-in 清單中的 confirmed 訂閱者會收到行銷活動郵件。
必須如實說明這項措施的效果。確認式選擇加入不會讓你的貢獻降為零,而是將每個地址的貢獻上限限制為一封郵件。受害者仍會收到這封郵件,而來自一千個網站、每個網站一封郵件,就足以構成完整攻擊。確認式選擇加入所移除的是後續所有郵件:你的清單會維持乾淨,也不會再向未要求第一封郵件的人寄送第二封。
另外還有兩項重要設定,而且很容易遺漏。第一,限制確認郵件的重送次數。如果同一地址可以重複提交,且每次都會收到另一封確認郵件,攻擊者就不需要一千個表單,因為你的表單本身就會寄出一千封郵件。已在該清單中處於 unconfirmed 的地址,至少一天內不應再收到任何郵件。第二,排程刪除未確認的資料列。三十天內未完成確認的地址,不是待處理的訂閱者。保留這些資料只會增加日後意外寄信給它們的機會。
在反向代理限制註冊表單的速率
將限制放在應用程式前方,而不是應用程式內部。由代理伺服器封鎖的請求不會開啟資料庫連線,也不會開始 SMTP (simple mail transfer protocol) 交握。應用程式內部的限制要等請求已經佔用 worker process 並執行查詢後才生效;在許多技術堆疊中,訊息甚至會在濫用檢查執行前就進入佇列。代理伺服器的限制也不會因應用程式升級而消失,因為它不在會被替換的程式碼中。
以下範例使用 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 不會計算 key 為空字串的請求,因此只有 POST 請求會進入 zone。讀者多次載入註冊頁面不會消耗額度。沒有這個 map 時,使用者重新整理頁面兩次,就會在提交表單前消耗自己的額度。
$binary_remote_addr 是以壓縮格式儲存的 client address,因此 10 megabyte 的 zone 約可容納 160,000 個地址。rate=2r/m 允許每三十秒提交一次。limit_req_status 429 會回傳 HTTP 429 Too Many Requests,而不是 nginx 預設的 503。429 才是正確的狀態碼,也是 client library 預期的狀態碼。
接著在網站的 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 nginxnginx -t 應輸出 configuration file /etc/nginx/nginx.conf test is successful。現在快速提交表單五次,並監看 error log:
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。
在依賴 per-IP 限制前,請先了解以下兩個陷阱。
位於 CDN 或其他 proxy 後方時,$binary_remote_addr 就是該 proxy。 所有訪客都會進入同一個 bucket,因此每分鐘最初幾個提交就會讓其他所有人遭到封鎖。請使用 real IP module 修正:針對 CDN 公開的每個網段加入 set_real_ip_from(Cloudflare 將其網段列在 cloudflare.com/ips),並加入 real_ip_header CF-Connecting-IP。讀取 access log 中的 $remote_addr,確認其中是訪客地址而不是 CDN 地址,即可驗證修正結果。
IPv6 會使 per-address 限制變得薄弱。 $binary_remote_addr 儲存完整的 /128,而住宅 IPv6 配發通常是 /64 或更大。可用地址數遠多於攻擊者能實際使用的數量,而且每個地址都有自己的完整額度。請再加入第二個 zone,針對 endpoint 本身設定總上限,並以固定值作為 key,讓表單不論同時使用多少來源地址,都受到整體速率限制:
map $request_method $signup_total_key {
POST "signup";
default "";
}
limit_req_zone $signup_total_key zone=signup_total:1m rate=30r/m;在相同的 location 中加入 limit_req zone=signup_total burst=10 nodelay;。將速率設在實際尖峰時段之上,並保留充足餘裕。這是粗略的控制方式:遭受攻擊時,也會拒絕正常的註冊。這是正確的取捨,因為另一個選項是讓伺服器持續寄送郵件。
每個地址的限制無法放在代理層
電子郵件地址位於 POST body 內,而 nginx 不會解析 request body。limit_req_zone 能用來建立 key 的每個變數,都來自 request line、headers 或 connection。因此,「此地址每天最多只能收到一封確認信」這類規則,必須放在第一個讀取 body 的元件中,也就是你的應用程式。
不要為了讓 $arg_email 可用,而將地址移到 query string。這會把每位訂閱者的地址以明文寫入 access log,也會寫入其下游的任何 log shipper。你會以速率限制換來隱私問題。
有一個真正的例外。nginx JavaScript module njs 能讀取 request body,並從中設定變數,因此可以在代理層建立每個地址的 key。這確實是可行的選項,但也會在 request path 中加入新程式碼。對大多數網站而言,每個地址的上限應放在已經知道該地址是否有待確認項目的資料庫旁;代理層則處理其擅長的每個 IP 與每個 endpoint 的限制。
請勿在訊息中重複顯示提交的文字
請勿讓攻擊者提供的任何字串出現在您傳送的訊息中。這有兩個不同的原因,而且兩者都曾在實際攻擊中出現。
如果確認郵件使用表單中的姓名問候收件者,攻擊者就能將訊息填入姓名欄位。您的伺服器接著會從您的網域將這段文字傳送給受害者,並使用您的 DKIM (DomainKeys Identified Mail) 金鑰簽署。您的網站便成為他人濫用行為的傳送服務,而收件端服務提供者會看到您的網域。
第二個原因更嚴重。如果將任何提交欄位的內容手動串接到郵件標頭,該欄位中的換行字元就能加入攻擊者指定的標頭,包括 Bcc。現代郵件函式庫會拒絕標頭值中的換行字元,但從 shell script 將文字透過管線傳給 sendmail 的程式通常不會拒絕。
安全的確認訊息只包含您的網站名稱、一個連結,以及一句說明。地址本身只應出現在 mail transfer agent 需要的位置,也就是 To 標頭。請進行以下測試:在姓名欄位中提交包含換行字元及明顯連結的內容,然後使用 less 讀取收到的原始訊息,確認兩者都未被保留。
此外,請讓成功頁面對所有地址顯示相同內容。若某個地址顯示「您已訂閱」,另一個地址顯示「請查看收件匣」,只要持有待測地址清單的人,就能將您的表單當成會員查詢工具。
應使用哪一種機器人檢查?
在選擇有效性時,也要同樣重視無障礙性。視覺選圖 CAPTCHA 無法由盲人讀者完成,而音訊備援對一般聽力者也不容易。一項會讓合法使用者無法完成註冊的檢查,同時也是一種成本。以下列出 4 個選項,建議依序嘗試。
在瀏覽器中執行工作量證明。 瀏覽器計算伺服器可低成本驗證的雜湊值,使用者不需要解題。listmonk 可在 Settings,再進入 Security,使用 ALTCHA;這不需要第三方服務。截至 2026 年 8 月,這是 listmonk 自己建議使用的方式,優先於已棄用的 hCaptcha 選項。成本會落在提交次數最多的一方,也就是攻擊者。
受管理的非互動式檢查。 Cloudflare Turnstile 對大多數訪客完全不顯示任何內容,只在其訊號看起來可疑時提出挑戰。它有效,但會讓第三方介入註冊流程。
Honeypot 欄位。 這是使用者看不到、但簡易機器人會填入的文字輸入欄位。請使用表單中其他地方未使用的名稱,並設定 autocomplete="off"、tabindex="-1" 與 aria-hidden="true",避免密碼管理器自動填入,也避免螢幕閱讀器讀出。名稱為 email2 或 address 的欄位會由瀏覽器自動填入,接著你就會拒絕真實使用者。
<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 秒內送達的提交。使用者不可能在這麼短的時間內讀完表單並輸入地址。請簽署時間戳記,否則機器人只要傳送舊的時間戳記即可。
無論選擇哪一種,都要確認 token 只能使用一次。如果腳本能通過檢查一次,再將該 token 重播到 1000 個地址,這項檢查只能證明瀏覽器曾執行過一次,除此之外沒有任何意義。
如何在收到濫用回報前發現問題?
你應該透過自己的圖表掌握情況,而不是等託管服務商的 abuse desk 通知。請監控兩項指標。
統計整份日誌中各來源位址的提交次數:
sudo awk '/POST \/subscription\/form/ {print $1}' /var/log/nginx/access.log \
| sort | uniq -c | sort -rn | head -20接著讓 fail2ban 讀取 nginx 已寫入的相同 limiting requests 行,並封鎖重複違規者。fail2ban 已內建專用的 filter。建立 /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 = 3600sudo systemctl reload fail2ban
sudo fail2ban-client status nginx-limit-req狀態輸出會列出 jail 的 filter,以及目前失敗與封鎖的次數。在平靜的日子看到 Currently banned: 0 是正常的。如果完全沒有顯示該 jail,表示 fail2ban 根本沒有載入這個檔案;此時 sudo fail2ban-client -d | grep nginx-limit-req 會輸出它實際解析的設定。內建 filter 會比對每個 limit_req 區域。請在 /etc/fail2ban/filter.d/nginx-limit-req.local 的 [Definition] 區段中設定 ngx_limit_req_zones = signup,將比對範圍縮小到你的註冊區域。如需進一步了解 jail 檔案結構與封鎖指令,請參閱 Ubuntu 24.04 的 fail2ban 指南。
第二項訊號是比例,不需要安裝新軟體:以提交次數除以確認次數。健康的名單中,多數提交地址的人都會點擊連結,通常明顯超過半數。當提交次數上升而該比例驟降時,表示你的服務正遭到濫用。依照現有的報表排程,比較最近一小時建立的 unconfirmed 訂閱者數量與 confirmed 訂閱者數量。
這會付出什麼代價:寄件者信譽與封鎖清單
這是把惱人問題變成帳單的部分。
用於轟炸的地址清單通常是蒐集而來,而蒐集的清單中包含 spamtrap:這些地址從未在任何地方註冊,只公開來攔截未經同意寄信的寄件者。你的確認訊息會送達其中一個地址。有些封鎖清單營運者只需要這項證據。
從未要求接收你訊息的收件者不會按下取消訂閱。他們會按下「檢舉為垃圾郵件」。自 2024 年 2 月起生效的 Google bulk sender 規則要求,每日寄送至少 5,000 則訊息至 Gmail 的寄件者,必須將 Postmaster Tools 中的垃圾郵件檢舉率維持在 0.3% 以下。較小的寄件者不會以這個數值進行衡量,但相同的檢舉訊號仍會影響篩選決策,讓你的郵件被丟進垃圾郵件資料夾。這批寄送中的虛假地址也會產生大量 hard bounce,而 hard-bounce 率上升本身就是所有大型服務商都會採用的信譽訊號。
如果你在 VPS 上執行 自己的 mail server 與 mailcow,封鎖清單登錄會落在你的 IP 位址與網域上。要向 Spamhaus 這類營運者申請移除登錄,必須填寫表單並等待處理;在等待期間,你的發票與密碼重設郵件也同樣無法送達。如果改用共用 provider 寄信,預期對方會先停用你的帳戶,再查看你的說明,因為你的 network traffic 會危及該 IP 上的所有其他寄件者。
相較之下,處理工作並不多。今天啟用 confirmed opt-in,因為每個清單只需設定一次。接著加入 proxy rate limit,因為只需修改一個檔案並重新載入設定。bot check 與 alerting 可在本週稍後完成。
FAQ
雙重選擇加入能阻止訂閱轟炸嗎?
它能避免你的名單遭到污染,並將你每個提交地址的發送量限制為1則訊息。這是你能採取的最大單項改善措施。它無法阻止受害者的收件匣被塞滿,因為攻擊是由1000個網站各發送1則訊息所累積而成。請在代理伺服器上搭配每個 IP 的速率限制,並限制確認訊息的重送次數,讓同一個地址重複提交時不會產生第二則訊息。
如何分辨轟炸行為與正常的一天真實註冊?
觀察提交後發生的情況。真實註冊通常會完成確認,而且多半會在數小時內完成。轟炸行為則會留下大量永遠不確認、不開啟也不點擊的地址。提交時間與來源也會呈現異常集中:出現許多你從未見過的來源地址、平時不會寄送的收件網域,以及平均分布在整天的抵達時間,而不是依照受眾的清醒時段分布。
我應該刪除這些提交過的地址嗎?
應該。刪除超過約30天仍未確認的記錄,並以排程執行,不要手動處理。不要再向這些地址傳送任何內容,包括道歉訊息或「這是你提交的嗎?」之類的訊息,因為對已經收到大量訊息的人而言,這會是第二則未經要求的訊息。如果其中任何地址是 spamtrap,後續寄送會成為 blocklist 維運者等待的確認證據。
速率限制會拒絕真實訂閱者嗎?
每個 IP 每30秒只能提交1次,並允許3次突發請求;對只填寫一次表單的人而言,這樣的限制不會造成影響。當許多人共用同一個地址時,限制就可能產生影響,例如辦公室透過單一 NAT(network address translation)閘道連線,或你的代理伺服器看到的是 CDN 的地址,而不是訪客的地址。在收緊限制前,先查看存取日誌中的 $remote_addr,並將端點上限維持在高於最繁忙真實時段的水準。
一輪轟炸後,我的寄送 IP 被列入 blocklist。首先該做什麼?
先停止使用該 IP 寄送,不要立即提出任何申訴。暫停活動佇列、修正表單,並刪除未確認的地址,因為移除列管後若又出現相同流量,重新列入 blocklist 的速度會比第一次更快。接著確認你被列入哪個清單,因為多數維運者都有依 IP 地址查詢的頁面,然後依照對方的移除程序處理。預期等待時間會以數天計算,並利用這段時間確認你的 SPF(sender policy framework)記錄與 DKIM 簽署仍能通過驗證。