网站短信验证码被刷(短信轰炸)怎么防:先加一道行为验证,再做频控的 6 步清单
结论先说:短信轰炸挡不住,通常不是验证码位数的问题,而是你的「发送短信」接口谁都能直接调。 正确的顺序是:先在发送入口加一道行为验证抬高机器成本,再在服务端按手机号、IP、设备做频控,最后补监控和告警。三层一起上,既能挡住脚本,也不会误伤正常用户。下面是可以直接照着做的 6 步清单,附 Node.js 示例。
短信轰炸是怎么打你的
攻击者的目标不是攻破你的系统,而是让你替他付短信费、让被填的号码收到骚扰短信。常见手法有两种:
- 脚本遍历号段:写个循环,把 13x/15x/18x 号段挨个请求你的发送接口,一次几千条;
- 接码平台 + 打码平台:用接码平台拿大量真实可收短信的号码,用打码平台自动过图形验证码,成本几分钱一次。
结果通常有三层:你的短信账户余额被刷光;短信通道被运营商限流甚至关停,正常用户也收不到;被骚扰的号码投诉,你的域名和通道进黑名单。对小站来说,一次攻击可能就是一整月的短信预算。
为什么只加图形验证码不够
很多站点的第一反应是「加个四位数字图形验证码」。这能挡住最简单的脚本,但挡不住打码平台——一张图几分钱,攻击者依然可以批量请求。
更常见的问题是顺序错了:先发短信、再限制次数。等你发现同一个号码收到第 10 条时,钱已经花出去了,骚扰也已经发生了。
所以要么在发送之前验证,要么就要接受「已经发出去的费用无法追回」。行为验证的价值在于:它校验的是滑动轨迹、速度、停顿这些行为特征,机器要模拟得像真人,成本远高于识别一张扭曲的图。
防短信轰炸的 6 步清单
第 1 步:发送入口先过行为验证
在「获取验证码」按钮的流程里,先弹一次滑动验证,通过后才允许调发送接口。用户多花一两秒,攻击者的单次成本却从「一个 HTTP 请求」变成「要模拟真人行为」。
接入方式很简单,以不墨行为验证码为例(有一个月免费试用,个人开发者可以直接开):
<div id="BUMO_CAPTCHA"></div>
<input type="hidden" name="captchaId" id="captchaId" />
<script src="https://cdn.bumo.cc/apps/captcha-sdk/captcha.js"></script>
<script>
new window.BumoCaptcha({
containerId: 'BUMO_CAPTCHA',
id: '你的产品ID',
successCallback: function (captchaId) {
document.getElementById('captchaId').value = captchaId;
// 这里再调你的 /api/send-code
},
});
</script>
注意:前端通过只是拿到凭证,不能作为放行依据。
第 2 步:服务端二次校验,通过才发短信
发送接口的第一件事,是拿 captchaId 去验证码服务换一个「是否真人」的结果。没通过就直接拒绝,不要进入后续逻辑。
async function verifyCaptcha(captchaId) {
try {
const res = await fetch('https://captcha.bumo.cc/api/captcha/server/get-result', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
authorization: 'Bearer 你的APIKEY', // 只放服务端
'x-id': '你的产品ID',
referer: 'https://你的域名', // 必须在域名白名单里
},
body: JSON.stringify({ r: captchaId }),
signal: AbortSignal.timeout(10000),
});
const json = await res.json();
if (json.success) return json.data.p === true;
if (json.code === 20001) return true; // 超过速率限制,放行避免影响业务
return false;
} catch (e) {
// 验证码服务不可用时前端会给 NOSERVER_ 开头的 ID,兜底放行
return String(captchaId).startsWith('NOSERVER_');
}
}
两个细节:API KEY 绝不能出现在前端;校验请求必须带超时,否则验证码服务抖动会把你自己的发送接口一起拖死。
第 3 步:按手机号频控
这是最有效的一层,因为攻击者最终要落到真实号码上。建议的初始值(按业务调整):
- 同一号码 60 秒内只能发 1 条;
- 同一号码 1 小时最多 5 条;
- 同一号码 24 小时最多 10 条。
用 Redis 做,多实例部署也能共享计数:
// 冷却:60 秒内重复请求直接拒绝
const cooldownKey = `sms:cd:${phone}`;
const first = await redis.set(cooldownKey, '1', { NX: true, EX: 60 });
if (!first) return res.status(429).json({ message: '请 60 秒后再试' });
// 小时 / 天维度计数
const hourKey = `sms:h:${phone}:${new Date().toISOString().slice(0, 13)}`;
const hourCount = await redis.incr(hourKey);
if (hourCount === 1) await redis.expire(hourKey, 3600);
if (hourCount > 5) return res.status(429).json({ message: '发送过于频繁,请稍后再试' });
不要只做「同一 IP 每分钟 N 条」:大量用户共用出口 IP(公司、学校、运营商 NAT),限制太死会误伤;只做号码维度又挡不住攻击者换号。两个维度要一起看。
第 4 步:按 IP 和设备维度加一层
IP 维度用于发现「一个来源打很多号码」:
- 同一 IP 1 小时最多 10~20 条,按你的用户分布调;
- 记录 User-Agent、设备指纹(如有),同一设备短时间请求多个号码要重点标记;
- 对来自机房 IP、已知代理/VPN 段、境外异常来源的请求提高验证等级或直接拒绝。
这里的目标不是精确识别每一个攻击者,而是把「单 IP 打几千条」的成本抬高到不划算。
第 5 步:验证码本身要一次性、短有效
短信里的验证码也是攻击面:
- 6 位数字、5 分钟内有效,过期即失效;
- 一次有效:校验成功立刻删除,不能重复使用;
- 错误次数限制:同一号码连续错 5 次作废,必须重新获取;
- 服务端存哈希,不要把验证码明文写进日志;
- 校验接口也要限流,防止有人拿 6 位数字暴力枚举(6 位只有 100 万种,不限流是能撞出来的)。
第 6 步:监控、告警、黑名单
前面五步是拦截,这一步是让你在被打的第一分钟就知道:
- 监控每分钟发送量,超过日常峰值 3 倍就告警;
- 监控同 IP / 同设备的号码数,一个 IP 打几十个不同号码是典型特征;
- 监控验证失败率,突然升高往往是在试接口;
- 保留最近请求的号码、IP、时间,出事后能快速拉黑;
- 接入短信服务商自带的频控和黑名单,运营商层面再挡一层。
改造顺序:先止血,再优化
如果网站已经被刷过,按这个顺序改,改动最小、见效最快:
- 当天:发送接口加行为验证 + 服务端校验,先把「一个请求一条短信」堵住;
- 当天:加同号码 60 秒冷却和小时上限,挡住换 IP 但号码不变的攻击;
- 本周:补 IP/设备维度和监控告警,能提前发现而不是月底看账单;
- 之后:根据真实数据调阈值,别一上来把正常用户限死。
几个常见误区
「我用了图形验证码,应该没事了。」 打码平台几分钱一张,图形验证码只能挡住最便宜的脚本。行为验证 + 频控才是组合拳。
「限流放在网关就够了。」 网关通常只能按 IP 限。短信轰炸的关键维度是手机号,必须落到业务层。
「验证码服务挂了,我的登录也挂了。」 所以要设超时和兜底:验证码服务不可用时放行,业务可用性优先。这一点在接入文档里写得很清楚,别省掉。
「免费额度够用,先不买。」 先用免费额度把流程跑通、把阈值调出来,等量上来再按需付费。比起被刷掉一整月短信费,这点成本可以忽略。
一句话总结
短信轰炸的根因是「发送接口谁都能调」,处方是行为验证 + 服务端二次校验 + 号码/IP 双维度频控 + 一次性验证码 + 监控告警。 想最快止血,先做前两步:3 分钟接入向导 填域名、拿 Key、复制前后端代码,第一次滑动验证通过就自动确认接入,全程不需要人工审核。想先看效果可以去 在线 DEMO,产品说明和常见问题在这里。