短信验证码又被盗刷?这篇文章帮你封住所有漏洞

198 阅读8分钟

前言

阿城最近在我的熏陶下,喜欢看各种文章和代码。

那天他给我发了一个代码截图,附带着几句话:

“哥你看,这段 sendCode 写得挺清爽的,但是我咋看着不对劲呢”

“短信验证码这种接口,就这么暴露出去?会不会被人刷爆?”

我扫了一眼截图:一个 POST /sendCode,传手机号就发短信;没限流、没人机校验、没发送间隔、也没次数上限。

代码足够清爽,但安全性也确实清爽,看着就透心凉。

我跟阿城说:短信验证码这个东西,本质上并不是个普通的业务接口。这段代码确实写得很优雅,但完全没防护,那一旦发生意外,它也会优雅地把短信预算送走,甚至攻击者都不需要多厉害,只要一段脚本就够了。

所以这篇文章,我们把短信验证码发送链路里最常见的漏洞拆开讲明白,再按漏洞逐个去做防御。

要注意的是,有些防御措施以牺牲用户体验为代价,但这一般都是在安全、成本、转化率之间做过权衡后的综合选择,而不是为了安全而安全。

耐心看完,你一定有所收获。

Text Love GIF by Pudgy Penguins.gif

正文

常见漏洞

漏洞 1:没有频率限制(或限制太单薄)

  • 同一个 IP / 同一个手机号可以高频请求
  • 攻击脚本一分钟几百次,接口照单全收

漏洞 2:只按手机号限制,不按 IP / 设备 / 用户限制

  • 攻击者换手机号就绕过
  • 或者用代理池换 IP 绕过单 IP 规则

漏洞 3:缺少人机校验(图形/滑动/行为验证码)

  • 发送验证码变成一个无门槛的接口
  • 脚本完全可以模拟正常请求流程

漏洞 4:验证码校验链路可被暴力尝试

  • 验证接口不限制失败次数
  • 验证码过长有效期、可重复使用
  • 导致猜码或撞码的风险上升

漏洞 5:缺少幂等与监控

  • 用户/前端重试也可能造成重复扣费
  • 没有告警,往往是产生账单后才发现

对症下药

下面我们按漏洞对应防御策略来写。每个策略都讲清楚:

  1. 解决了什么问题
  2. 带来什么副作用(用户体验/误伤)
  3. 如何落地(给出最小必要代码示例)

示例默认: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机制:把人机校验结果绑定到短信发送

流程:

  1. 前端做人机校验拿到凭证(如 challenge/validate 或 token)
  2. 后端验签通过后发放一个短时 ticket(Redis 里存 2 分钟)
  3. 发送短信必须携带 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 抬高脚本成本,验证码做到 短有效期、一次性、失败次数限制,再加上 幂等和监控告警,避免重复扣费、也能第一时间发现异常。

有些措施会让体验稍微变差一点,但这是在 体验、成本、安全 之间做过权衡后的选择:让正常用户多走半步,总比让攻击者一路绿灯强。

最后感慨两句。

写业务的时候我们总爱相信世界是善良的。

但成年人最大的体面,都要交学费交出来的。