短信验证码接口是所有业务接口里最「贵」的一个:每一条都真金白银扣费,被刷一晚上可能就是几千上万条短信。更麻烦的是,短信用量异常还会被运营商风控,严重时直接停掉你的通道,正常用户也收不到验证码。本文用 9 个问答讲清怎么发现被刷、怎么在 Nginx 与业务层做双重限流、验证码该放在哪一步,以及自动封禁怎么落地。
Q:短信接口被刷,到底会损失什么?
大多数人只想到短信费,实际损失链条比这长得多。
- 短信费直接流出:按 0.03 到 0.05 元一条算,一分钟刷 1000 条,一小时就是 2000 元左右。
- 运营商通道风控:短信用量突然暴涨、同一内容重复下发、大量号码为空号,都会触发通道方风控。轻则限速,重则直接停通道,恢复要走流程、耽误业务。
- 真实用户收不到验证码:通道被占用或被限速,正常注册、登录、支付验证全部卡住,这是最直接的用户损失。
- 被当成攻击跳板:部分刷接口的行为目的不是打你,而是拿你的通道给别人发广告或骚扰短信,你可能因此被投诉为骚扰源。
- 数据库垃圾数据:批量注册产生的无效账号会污染用户表,后续做营销触达、数据分析都要先清洗。
结论是:短信接口不能只做接口鉴权,必须做频率控制和人机校验。这不是可选项,而是所有带短信能力的业务的底线配置。
Q:怎么判断短信接口是被刷,而不是业务量上涨?
业务量上涨和被刷的日志特征完全不同,看四个维度就能分开。
- 号码分布:正常业务里同一个号码不会反复出现,号码段分布也贴合推广渠道;被刷时手机号往往是随机号段、大量真实不存在的号,或者干脆集中在少数几个号段。
- 请求来源:正常流量来自各类真实 UA 与分散 IP;被刷常出现少数机房 IP 高并发,或大量 IP 但 UA 高度一致。
- 时间分布:业务量跟着推广节奏走,被刷往往是凌晨突增、曲线近乎垂直。
- 后续行为:正常用户收到验证码后会提交注册或登录;被刷的号码拿到验证码后没有任何后续动作,这是最关键的判据。
先用日志把事实捞出来,不要凭感觉:
# 每分钟短信接口调用量(找到异常时间点)
grep "POST /api/sms/send" /var/log/nginx/access.log \
| awk '{print $4}' | cut -c14-18 | sort | uniq -c | tail -30
# 请求量 Top 20 来源 IP
grep "POST /api/sms/send" /var/log/nginx/access.log \
| awk '{print $1}' | sort | uniq -c | sort -rn | head -20
# 同一手机号被请求的次数(参数名按实际替换)
grep "POST /api/sms/send" /var/log/nginx/access.log \
| grep -oP 'phone=\K[0-9]{11}' | sort | uniq -c | sort -rn | head -20
# UA 分布(脚本化请求的 UA 通常高度雷同)
grep "POST /api/sms/send" /var/log/nginx/access.log \
| grep -oP '"[^"]*"$' | sort | uniq -c | sort -rn | head -10
把这四条拼起来看,几乎不会误判:同一号码高频 + 少量 IP 高并发 + UA 雷同 + 拿到码后无后续行为,就是被刷。
Q:Nginx 层怎么给短信接口单独限速?
关键原则:限速规则只挂在短信接口这一个 location 上,不要挂到全站,否则正常用户的页面访问会被误伤。
# /etc/nginx/nginx.conf 的 http 段
# 号码维度不好在 Nginx 做(要读 body),所以这里做 IP 维度 + 连接维度
# 10m 大约能存 16 万个 IP;rate=5r/m 即每分钟 5 次请求,突发 3
limit_req_zone $binary_remote_addr zone=sms_req:10m rate=5r/m;
# 同 IP 并发连接数限制,防止慢速刷接口
limit_conn_zone $binary_remote_addr zone=sms_conn:10m;
# 命中限速时返回 429,方便前端提示「操作过于频繁」
limit_req_status 429;
limit_conn_status 429;
server {
# 短信接口单独配置
location = /api/sms/send {
limit_req zone=sms_req burst=3 nodelay;
limit_conn sms_conn 5;
# 只允许 POST
limit_except POST { deny all; }
# 强制校验 Referer,空 Referer 直接拒绝(APP 接口可改为校验签名)
if ($http_referer = "") { return 403; }
proxy_pass http://backend;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}
两个参数别设错:burst 是允许的突发额度,设太小会让连点两次的真实用户被拦;nodelay 表示突发额度内的请求立即放行,不加的话请求会被排队延迟,体验很差。如果走 CDN,务必先按第二条问答里的方法把真实客户端 IP 还原出来,否则 limit_req_zone 拿到的全是 CDN 节点 IP,一限就是一整片用户。
Q:手机号维度怎么限?阈值该设多少?
号码维度必须在业务层做,因为 Nginx 默认拿不到 POST body。用 Redis 计数器实现最省事,同时天然带 TTL。
# 三道闸,任一条超限直接返回「操作过于频繁」,不进入发送逻辑
# 1) 同号码 60 秒内最多 1 条
key = "sms:phone:%s:60s" % phone
if redis.incr(key) > 1:
return {"code": 429, "msg": "请求过于频繁,请稍后再试"}
redis.expire(key, 60)
# 2) 同号码 24 小时内最多 10 条(防长期慢速刷)
key_day = "sms:phone:%s:day" % phone
cnt = redis.incr(key_day)
if cnt == 1:
redis.expire(key_day, 86400)
if cnt > 10:
return {"code": 429, "msg": "今日验证码次数已用完"}
# 3) 同号码两次请求间隔至少 60 秒(防绕过上面两条的竞争条件)
lock = "sms:lock:%s" % phone
if not redis.set(lock, 1, nx=True, ex=60):
return {"code": 429, "msg": "请 60 秒后再试"}
# 4) 号码格式与真实号段校验(前置过滤掉明显非法号码,省下短信费)
import re
if not re.match(r"^1[3-9]\d{9}$", phone):
return {"code": 400, "msg": "手机号格式错误"}
阈值经验值参考:同号码 60 秒 1 条是绝对底线,任何业务都不该放宽;同号码 24 小时 10 条适合注册登录为主的一般站点;如果是支付类高风险场景,可以收紧到 5 条。反过来,业务高峰时段适当放宽突发额度,但不要放开间隔限制。
Q:IP 维度的阈值和号码维度有什么不同?
最大的坑是 NAT 共享出口:公司、学校、运营商大内网的用户可能共用同一个公网 IP。阈值定太紧会造成「一个用户刷多了,整栋楼都用不了」。
- 一般站点:同 IP 每小时 20 条、每天 50 条,配合号码维度已经够用。
- 移动端为主的业务:移动网络 IP 池变动频繁,同 IP 阈值可以适当放宽到每小时 30 条,把压力交给号码维度。
- 企业内网 / 校园网场景:不要按单 IP 硬拦,改为「同 IP 同号码」组合限制,或者对已知出口 IP 段单独放行。
- 必须叠加人机验证:IP 维度只能挡低成本脚本,稍高级的攻击者用代理池就能绕过,IP 限速是拦量,人机验证才是拦人。
# Redis:同 IP 每小时计数(Nginx 已限速,这里做业务层兜底)
key_ip = "sms:ip:%s:hour" % client_ip
n = redis.incr(key_ip)
if n == 1:
redis.expire(key_ip, 3600)
if n > 20:
return {"code": 429, "msg": "当前网络请求过于频繁"}
Q:验证码/人机校验应该放在哪一步?
位置错了等于没做。有人把图形验证码放在「短信已经发出之后」,那纯属浪费——短信费已经花了。
- 正确顺序:先校验图形/滑块/行为验证码,通过之后再进入号码与 IP 限速判断,全部通过才调用短信网关。
- 成本对比:一次性图形验证码的破解成本远高于一条短信的发送成本,但只要放在正确位置,它能把绝大多数批量脚本挡在付费动作之前。
- 分级策略:不要一上来就对所有用户弹验证码,会伤转化率。做法是先静默,触发阈值后再升级——同 IP 或同设备在 5 分钟内请求超过 3 次,才要求验证码。
- 验证码本身要防复用:一次性使用、绑定会话 ID、有效期 120 秒,校验通过立即失效,防止验证码被拿去换接口调用。
这套分级的价值在于:正常用户几乎感知不到,而脚本一旦被要求过验证码,成本就从「发请求」变成「要解题」,绝大多数刷量会在这一步停下。
Q:怎么用 ipset 做紧急封禁和自动拉黑?
攻击正在进行时,不要手动一条条敲 iptables,用 ipset 先止血再慢慢分析。
# 建集合(timeout 300 表示 5 分钟后自动解封,避免误封长期生效)
ipset create smsban hash:ip timeout 300 maxelem 100000
# 把集合挂到 iptables
iptables -I INPUT -p tcp --dport 443 -m set --match-set smsban src -j DROP
iptables -I INPUT -p tcp --dport 80 -m set --match-set smsban src -j DROP
# 从日志里自动提取 Top IP 并入库(每分钟跑一次)
grep "POST /api/sms/send" /var/log/nginx/access.log \
| awk '{print $1}' | sort | uniq -c | sort -rn | head -50 \
| awk '$1 > 60 {print $2}' | while read ip; do ipset add smsban $ip -exist; done
# 查看当前封禁情况
ipset list smsban | head -30
三个细节决定了这套机制能不能长期跑:① 一定要设 timeout,误封会自动过期,不需要人守着;② 封禁前加白名单校验,把搜索引擎蜘蛛、监控探针、企业出口 IP 排除掉(ipset add smsban 1.2.3.4 -exist 前先查白名单表);③ 记录封禁日志,每次拉黑都写一行(时间、IP、触发次数、来源日志),事后排查有据可依——没有日志的自动封禁,出事时无法回溯。
Q:CDN 和 WAF 在防刷场景里能帮上什么忙?
能帮上,但有明确的能力边界,别指望全托管。
- 隐藏源站 + 边缘拦截:请求在边缘节点就被限速或人机校验挡掉,压根不回源,源站压力最小。
- 真实 IP 还原是前提:在 Nginx 里加
set_real_ip_from和real_ip_header,把$remote_addr还原成真实客户端 IP,否则所有基于 IP 的限速都会误伤一整片用户。 - 频率规则要分接口配置:短信、登录、支付这类接口的阈值必须远低于图片、静态资源,不能对全站套一条统一规则。
- WAF 拦不住的部分:业务级逻辑(同号码多次、拿到码不注册、设备指纹)WAF 看不到,必须落到业务代码里。
- 能力分工的结论:CDN/WAF 负责挡住大流量和低级脚本,业务限速与人机验证负责识别异常行为,两者缺一不可。
Q:还有什么补充建议?
落地清单按顺序过一遍:① 号码格式与真实号段前置校验 → ② Redis 三道锁(60 秒 / 24 小时 / 间隔)→ ③ Nginx 短信接口单独 limit_req + limit_conn → ④ 触发阈值后升级人机验证 → ⑤ 短信发送量与费用的分钟级告警 → ⑥ 异常号段与高频 IP 自动进 ipset → ⑦ 通道侧设置单日发送上限做最后一道保险。
补两个容易被忽略的点。第一,给短信通道设一个日发送上限,哪怕业务量再大也别敞开——这是最后一道成本熔断,被刷时最多损失到上限,而不是损失到天亮。第二,把「拿到验证码后有没有后续行为」做成监控指标,这个比值一旦异常下降,基本可以确定有人在刷,比等费用报表发现要快得多。
最后提醒一句:限速不是越严越好。所有阈值都要先在日志上回放一遍历史流量,确认不会误伤正常用户再上线;上线后持续观察 429 返回比例,如果明显升高,说明阈值定保守了。防刷的目标是拦住异常、放过正常,不是把接口关掉。