前两天,技术社区里有位朋友(暂且叫他阿强)半夜发帖求助,说他们公司刚上线的一个H5活动页,半夜被SMS Boom(短信轰炸机) 盯上了。早上老板醒来一看阿里云账单,好家伙,一晚上发送了20多万条短信,直接损失了好几万现金。老板震怒,阿强当天就被约谈“优化”了。

去面试下一家公司时,面试官又刚好问到:“在这个场景下,如果让你设计短信验证码接口,你怎么做防刷?” 阿强只答了“前端倒计时”和“IP限流”,面试官笑了笑说:“就这?黑产的秒拨IP池有几百万个,你防得住谁?”
各位开发者,短信防刷绝不仅仅是一个简单的限流问题,它是一场与自动化攻击脚本的持续博弈。今天我们就来系统性地拆解,如何从代码层面构建一套有效的防御体系。这类关于安全和高并发场景的实战问题,在像云栈社区这样的技术论坛里也经常被深入讨论。
一、 认清对手:攻击者的手段比你想象的更专业
在部署防御之前,必须了解攻击者常用的武器。针对短信接口的攻击,主要有两种模式:
-
短信轰炸(SMS Boom):
- 原理:攻击者编写脚本,批量调用大量防护薄弱的短信接口。输入特定受害者的手机号,这些接口就成了攻击者的“炮台”,对目标手机进行短信轰炸。
- 痛点:这种攻击目的多为骚扰,调用量激增,但目标手机号高度集中。
-
薅羊毛(注册机):
- 原理:利用“接码平台”提供的海量廉价手机号,配合自动化脚本批量注册账号,以领取新人红包等优惠。
- 痛点:最难防御。攻击者使用的是真实手机号,IP地址也通过动态代理池秒级切换,普通的IP限流规则基本失效。
二、 无效防御:那些“自欺欺人”的手段
很多初级开发者喜欢在前端做文章:
- 常见做法:点击发送按钮后,前端将按钮置灰并开始60秒倒计时。
- 评价:这就好比只锁了正门却留着窗户大开。攻击脚本是直接通过HTTP请求调用后端接口的,谁会去模拟点击你的前端按钮?所有有效的防御都必须下沉到服务端实现。

三、 有效防御:核心代码落地(硬核实战)
既然简单的IP限流防不住专业攻击,我们就需要在更上层的逻辑和架构上下功夫。以下三道防线,建议层层部署。
第一道防线:强制人机验证(后端二次校验)
这是拦截自动化脚本最有效的手段之一。关键点:不要只在前端校验! 很多项目前端验证通过后直接调用短信接口,后端却不对验证凭证进行二次校验,导致验证形同虚设。

正确流程(Java代码示例):
@RestController
@RequestMapping("/sms")
public class SmsController {
@Autowired
private CaptchaService captchaService; // 假设对接了极验、阿里云等第三方服务
@PostMapping("/send")
public Result sendSms(@RequestBody SmsRequest req) {
// 1. 第一步:必须先校验滑块/图形验证码的Ticket
// 如果Ticket无效或已过期,直接返回错误,不进入后续发短信逻辑
boolean isHuman = captchaService.verify(req.getCaptchaTicket(), req.getIp());
if (!isHuman) {
return Result.error("验证失效,请重新操作");
}
// 2. 第二步:执行发送逻辑...
}
}
原理:验证码服务商会返回一个加密的Ticket,后端必须拿着这个Ticket去服务商服务器进行二次验证。只有服务商确认这是“人类行为”后,才继续处理短信请求。
第二道防线:基于Redis的多维限流(保证原子性)
不要只限制IP!IP对于攻击者来说是廉价资源。应该重点限制手机号和业务整体频次。我们需要一个原子性的限流器,防止并发场景下的计数不准问题。这里可以利用 Redis 的Lua脚本实现。
Redis Lua脚本(rate_limit.lua):
-- keys[1]: 限流Key (例如 sms:limit:13800138000)
-- argv[1]: 限流阈值 (例如 5 次)
-- argv[2]: 过期时间 (例如 3600 秒)
local current = redis.call('INCR', KEYS[1])
if tonumber(current) == 1 then
redis.call('EXPIRE', KEYS[1], ARGV[2])
end
if tonumber(current) > tonumber(ARGV[1]) then
return 0 -- 超过阈值,拒绝
else
return 1 -- 允许通过
end
Java调用代码:
@Autowired
private StringRedisTemplate redisTemplate;
public void checkRateLimit(String phone, String ip) {
// 1. 限制单个手机号:1小时内只能发5条 (主要防轰炸)
String phoneKey = "sms:limit:phone:" + phone;
if (!executeLua(phoneKey, 5, 3600)) {
throw new BusinessException("操作太频繁,请稍后再试");
}
// 2. 限制单个IP:24小时内只能发20条 (辅助防羊毛党,能拦一部分是一部分)
String ipKey = "sms:limit:ip:" + ip;
if (!executeLua(ipKey, 20, 86400)) {
throw new BusinessException("当前IP请求受限");
}
}
private boolean executeLua(String key, int threshold, int expireSeconds) {
// 加载并执行上面的Lua脚本,返回1允许,0拒绝
// ... 具体脚本加载和执行逻辑 ...
}
第三道防线:接口参数签名(防止请求重放)
攻击者有时会录制一个正常的请求包(包含有效的人机验证Ticket),然后进行重放攻击。为了防止这一点,必须引入 签名(Sign) 机制,并配合 时间戳(Timestamp) 和随机数(Nonce)。
Java校验逻辑:
public void verifySign(SmsRequest req) {
// 1. 校验时间戳:防止60秒之前的旧请求被重放
long now = System.currentTimeMillis();
if (now - req.getTimestamp() > 60000) {
throw new BusinessException("请求已过期");
}
// 2. 校验随机数Nonce:防止60秒内的高频重放
// 将nonce存入Redis,设置60秒过期。如果Redis中已存在该nonce,说明是重复请求
String nonceKey = "sms:nonce:" + req.getNonce();
Boolean isAbsent = redisTemplate.opsForValue().setIfAbsent(nonceKey, "1", 60, TimeUnit.SECONDS);
if (Boolean.FALSE.equals(isAbsent)) {
throw new BusinessException("重复的请求");
}
// 3. 校验签名Sign
// 签名算法示例:MD5(phone + timestamp + nonce + secretKey),实际应用中建议使用更安全的HMAC
String raw = req.getPhone() + req.getTimestamp() + req.getNonce() + "Your_Secret_Key_Here";
String calcSign = DigestUtils.md5DigestAsHex(raw.getBytes());
if (!calcSign.equals(req.getSign())) {
throw new BusinessException("签名错误");
}
}
四、 业务层防御:基于场景的“逻辑防线”
如果上述技术防线被绕过(例如攻击者使用“真人打码”平台),就需要依靠业务逻辑进行最后一道拦截。
1. 场景化拦截(至关重要!)
千万不要让短信接口成为一个通用的、无状态的发送器!
- 找回密码场景:用户输入手机号请求发送验证码。后端应先查询数据库,如果这个手机号根本未注册,应直接返回错误(例如“用户不存在”)。甚至可以返回“发送成功”但实际不调用短信服务商(逻辑伪装),这样既防止攻击者利用接口探测注册用户库,也避免了给无关号码发短信的成本。
- 注册场景:如果请求发送验证码的手机号在数据库中已存在,应直接提示“该手机号已注册,请直接登录”,坚决不再发送验证码。

2. 蜜罐(Honey Pot)技术
在前端表单中埋设一个对用户不可见的输入框。
<input type="text" name="robot_check" style="display:none;" />
后端逻辑:如果接收到的请求中,robot_check 这个字段有值,那么这几乎可以100%断定是自动化脚本提交的(因为正常用户看不到也不会填写这个框)。对于此类请求,可以直接封禁该IP,或者静默拦截不发短信。

五、 系统级兜底:网关层流量控制
无论业务代码写得多么完善,都应该设置一道系统级别的防火墙——在API网关或应用入口进行全局流量控制。例如接入 Sentinel,为短信发送接口配置一个全局QPS阈值。
# Sentinel 流控规则示例
resource: POST:/sms/send
grade: QPS
count: 100 # 设置每秒最多100次请求,超过则快速失败,保护短信余额和下游服务
这样,即使所有业务逻辑防线被意外突破,至少能保证短信费用不会在瞬间被消耗殆尽,为人工干预争取时间。
六、 总结与建议
短信防刷没有一劳永逸的“银弹”,它本质上是安全成本、用户体验与业务风险之间的平衡。
- 人机验证是性价比最高的方案,必须实施,且后端必须进行二次校验。
- Redis限流要针对手机号和IP进行多维度限制,并使用Lua脚本保证原子性。
- 业务前置校验(如查库判断手机号状态)能有效拦截大量无效或恶意请求。
- 接口签名与防重放机制能防止简单的抓包重放攻击。
- 系统级流控是保护资金和系统稳定的最后屏障。
当下次在面试中被问到这个问题时,你可以系统地阐述这套 “人机验证+Redis多维限流+签名防重放+业务逻辑拦截+网关流控兜底” 的组合防御策略,清晰地展示你的防御层次和架构思维。
最后提醒:如果业务仅面向中国大陆,务必在阿里云、腾讯云等云服务商的后台,将“国际/港澳台短信”功能默认关闭,这能避免因接口暴露导致的跨国短信天价账单,堵住最大的资金损失漏洞。
希望这篇从实战角度的总结能对你有所帮助。在开发中遇到类似的安全或架构难题,不妨到技术社区与同行交流,往往能碰撞出更优的解决方案。