Cách chặn subscription bombing trên form đăng ký
Kẻ tấn công có thể gửi một địa chỉ vào hàng trăm form cùng lúc. Dùng confirmed opt-in và rate limit để server không gửi email hàng loạt.
Subscription bombing là gì?
Subscription bombing là một cuộc tấn công sử dụng form đăng ký của bạn để làm ngập inbox của người khác. Kẻ tấn công lấy địa chỉ email của một nạn nhân rồi gửi địa chỉ đó đến hàng trăm hoặc hàng nghìn form không được bảo vệ trong thời gian ngắn. Mỗi website trong số đó gửi một email chào mừng hoặc email xác nhận đến địa chỉ này. Các email đó cùng nhau che khuất những thư mà nạn nhân thực sự cần đọc.
Mục tiêu là người sở hữu inbox đó. Trong lúc inbox chứa đầy email xác nhận đăng ký, kẻ tấn công đang dùng tiền trên thẻ của người đó hoặc reset password của một trong các account của họ. Cảnh báo gian lận từ ngân hàng vẫn được gửi đến. Nhưng nó nằm dưới hàng nghìn email khác được gửi đến trong cùng một giờ, nên không ai kịp nhìn thấy.
Server của bạn là công cụ được dùng để thực hiện cuộc tấn công. Không có gì trên máy của bạn bị hỏng. Không có account nào của bạn bị compromise. Ai đó đã nhập một địa chỉ vào public form và phần mềm của bạn thực hiện đúng chức năng được thiết kế: gửi mail đến địa chỉ đó. Vì vậy rất khó phát hiện cuộc tấn công này. Không có dấu hiệu intrusion trong log, vì thực tế không có intrusion.
Dạng tấn công bạn nhìn thấy từ phía mình
Nó xuất hiện theo một trong hai dạng.
Dạng ồn ào là một đợt bùng phát. Trong vài phút, có hàng trăm request POST gửi đến một form, từ nhiều địa chỉ IP nguồn khác nhau, chứa các địa chỉ thuộc những domain mà bạn chưa từng gửi email đến. Dạng này dễ phát hiện khi bạn kiểm tra log.
Dạng im lặng thường bị bỏ sót. Kẻ tấn công có danh sách hàng nghìn form dễ bị khai thác, nên form của bạn chỉ cần nhận một hoặc hai submission mỗi giờ. Jye Cusch đã mô tả một cuộc tấn công đúng theo dạng này trên một site do anh ấy vận hành: không có traffic spike, chỉ có các lượt đăng ký đều đặn xuất hiện vào những giờ không khớp với thời gian hoạt động của người dùng. Một form riêng lẻ trông vô hại vì nó hầu như không làm gì. Thiệt hại là tổng số submission từ mọi form trong danh sách của kẻ tấn công.
Sau đó, cả hai dạng đều có chung một dấu hiệu: không có gì xảy ra tiếp theo. Các địa chỉ không bao giờ xác nhận. Chúng không bao giờ mở message và cũng không bao giờ click link. Trên một confirmed opt-in list, chúng sẽ ở trạng thái unconfirmed mãi mãi. Đống địa chỉ đó là bằng chứng rõ nhất mà bạn có thể thu thập.
Trước tiên, hãy đếm số submission mỗi phút trong access log.
sudo awk '/POST \/subscription\/form/ {print substr($4, 2, 17)}' \
/var/log/nginx/access.log | uniq -c | sort -rn | head$4 trong default combined log format là timestamp nằm trong dấu ngoặc vuông. Vì vậy, lệnh này in số lượng theo từng phút, theo thứ tự từ cao xuống thấp. Một form thường chỉ nhận bốn lượt đăng ký mỗi ngày mà đột nhiên nhận sáu mươi lượt trong một phút thì không phải đang hoạt động bình thường.
Opt-in xác nhận: biện pháp có tác động lớn nhất
Opt-in xác nhận, thường được gọi là double opt-in, nghĩa là một địa chỉ chưa trở thành subscriber cho đến khi có người nhấp vào liên kết trong message được gửi đến địa chỉ đó. Bật tùy chọn này thì mỗi địa chỉ được gửi đúng một message, chỉ một lần. Địa chỉ đó không bao giờ được thêm vào list, nên cũng không nhận campaign hoặc welcome sequence.
Trong listmonk, máy chủ newsletter tự host, đây là tùy chọn theo từng list: một list dùng single opt-in hoặc double opt-in. Tài liệu mô tả rất rõ sự khác biệt. Trên list double opt-in, subscriber "xác nhận rõ ràng việc đăng ký bằng cách nhấp vào email xác nhận họ nhận được. Trước thời điểm đó, họ không nhận campaign." Subscriber ở trạng thái unconfirmed, chuyển sang confirmed khi nhấp vào liên kết, và chỉ subscriber ở trạng thái confirmed trên opt-in list mới nhận campaign mail.
Hãy đánh giá đúng tác dụng của cơ chế này. Opt-in xác nhận không làm phần đóng góp của bạn giảm về zero. Nó giới hạn phần đóng góp đó ở một message cho mỗi địa chỉ. Nạn nhân vẫn nhận message đó, và một message từ mỗi trong số một nghìn site là toàn bộ cuộc tấn công. Điều opt-in xác nhận loại bỏ là mọi message sau đó: list của bạn vẫn sạch, và bạn không bao giờ gửi message thứ hai cho người chưa từng yêu cầu message đầu tiên.
Hai tùy chọn khác cũng quan trọng và đều rất dễ bị quên. Thứ nhất, hãy giới hạn số lần gửi lại message xác nhận. Nếu cùng một địa chỉ có thể được submit lại và nhận thêm một email xác nhận sau mỗi lần, attacker không cần một nghìn form, vì chỉ riêng form của bạn cũng sẽ gửi một nghìn message. Một địa chỉ đã ở trạng thái unconfirmed trên list đó không nên nhận thêm gì trong ít nhất một ngày. Thứ hai, hãy xóa các row chưa xác nhận theo lịch định kỳ. Một địa chỉ chưa xác nhận sau ba mươi ngày không còn là subscriber đang chờ xử lý. Giữ lại địa chỉ đó chỉ tạo thêm khả năng một thứ gì đó vô tình gửi mail đến nó sau này.
Giới hạn rate form đăng ký tại reverse proxy
Đặt giới hạn ở phía trước ứng dụng thay vì bên trong ứng dụng. Request bị proxy chặn sẽ không mở kết nối database và không bắt đầu phiên SMTP (simple mail transfer protocol). Giới hạn bên trong ứng dụng chỉ chạy sau khi request đã chiếm một worker process và thực hiện một query. Trong nhiều stack, message còn được đưa vào queue trước khi kiểm tra abuse. Giới hạn tại proxy cũng vẫn tồn tại sau khi nâng cấp ứng dụng vì nó không nằm trong phần code bị thay thế.
Ví dụ dưới đây dùng nginx. Cách này cũng áp dụng được với reverse proxy bạn dùng trước ứng dụng, dù tên các directive sẽ khác.
Đặt nội dung này vào block http, trong một file như /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 thực hiện phần việc chính. nginx không đếm request có key là chuỗi rỗng, nên chỉ các request POST mới được đưa vào zone. Người dùng tải trang đăng ký nhiều lần không tiêu tốn hạn mức. Nếu không có map đó, người refresh trang hai lần sẽ tự tiêu tốn hạn mức trước khi gửi form.
$binary_remote_addr là địa chỉ client ở dạng đóng gói, nên zone 10 megabyte chứa được khoảng 160,000 địa chỉ. rate=2r/m cho phép một lần gửi trong mỗi ba mươi giây. limit_req_status 429 trả về HTTP 429 Too Many Requests thay vì 503 mặc định của nginx. Đây là mã phản hồi chính xác và cũng là mã mà client library mong đợi.
Sau đó, trong block server của site:
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 cho phép người dùng vô tình double-click nút gửi, đồng thời từ chối request thứ tư ngay lập tức thay vì đưa request đó vào queue.
sudo nginx -t && sudo systemctl reload nginxnginx -t phải in ra configuration file /etc/nginx/nginx.conf test is successful. Bây giờ hãy gửi form năm lần liên tiếp thật nhanh và monitor error log:
sudo tail -f /var/log/nginx/error.logMột request bị chặn sẽ ghi một dòng. Đây là chuỗi bạn cần tìm:
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"Nếu không có dòng nào, giới hạn chưa được áp dụng. Nguyên nhân thường gặp là limit_req nằm trong block location mà request không đi qua. Vì vậy, hãy kiểm tra bằng curl -si -X POST https://news.example.com/subscription/form vài lần liên tiếp và xác nhận bạn nhận được 429.
Có hai vấn đề cần biết trước khi dựa vào giới hạn theo IP.
Nếu đứng sau CDN hoặc proxy khác, $binary_remote_addr chính là proxy đó. Mọi visitor sẽ dùng chung một bucket, nên vài lần gửi đầu tiên trong mỗi phút có thể khóa tất cả visitor còn lại. Hãy khắc phục bằng module real IP: thêm set_real_ip_from cho từng range mà CDN công bố (Cloudflare liệt kê các range tại cloudflare.com/ips), rồi thêm real_ip_header CF-Connecting-IP. Xác nhận đã sửa bằng cách đọc $remote_addr trong access log và kiểm tra đó là địa chỉ của visitor, không phải địa chỉ của CDN.
IPv6 khiến giới hạn theo địa chỉ trở nên yếu. $binary_remote_addr giữ toàn bộ /128, trong khi một allocation IPv6 dành cho hộ gia đình thường là /64 hoặc lớn hơn. Số lượng địa chỉ này lớn hơn rất nhiều so với khả năng sử dụng của attacker, và mỗi địa chỉ có một hạn mức riêng chưa dùng. Hãy thêm một zone thứ hai làm giới hạn trần cho chính endpoint này, dùng một giá trị cố định làm key, để form luôn có rate tổng thể bất kể có bao nhiêu địa chỉ nguồn đang được sử dụng:
map $request_method $signup_total_key {
POST "signup";
default "";
}
limit_req_zone $signup_total_key zone=signup_total:1m rate=30r/m;Thêm limit_req zone=signup_total burst=10 nodelay; vào cùng location. Đặt rate cao hơn giờ có lượng truy cập thật lớn nhất và chừa thêm khoảng dự phòng. Đây là một biện pháp thô: trong khi bị tấn công, nó cũng sẽ từ chối các lượt đăng ký hợp lệ. Đây là đánh đổi đúng, vì phương án còn lại là server của bạn phải gửi email.
Giới hạn theo từng địa chỉ không thể đặt tại proxy
Địa chỉ email nằm trong phần body của POST, còn nginx không phân tích request body. Mọi biến mà limit_req_zone có thể dùng để tạo key đều lấy từ request line, header hoặc connection. Vì vậy, quy tắc như “địa chỉ này chỉ được nhận tối đa một email xác nhận mỗi ngày” phải nằm trong component đầu tiên đọc body, tức là application của bạn.
Không nên khắc phục bằng cách đưa địa chỉ vào query string để $arg_email có thể sử dụng. Cách này ghi địa chỉ của mọi subscriber vào access log dưới dạng plain text và cả mọi log shipper ở phía sau. Bạn sẽ đổi một bài toán rate limit lấy một vấn đề về privacy.
Có một ngoại lệ thực sự. Module JavaScript của nginx là njs có thể đọc request body và đặt một biến từ nội dung đó. Nhờ vậy, bạn có thể tạo key theo từng địa chỉ ngay tại proxy. Đây là một lựa chọn hợp lệ, nhưng cũng đồng nghĩa với việc thêm code mới vào request path. Với hầu hết site, giới hạn theo từng địa chỉ nên đặt cạnh database, nơi đã biết địa chỉ này có confirmation đang chờ hay chưa. Proxy nên xử lý giới hạn theo IP và theo endpoint, là những việc nó làm tốt.
Không lặp lại nội dung người dùng đã gửi trong thông báo
Không đưa bất kỳ chuỗi nào do attacker cung cấp vào thông báo bạn gửi. Có 2 lý do riêng biệt, và cả 2 đều từng được khai thác thực tế.
Nếu email xác nhận chào người nhận bằng tên lấy từ form, attacker có thể ghi nội dung của họ vào trường tên. Sau đó, server của bạn gửi nội dung đó đến nạn nhân từ domain của bạn và ký bằng key DKIM (DomainKeys Identified Mail) của bạn. Website của bạn đã trở thành dịch vụ phát tán nội dung lạm dụng cho người khác, và mail server nhận sẽ thấy domain của bạn trong email đó.
Lý do thứ 2 nghiêm trọng hơn. Nếu bạn tự nối bất kỳ trường nào đã gửi vào mail header, ký tự newline trong trường đó có thể thêm các header do attacker chọn, bao gồm Bcc. Các mail library hiện đại từ chối newline trong giá trị header. Nhưng code truyền text vào sendmail từ shell script thường không làm vậy.
Email xác nhận an toàn chỉ chứa tên website và một link, kèm 1 câu giải thích. Địa chỉ email chỉ xuất hiện ở nơi mail transfer agent cần đến, trong header To. Hãy kiểm tra như sau: gửi form với trường tên chứa một newline và một link dễ nhận biết, sau đó dùng less để đọc raw message nhận được. Xác nhận rằng cả 2 nội dung đó đều không còn trong message.
Trong quá trình kiểm tra, hãy bảo đảm success page hiển thị cùng một nội dung cho mọi địa chỉ. Nếu page hiển thị "bạn đã đăng ký" với một địa chỉ và "hãy kiểm tra inbox" với một địa chỉ khác, form của bạn sẽ trở thành công cụ kiểm tra thành viên cho bất kỳ ai có danh sách địa chỉ cần kiểm tra.
Bạn nên dùng bot check nào?
Hãy coi khả năng tiếp cận quan trọng không kém hiệu quả khi chọn bot check. CAPTCHA chọn ảnh không thể được người khiếm thị giải, còn phương án dự phòng bằng âm thanh gây khó khăn cho người có thính lực bình thường. Nếu một bot check khiến người dùng hợp lệ không thể đăng ký, thì biện pháp phòng vệ đó cũng tạo ra chi phí. Có 4 lựa chọn, theo thứ tự nên thử.
Proof of work trong browser. Browser tính một hash mà server có thể xác minh với chi phí thấp, và người dùng không phải tự giải gì cả. listmonk cung cấp tính năng này trong Settings, sau đó là Security, bằng ALTCHA; tính năng này không cần third-party service. Tính đến tháng 8 năm 2026, đây là khuyến nghị của chính listmonk thay cho hCaptcha đã deprecated. Chi phí tính toán sẽ dồn vào bên gửi nhiều request nhất, tức attacker.
Managed check không tương tác. Cloudflare Turnstile không hiển thị gì với hầu hết visitor, và chỉ đưa ra challenge khi các tín hiệu cho thấy có vấn đề. Cách này hiệu quả, nhưng đưa một bên thứ ba vào luồng đăng ký của bạn.
Honeypot field. Đây là một text input mà người dùng không bao giờ nhìn thấy nhưng bot đơn giản sẽ điền vào. Đặt cho field này một tên mà form không dùng ở nơi khác, rồi đặt autocomplete="off", tabindex="-1" và aria-hidden="true" để password manager không tự điền và screen reader không đọc nó. Field có tên email2 hoặc address sẽ được browser autofill, khiến bạn từ chối cả người dùng hợp lệ.
<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>Time-to-submit check. Khi page render, hãy đặt một timestamp đã ký vào hidden field, rồi từ chối submission đến trong chưa đầy 2 giây. Người dùng không thể đọc form và nhập địa chỉ nhanh như vậy. Hãy ký timestamp; nếu không, bot chỉ cần gửi một timestamp cũ.
Dù chọn phương án nào, hãy kiểm tra một điểm: token phải được consume một lần duy nhất. Nếu một script có thể giải check một lần rồi replay token đó cho 1000 địa chỉ, thì check chỉ chứng minh rằng một browser đã chạy một lần, không chứng minh được gì thêm.
Làm thế nào phát hiện trước khi nhận được báo cáo abuse?
Bạn cần biểu đồ của chính mình cho biết tình hình, thay vì chờ bộ phận abuse của nhà cung cấp hosting. Hãy theo dõi 2 chỉ số.
Đếm số lần gửi theo từng địa chỉ nguồn trong toàn bộ log:
sudo awk '/POST \/subscription\/form/ {print $1}' /var/log/nginx/access.log \
| sort | uniq -c | sort -rn | head -20Sau đó để fail2ban đọc cùng các dòng limiting requests mà nginx đã ghi, rồi ban những nguồn vi phạm lặp lại. fail2ban có sẵn filter đúng cho trường hợp này. Tạo /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-reqOutput trạng thái liệt kê filter của jail cùng số lượng hiện đang failed và banned. Currently banned: 0 vào ngày ít hoạt động là bình thường. Nếu jail hoàn toàn không xuất hiện, fail2ban chưa load file này; sudo fail2ban-client -d | grep nginx-limit-req sẽ in ra cấu hình mà nó thực sự đã parse. Filter có sẵn này khớp với mọi zone limit_req. Hãy giới hạn nó vào zone đăng ký bằng cách đặt ngx_limit_req_zones = signup trong section [Definition] của /etc/fail2ban/filter.d/nginx-limit-req.local. Bố cục file jail và các lệnh ban được giải thích chi tiết hơn trong hướng dẫn fail2ban cho Ubuntu 24.04.
Tín hiệu thứ 2 là một tỷ lệ và không cần cài thêm phần mềm: số lần gửi chia cho số lần xác nhận. Với một danh sách hoạt động bình thường, phần lớn người gửi địa chỉ sẽ nhấp vào liên kết, thường cao hơn một nửa đáng kể. Khi tỷ lệ này giảm mạnh trong lúc số lần gửi tăng, hệ thống của bạn đang bị lợi dụng. So sánh số subscriber unconfirmed được tạo trong 1 giờ gần nhất với số subscriber confirmed đã xác nhận, theo lịch chạy report hiện có của bạn.
Cái giá phải trả: uy tín của sender và các blocklist
Đây là phần biến một sự phiền toái thành hóa đơn.
Các danh sách địa chỉ được dùng để bombing thường bị thu thập tự động. Những danh sách này chứa spamtrap: các địa chỉ chưa từng đăng ký bất kỳ dịch vụ nào, chỉ được công khai để phát hiện sender gửi mail không có sự cho phép. Confirmation message của bạn sẽ đến một địa chỉ như vậy. Một số operator của blocklist chỉ cần có thế.
Người nhận chưa từng yêu cầu message của bạn sẽ không bấm unsubscribe. Họ sẽ bấm "report spam". Quy định dành cho bulk sender của Google, có hiệu lực từ February 2024, yêu cầu sender gửi từ 5,000 message mỗi ngày trở lên đến Gmail phải giữ tỷ lệ spam bị báo cáo trong Postmaster Tools dưới 0.3%. Sender nhỏ hơn không bị đánh giá theo con số này, nhưng cùng tín hiệu complaint vẫn được dùng để quyết định việc lọc mail và đưa mail của bạn vào thư mục spam. Các địa chỉ giả trong đợt chạy cũng tạo ra hard bounce, và tỷ lệ hard bounce tăng là một tín hiệu riêng về uy tín tại mọi nhà cung cấp lớn.
Nếu bạn chạy mail server riêng trên VPS với mailcow, việc bị đưa vào blocklist sẽ gắn với IP address và domain của bạn. Để được operator như Spamhaus gỡ khỏi blocklist, bạn phải gửi form rồi chờ xử lý. Trong thời gian đó, invoice và password reset của bạn cũng không được gửi đến người nhận. Nếu thay vào đó bạn gửi mail qua shared provider, hãy dự kiến họ sẽ suspend account của bạn trước rồi mới đọc phần giải thích, vì network traffic của bạn là rủi ro đối với mọi sender khác dùng chung IP đó.
So với những hậu quả này, phần việc cần làm khá nhỏ. Hãy bật confirmed opt-in ngay hôm nay vì mỗi list chỉ cần một setting. Tiếp theo, thêm proxy rate limit vì chỉ cần chỉnh một file rồi reload. Bot check và alerting có thể triển khai trong tuần này.
FAQ
Double opt-in có ngăn được subscription bombing không?
Cơ chế này ngăn danh sách của bạn bị làm bẩn và giới hạn phần đóng góp của bạn ở mức một message cho mỗi địa chỉ được gửi lên. Đây là cải thiện lớn nhất bạn có thể thực hiện. Cơ chế này không ngăn inbox của nạn nhân bị đầy, vì cuộc tấn công là tổng của một message từ mỗi trong số hàng nghìn site. Hãy kết hợp với rate limit theo IP tại proxy và giới hạn số lần gửi lại message xác nhận, để một địa chỉ được gửi lên hai lần không tạo ra message thứ hai.
Làm sao phân biệt một đợt bombing với một ngày có nhiều người đăng ký thật?
Hãy xem điều gì xảy ra sau khi gửi form. Người đăng ký thật sẽ xác nhận, thường trong vài giờ. Một đợt bombing để lại một loạt địa chỉ không bao giờ xác nhận, mở hoặc click. Các lượt gửi cũng thường có dấu hiệu bất thường: nhiều địa chỉ nguồn mà bạn chưa từng thấy, các domain người nhận mà bạn thường không gửi đến, và thời điểm nhận được phân bố đều trong cả ngày thay vì theo giờ hoạt động của độc giả.
Tôi có nên xóa các địa chỉ đã được gửi lên không?
Có. Hãy xóa các bản ghi chưa xác nhận cũ hơn khoảng 30 ngày và thực hiện theo lịch thay vì làm thủ công. Không bao giờ gửi thêm bất kỳ thứ gì đến các địa chỉ đó, kể cả lời xin lỗi hoặc message kiểu “có phải bạn không?”, vì đó là message thứ hai không được yêu cầu gửi đến một người vốn đã bị dội bom bởi các message tương tự. Nếu bất kỳ địa chỉ nào trong số đó là spamtrap, follow-up sẽ là bằng chứng xác nhận mà đơn vị vận hành blocklist đang chờ.
Rate limiting có từ chối người đăng ký thật không?
Giới hạn theo IP ở mức một lần gửi mỗi 30 giây, với burst là 3, sẽ không ảnh hưởng đến người chỉ điền form một lần. Giới hạn này sẽ gây ảnh hưởng khi nhiều người thật dùng chung một địa chỉ, chẳng hạn một văn phòng đứng sau một gateway NAT (network address translation), hoặc khi proxy của bạn nhìn thấy địa chỉ của CDN thay vì địa chỉ của visitor. Hãy đọc $remote_addr trong access log trước khi siết giới hạn và giữ ngưỡng tối đa của endpoint cao hơn lưu lượng thật trong giờ cao điểm.
Sending IP của tôi nằm trong blocklist sau một đợt tấn công. Tôi cần làm gì trước?
Hãy dừng gửi từ IP đó trước khi yêu cầu xử lý gì. Tạm dừng campaign queue, sửa form và xóa các địa chỉ chưa xác nhận, vì nếu delist rồi lại tiếp tục tạo cùng loại traffic, IP của bạn sẽ bị đưa vào list lại nhanh hơn lần đầu. Sau đó xác định bạn đang nằm trong list nào, vì hầu hết đơn vị vận hành đều có trang lookup theo địa chỉ IP của bạn, rồi làm theo quy trình gỡ bỏ của họ. Hãy chuẩn bị cho thời gian chờ tính bằng ngày và dùng thời gian đó để xác nhận record SPF (sender policy framework) cũng như DKIM signing vẫn pass.