上个月给一个小项目加了手机验证码注册。接口写好,本地测通,部署上去,前两天没什么事。第三天早上打开发送记录,四百多条,全是连号手机号,间隔不到一秒。
余额直接掉了二十多块。
这才反应过来------验证码接口天生就是公网暴露的,你总不能让用户先登录再注册。攻击者只要知道接口地址,剩下的就是一个 for 循环的事。
先看清楚对方怎么打的
翻请求日志,攻击模式特别朴素:
plaintext
2026-07-18 03:14:22 POST /api/sms/send 138****0001 200 12ms 45.77.xx.xx
2026-07-18 03:14:22 POST /api/sms/send 138****0002 200 11ms 45.77.xx.xx
2026-07-18 03:14:23 POST /api/sms/send 138****0003 200 13ms 45.77.xx.xx
...
同一个 IP,User-Agent 是 python-requests/2.31.0,连伪装都懒得做。四百多条请求集中在凌晨三点到四点之间,那会儿谁也没在看。
不复杂,但有效------短信是按条扣费的,刷一晚上就是真金白银。
第一道:按手机号和 IP 做频率限制
最先补的是最基本的限流。两个维度同时卡:
手机号维度:同一个号 60 秒内只能请求一次,24 小时累计不超过 5 次。
python
from collections import defaultdict
import time
phone_records = defaultdict(list)
def check_phone_limit(phone: str) -> bool:
now = time.time()
# 只保留 24 小时内的记录
phone_records[phone] = [t for t in phone_records[phone] if now - t < 86400]
records = phone_records[phone]
if records and now - records[-1] < 60:
return False
if len(records) >= 5:
return False
records.append(now)
return True
IP 维度:单 IP 每分钟最多 10 次,每小时最多 30 次。
如果你用 Redis,可以用 INCR + EXPIRE 来做,比内存字典靠谱:
python
import redis
r = redis.Redis()
def check_ip_limit(ip: str) -> bool:
minute_key = f"sms:ip:{ip}:min"
hour_key = f"sms:ip:{ip}:hour"
if r.incr(minute_key) == 1:
r.expire(minute_key, 60)
if int(r.get(minute_key) or 0) > 10:
return False
if r.incr(hour_key) == 1:
r.expire(hour_key, 3600)
if int(r.get(hour_key) or 0) > 30:
return False
return True
加上之后,那种单 IP 循环刷号的脚本直接就拦住了。但过了没几天又来一波------这次用了代理池,每条请求的 IP 都不一样,IP 限流没用了。
第二道:人机验证前置
代理池可以换 IP,但过人机验证就费劲了。在前端加一道滑块验证(或者点选、旋转,什么都行),通过之后拿到一个一次性 token,连同手机号一起提交给后端。
javascript
async function sendCode(phone) {
// 用户先通过滑块验证
const token = await captcha.verify();
const res = await fetch('/api/sms/send', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ phone, captcha_token: token })
});
return res.json();
}
后端拿到 captcha_token 之后必须到验证服务的服务端接口去校验 ,不能在前端 JS 里判断。我见过有人把校验逻辑写在前端------if (result.success) 直接放行------等于没加。
python
import requests
def verify_captcha(token: str) -> bool:
resp = requests.post('https://captcha-provider.com/verify', json={
'secret': CAPTCHA_SECRET,
'token': token
})
return resp.json().get('success', False)
加了人机验证之后,短信消耗立刻降回正常水平。多数脚本到这一步就放弃了------自动过滑块的成本远高于刷你几条短信的收益。
第三道:服务端 IP 白名单
人机验证拦住了从浏览器方向来的攻击,但如果有人拿到了你的短信服务凭证(模板编码或者 API Key),直接从自己的服务器调短信通道怎么办?
这时候 IP 白名单就有用了。把你业务服务器的出口 IP 配到短信服务的白名单里,只有从这些 IP 发出的请求才能调通短信接口:
bash
# 查服务器出口 IP
curl -s ifconfig.me
# 120.78.xxx.xxx
把这个 IP 填到短信服务后台的白名单配置里。如果有多台服务器或者经过了负载均衡,把所有出口 IP 都加上。
这一层的意义在于:就算凭证泄露了,攻击者拿去也调不通。而如果你发现凭证可能泄露,在平台侧重置一下编码,旧的立刻失效。
一个容易踩的坑:重发逻辑
限流做完之后还有一个细节。用户点了发送,等了 30 秒没收到(运营商慢或者网络抖动),又点了重发。如果直接生成一条新验证码发出去,用户手机上会收到两条不同的码,不知道该输哪个。
改成这样:60 秒内重发时不生成新码,把上一条原样再发一次。验证码有效期统一 5 分钟,过期作废。
python
code_store = {}
def get_or_create_code(phone: str) -> str:
now = time.time()
if phone in code_store:
code, ts = code_store[phone]
if now - ts < 300:
return code
new_code = f"{random.randint(100000, 999999)}"
code_store[phone] = (new_code, now)
return new_code
这样用户不管点几次重发,拿到的都是同一个码,不会混乱。
别忘了加个异常报警
防护做了几层也不能完全放心。加两个最简单的报警:
- 每小时短信发送量超过日常均值 3 倍,告警
- 单 IP 一分钟内请求验证码超过 20 次,告警并临时封禁
不需要专门搭监控系统,一个定时查日志的脚本就够。关键是出了问题能第一时间知道,别等月底看账单才发现。
如果你的业务服务器本身就接了告警通道,直接在限流触发时发一条通知就行。我用的是一个 HTTP 接口推消息的服务,限流被触发的时候 POST 一下就能收到通知,不用额外搭东西。
防护层级总结
把上面几道防线列一下:
| 层级 | 手段 | 拦什么 |
|---|---|---|
| 前端 | 人机验证(滑块/点选) | 脚本自动化请求 |
| 接口 | 手机号 + IP 频率限制 | 高频刷号 |
| 通道 | IP 白名单 | 凭证泄露后的非法调用 |
| 运营 | 发送量异常报警 | 漏网的异常流量 |
这几层没有哪个是多高深的东西,但不做的话后果很实在------短信通道按条计费,被刷一晚上可能就是好几百块。上线前花半天把这些加好,比出了事再补划算得多。
我自己用的短信服务自带 IP 白名单和凭证重置,不需要企业资质就能接入,模板也是平台提供的现成的不用审核。有需要的话可以看看:push.spug.cc。