前言
阿城最近在我的熏陶下,喜欢看各种文章和代码。
那天他给我发了一个代码截图,附带着几句话:
“哥你看,这段 sendCode 写得挺清爽的,但是我咋看着不对劲呢”
“短信验证码这种接口,就这么暴露出去?会不会被人刷爆?”
我扫了一眼截图:一个 POST /sendCode,传手机号就发短信;没限流、没人机校验、没发送间隔、也没次数上限。
代码足够清爽,但安全性也确实清爽,看着就透心凉。
我跟阿城说:短信验证码这个东西,本质上并不是个普通的业务接口。这段代码确实写得很优雅,但完全没防护,那一旦发生意外,它也会优雅地把短信预算送走,甚至攻击者都不需要多厉害,只要一段脚本就够了。
所以这篇文章,我们把短信验证码发送链路里最常见的漏洞拆开讲明白,再按漏洞逐个去做防御。
要注意的是,有些防御措施以牺牲用户体验为代价,但这一般都是在安全、成本、转化率之间做过权衡后的综合选择,而不是为了安全而安全。
耐心看完,你一定有所收获。
正文
常见漏洞
漏洞 1:没有频率限制(或限制太单薄)
- 同一个 IP / 同一个手机号可以高频请求
- 攻击脚本一分钟几百次,接口照单全收
漏洞 2:只按手机号限制,不按 IP / 设备 / 用户限制
- 攻击者换手机号就绕过
- 或者用代理池换 IP 绕过单 IP 规则
漏洞 3:缺少人机校验(图形/滑动/行为验证码)
- 发送验证码变成一个无门槛的接口
- 脚本完全可以模拟正常请求流程
漏洞 4:验证码校验链路可被暴力尝试
- 验证接口不限制失败次数
- 验证码过长有效期、可重复使用
- 导致猜码或撞码的风险上升
漏洞 5:缺少幂等与监控
- 用户/前端重试也可能造成重复扣费
- 没有告警,往往是产生账单后才发现
对症下药
下面我们按漏洞对应防御策略来写。每个策略都讲清楚:
- 解决了什么问题
- 带来什么副作用(用户体验/误伤)
- 如何落地(给出最小必要代码示例)
示例默认:Spring Boot + Redis,想必这也是最通用的组合。
1. 频率限制:先把无穷发送变成有限发送
1.1 IP 维度限流:挡住最常见的脚本
作用: 同一来源 IP 短时间内没法频繁打接口。
副作用: 同一个出口 IP(比如公司网络、校园网)可能多人共用,阈值太小会误伤。
示例:同一 IP 60 秒最多 5 次
String ip = request.getRemoteAddr();
String key = "sms:rl:ip:" + ip;
Long n = redis.opsForValue().increment(key);
if (n != null && n == 1) redis.expire(key, Duration.ofSeconds(60));
if (n != null && n > 5) throw new TooManyRequestsException("请求过于频繁");
建议:IP 限流阈值也不能无脑一刀切,更推荐超过阈值后进入更严格校验(比如人机验证),而不是直接拦截。
1.2 手机号限流:最典型
作用: 同一手机号必须冷却,并有当天的上限。
副作用: 极少数情况下用户确实需要多次发送(比如网络延迟、短信被拦截等),会给用户带来意想不到的麻烦。
示例:60 秒冷却 + 24 小时最多 5 次
// 60s 冷却
String cdKey = "sms:cd:" + phone;
if (Boolean.TRUE.equals(redis.hasKey(cdKey))) throw new TooManyRequestsException("请稍后再试");
redis.opsForValue().set(cdKey, "1", Duration.ofSeconds(60));
// 24h 上限
String dayKey = "sms:cnt:" + phone;
Long cnt = redis.opsForValue().increment(dayKey);
if (cnt != null && cnt == 1) redis.expire(dayKey, Duration.ofHours(24));
if (cnt != null && cnt > 5) throw new TooManyRequestsException("今日验证码次数已用完");
体验权衡:
- 冷却时间 60s 基本是行业共识;
- 日上限 5 次对绝大多数用户足够,但能显著压制攻击成本。
这属于轻度影响体验,但极大降低风险,是比较典型的决策。
1.3 用户/设备维度限流
如果有 userId(已登录)或可靠的 deviceId(设备指纹/客户端ID),建议也加上类似的计数机制。
作用: 攻击者换手机号也不那么容易。
副作用: 设备指纹如果不可靠,可能存在误判,需要谨慎。
这个就不提供代码示例了,基本和手机号类似。
2. 人机校验:让攻击成本上升
只靠限流肯定是不够的,攻击者有很多方式可以规避,比如:
- 通过代理池换 IP
- 通过猫池批量换手机号
- 降速慢刷
这时必须引入人机校验(滑动/行为验证码等)。
通过这种收到那,实现风险分层,确保正常用户顺畅使用,只有可疑请求才加验证。
2.1 两种策略:全量上验证码 vs 分层触发验证码
-
全量上验证码:最安全,但体验下降明显
-
分层触发:更推荐
- 当 IP/手机号触发某个阈值后,再要求通过人机校验
权衡说明:
- 对新用户来说,多一步验证码可能降低转化;
- 但如果你的业务正被攻击盗刷,先止血更重要;
- 最佳实践通常是平时分层触发,攻击期临时提升强度,甚至全量。
2.2 ticket机制:把人机校验结果绑定到短信发送
流程:
- 前端做人机校验拿到凭证(如 challenge/validate 或 token)
- 后端验签通过后发放一个短时
ticket(Redis 里存 2 分钟) - 发送短信必须携带 ticket;ticket 用一次就作废(防止重放)
示例:校验 ticket(一次性)
String tKey = "sms:ticket:" + ticket;
if (!Boolean.TRUE.equals(redis.hasKey(tKey))) throw new ForbiddenException("请先完成人机校验");
redis.delete(tKey); // 一次性:防止同 ticket 重放刷短信
代码比较短啊,但是示例效果应该是有了,就是把短信发送从一个公开接口,变成持票入场的机制。
3. 验证码设计与校验:别让验证接口也成为突破口
短信盗刷主要攻击的是发送接口,但验证接口也要守住。
3.1 验证码必须:短有效期、一次性使用
作用: 过期自动失效,成功后立刻作废。
副作用: 用户可能因为短信延迟导致过期,需要重新获取(体验略微降低)。
示例:校验成功即删除
String codeKey = "sms:code:" + phone;
String real = redis.opsForValue().get(codeKey);
if (!Objects.equals(real, inputCode)) throw new ForbiddenException("验证码错误");
redis.delete(codeKey); // 一次性使用
3.2 限制验证失败次数:防止暴力尝试
作用: 抵抗穷举或者撞码的行为 副作用: 用户如果手滑输错多次,会被暂时锁定(体验下降,但在业务上也能理解)
示例:10 分钟最多错 5 次
String failKey = "sms:fail:" + phone;
Long fail = redis.opsForValue().increment(failKey);
if (fail != null && fail == 1) redis.expire(failKey, Duration.ofMinutes(10));
if (fail != null && fail > 5) throw new ForbiddenException("错误次数过多,请稍后再试");
权衡说明:
虽然是短时间的锁定,但必然会让用户不爽;
可是它能让攻击者的穷举直接失去意义
在安全链路里,这是典型的用少量体验换大量安全
4. 幂等:别让重复点击变成重复扣费
某种情况下,很多短信费用并不是黑客盗刷的,也可能是下面这些场景:
- 用户狂点重新发送
- 前端网络抖动自动重试
- 网关重试导致重复请求
作用: 同一个请求只发送一次。
副作用: 需要前端或客户端配合提供 requestId(或你在服务端生成并返回)。
示例:3 分钟幂等
Boolean ok = redis.opsForValue()
.setIfAbsent("sms:idem:" + requestId, "1", Duration.ofMinutes(3));
if (!Boolean.TRUE.equals(ok)) return; // 已处理过,直接忽略
5. 监控与告警:主动的监控报警,提前预防
建议至少监控以下这几点:
- 每分钟发送量(总量 + 按渠道)
- 命中限流次数(按 IP / phone)
- 发送失败率(突然升高往往是被刷或被风控)
- 单 IP/单号异常排行
监控代码和平台差异很大,这里不贴具体的实现,关键是一定要有阈值告警,比方说邮件或者飞书、钉钉的报警。
写在最后
到这里,我们把短信验证码这条链路最容易被钻的地方都补了一遍:用 IP/手机号/用户 做限流兜底,用 人机校验 + ticket 抬高脚本成本,验证码做到 短有效期、一次性、失败次数限制,再加上 幂等和监控告警,避免重复扣费、也能第一时间发现异常。
有些措施会让体验稍微变差一点,但这是在 体验、成本、安全 之间做过权衡后的选择:让正常用户多走半步,总比让攻击者一路绿灯强。
最后感慨两句。
写业务的时候我们总爱相信世界是善良的。
但成年人最大的体面,都要交学费交出来的。