1. 从一次线上告警说起:短信接口被刷爆的凌晨三点
凌晨三点,手机突然被一连串告警短信炸醒——短信发送量在十分钟内飙到了平时的四十倍,短信通道余额直接见底,登录注册接口的QPS曲线拉出了一根几乎垂直的尖峰。这不是什么业务爆发,而是典型的短信验证码接口被恶意调用。做过线上业务的人对这一幕应该不陌生:有人写了个脚本,拿着你的短信接口当免费的“短信轰炸机”,一分钟能发几千条,每条短信几分钱,一晚上就能烧掉你几千甚至上万的短信费用,顺带把正常用户的注册登录全部堵死。
这篇文章要聊的,就是如何防止短信验证码接口、登录注册入口被恶意调用攻击。这不是一个纯理论话题,而是每一个有用户体系的系统迟早都要面对的现实问题。我会从攻击者的视角拆解他们是怎么打的,再从防守方的角度一层一层把防护体系搭起来,包括频率限制、人机校验、风控策略、号码画像、监控告警这些核心手段,最后给出可以直接抄作业的落地方案和踩坑经验。
适合谁看?如果你正在做用户注册登录、短信验证码、营销活动页、或者任何暴露在公网上的写接口,这篇文章就是写给你的。不管你是刚接手这块业务的新手,还是想系统性梳理防护体系的老手,都能从中找到能直接用的东西。
2. 攻击者到底怎么打:拆解恶意调用的四种典型手法
要防住攻击,先得知道对手怎么出招。短信验证码接口和登录注册入口之所以成为重灾区,核心原因是它们通常无需登录态就能访问,而且每次调用都有真实成本(短信费、计算资源、数据库写入)。攻击者正是看中了这两点。
2.1 脚本直刷:最粗暴也最常见的打法
最基础的手法就是写个脚本,直接对着你的短信发送接口循环发请求。参数里手机号随便填,或者用一个号段批量生成。这种攻击没有任何技术含量,但架不住量大。我见过一个案例,攻击者用一台普通云主机,单机每秒能打过来两百多个请求,持续了整整两个小时。如果你的接口没有任何频率限制,这两个小时足够把短信余额清空好几轮。
这种攻击的特征很明显:同一IP高频请求、手机号呈规律性分布、请求间隔极其均匀。识别起来不难,难的是在业务初期很多人根本没做限制。
2.2 号码枚举:拿你的接口当“号码是否注册”的查询器
比脚本直刷更隐蔽的一种手法是号码枚举。攻击者手里有一批手机号(可能来自其他渠道泄露的数据),然后拿你的登录或注册接口逐个尝试,通过返回信息的差异来判断这个号码是否在你的平台注册过。比如注册接口返回“该号码已注册”,登录接口返回“密码错误”还是“用户不存在”,这些细微差异都会被利用。
这种攻击的可怕之处在于单IP请求频率不高,很容易绕过简单的频率限制。攻击者会用代理池把请求分散到成百上千个IP上,每个IP只发几条,看起来就像正常用户。等你发现的时候,手里的用户号码库已经被摸了个遍。
2.3 分布式代理池:把频率限制按在地上摩擦
说到代理池,这是目前对抗频率限制最主流的手段。攻击者手里维护着大量的出口IP,每次请求换一个IP,这样基于单IP的限流就完全失效了。代理池的来源五花八门,有专门的代理服务,也有被入侵的设备组成的网络。
面对这种攻击,单纯靠IP维度做限制已经不够了,必须引入手机号维度、设备指纹维度、行为特征维度的综合判断。这也是为什么后面我会强调多层防护的必要性——任何单一维度的限制都有被绕过的可能。
2.4 短信轰炸:拿你的接口去骚扰真实用户
还有一种手法严格来说不算是“攻击你的系统”,而是滥用你的接口去骚扰别人。攻击者把你的短信接口当成工具,填入受害者的手机号,短时间内触发大量验证码短信,让受害者被短信淹没。这种情况一旦发生,不仅受害者投诉,你的短信通道也可能被运营商封禁。
这种攻击的防护重点在于对同一手机号的发送频率做严格限制,同时识别请求来源的异常。下面这张表把四种手法做个对比,方便你快速定位自己可能面临的风险。
| 攻击手法 | 核心特征 | 主要危害 | 防护重点 |
|---|---|---|---|
| 脚本直刷 | 单IP高频、参数规律 | 短信费用暴涨、接口拥堵 | IP频率限制、人机校验 |
| 号码枚举 | 低频分散、关注返回差异 | 用户数据泄露 | 统一返回信息、行为分析 |
| 代理池攻击 | 多IP、每IP低频 | 绕过IP限流 | 手机号维度限流、设备指纹 |
| 短信轰炸 | 针对特定号码高频 | 用户骚扰、通道封禁 | 号码维度限流、来源识别 |
3. 防护体系怎么搭:从入口到短信发送的五层防线
搞清楚攻击手法之后,防护思路就清晰了。我的经验是不要指望单点防护能解决问题,而是要搭一套分层的体系,让攻击者每突破一层都要付出额外成本。下面这套五层防线是我在多个项目里验证过的,从最外层到最内层依次收紧。
3.1 第一层:入口层的基础限流
最外层是基于IP和接口维度的基础限流。这一层的目的不是挡住所有攻击,而是把最粗暴的脚本直刷挡在门外,同时控制正常流量的突发。实现方式通常有两种:网关层限流和应用层限流。
网关层可以用Nginx的limit_req模块,配置简单,性能损耗小。比如限制单个IP对短信接口的访问频率:
limit_req_zone $binary_remote_addr zone=sms_limit:10m rate=1r/s; location /api/sms/send { limit_req zone=sms_limit burst=3 nodelay; limit_req_status 429; proxy_pass http://backend; }这段配置的意思是:每个IP每秒最多1个请求,允许突发3个,超出直接返回429。burst=3是给正常用户留的缓冲,比如用户手抖点了两下不会立刻被拦。nodelay表示突发请求立即处理而不是排队,避免正常用户等待。
应用层限流可以用Redis做滑动窗口,粒度更细,能结合业务逻辑。比如限制单IP每分钟最多5次短信请求:
import redis import time r = redis.Redis() def check_ip_limit(ip, limit=5, window=60): key = f"sms:ip:{ip}" now = time.time() pipe = r.pipeline() pipe.zremrangebyscore(key, 0, now - window) pipe.zadd(key, {str(now): now}) pipe.zcard(key) pipe.expire(key, window) _, _, count, _ = pipe.execute() return count <= limit注意:滑动窗口比固定窗口更平滑,不会出现“窗口边界双倍流量”的问题。固定窗口在窗口切换的瞬间可能放过两倍请求,这个坑我踩过。
3.2 第二层:手机号维度的发送限制
IP可以换,但手机号是攻击者绕不开的——因为短信最终要发到这个号码上。所以手机号维度的限制是防护体系里最关键的一环。核心策略是:同一手机号在时间窗口内只能发送有限次数的验证码。
具体参数怎么定?我的经验值是:同一号码60秒内只能发1次,1小时内最多5次,24小时内最多10次。这个参数是根据正常用户行为统计出来的——正常人不会一分钟内反复点发送,也不会一天要十几次验证码。超过这个频率的,要么是攻击,要么是异常用户,都值得拦一下。
实现上同样用Redis,key用手机号:
def check_phone_limit(phone): now = int(time.time()) # 60秒内只能发1次 if r.get(f"sms:phone:60s:{phone}"): return False, "请求过于频繁,请稍后再试" # 1小时内最多5次 hour_key = f"sms:phone:1h:{phone}" hour_count = r.incr(hour_key) if hour_count == 1: r.expire(hour_key, 3600) if hour_count > 5: return False, "今日发送次数已达上限" # 24小时内最多10次 day_key = f"sms:phone:24h:{phone}" day_count = r.incr(day_key) if day_count == 1: r.expire(day_key, 86400) if day_count > 10: return False, "今日发送次数已达上限" # 记录60秒锁 r.setex(f"sms:phone:60s:{phone}", 60, "1") return True, "ok"提示:这里的计数要在短信真正发送成功后才累加,否则用户因为网络问题重试也会被计数,体验很差。我一般把计数逻辑放在短信网关返回成功之后。
3.3 第三层:人机校验,把脚本挡在门外
频率限制能挡住大部分脚本,但挡不住精心设计的低频攻击。这时候就需要人机校验出场了。常见的手段有图形验证码、滑块验证、行为验证等。核心思路是:在发送短信之前,先让用户完成一个“人类容易、机器困难”的任务。
图形验证码是最基础的,但现在的OCR识别已经很强了,简单的字符验证码基本形同虚设。我更推荐滑块验证或行为验证,这类验证通过分析鼠标轨迹、点击行为来判断是不是真人,对抗成本高很多。
接入方式通常是这样的流程:前端先请求一个验证token,用户完成验证后拿到token,发送短信时带上这个token,后端校验token的有效性和一次性。关键点是token必须一次性使用,用完即废,防止重放。
def verify_captcha_token(token, phone): # token存在且未被使用 key = f"captcha:token:{token}" if not r.get(key): return False # 校验token绑定的手机号是否一致 bound_phone = r.get(key) if bound_phone != phone: return False # 一次性使用,立即删除 r.delete(key) return True注意:token一定要绑定手机号,否则攻击者可以拿一个验证过的token去刷不同号码。这个细节很多人会忽略。
3.4 第四层:设备指纹与行为风控
到了这一层,防护就从“规则”升级到“风控”了。设备指纹是通过收集浏览器或客户端的各种特征(UA、屏幕分辨率、时区、Canvas指纹等)生成一个相对稳定的设备标识。即使用户换了IP,设备指纹还是能帮你识别出这是同一台设备。
行为风控则是分析请求的行为特征,比如:请求间隔是否过于均匀、参数是否呈现机器生成的规律、同一设备是否在短时间内请求了多个不同号码。这些特征组合起来,能识别出很多规则限流漏掉的攻击。
举个实际例子:正常用户从打开页面到点击发送短信,中间会有几秒的停留和操作时间;而脚本往往是直接调接口,从页面加载到请求发送可能只有几十毫秒。这个时间差就是一个很好的风控特征。
def risk_score(request): score = 0 # 页面停留时间过短 if request.get("page_stay_ms", 0) < 1000: score += 30 # 同一设备短时间内请求多个号码 device_id = request.get("device_id") recent_phones = r.smembers(f"device:phones:{device_id}") if len(recent_phones) > 3: score += 40 # 请求间隔过于均匀(标准差过小) intervals = get_recent_intervals(device_id) if len(intervals) > 3 and statistics.stdev(intervals) < 0.1: score += 30 return score分数超过阈值就触发二次验证或直接拦截。这套东西不需要一开始就做得很复杂,先从几个最明显的特征入手,跑一段时间看效果再迭代。
3.5 第五层:监控告警与应急响应
再好的防护也可能被突破,所以监控告警是最后一道保险。核心监控指标包括:短信发送量、发送失败率、接口QPS、各维度限流触发次数、验证码校验失败率。这些指标要设置合理的阈值,一旦异常立即告警。
我的经验是设置动态基线比固定阈值更有效。比如短信发送量,工作日白天有个正常波动范围,凌晨应该是低谷。如果凌晨突然出现高峰,那基本就是被攻击了。用历史数据算一个基线,偏离超过一定比例就告警。
应急响应方面,要提前准备好一键降级开关:比如临时关闭短信发送、切换到更严格的验证策略、启用备用短信通道等。攻击来的时候时间就是金钱,临时写代码肯定来不及,必须提前把开关做好。
4. 核心环节实操:把防护策略落到代码里
前面讲了体系,这一节把几个核心环节的具体实现掰开揉碎讲清楚。这些都是可以直接拿去用的方案,参数可以根据自己的业务调整。
4.1 短信发送接口的完整防护流程
一个健壮的短信发送接口,请求进来之后应该经过这样一串检查,任何一环不通过就拒绝:
- 参数校验:手机号格式、验证码类型是否合法
- IP限流:单IP频率检查
- 人机校验:验证token是否有效
- 手机号限流:号码维度的发送频率检查
- 风控评分:设备指纹和行为特征评分
- 发送短信:调用短信网关
- 记录日志:记录本次请求的所有维度信息,用于后续分析
这个顺序是有讲究的。把成本低的检查放在前面,比如参数校验和IP限流几乎不耗时,先做能快速拒绝掉大部分无效请求。人机校验和风控评分相对耗时,放在后面。短信发送是真正产生成本的,放在最后。
def send_sms(request): phone = request.get("phone") # 1. 参数校验 if not is_valid_phone(phone): return error("手机号格式不正确") # 2. IP限流 if not check_ip_limit(request.ip): return error("请求过于频繁") # 3. 人机校验 if not verify_captcha_token(request.get("captcha_token"), phone): return error("验证失败,请重新验证") # 4. 手机号限流 ok, msg = check_phone_limit(phone) if not ok: return error(msg) # 5. 风控评分 if risk_score(request) > 70: return error("操作异常,请稍后再试") # 6. 发送短信 result = sms_gateway.send(phone, generate_code()) if not result.success: return error("发送失败,请稍后再试") # 7. 记录日志 log_sms_request(request, phone, result) return success()提示:日志一定要记录完整,包括IP、设备指纹、手机号、时间戳、风控评分。出问题的时候这些日志就是排查的依据。我一般会把日志保留至少30天。
4.2 验证码的生成、存储与校验
验证码本身的安全也很重要。几个关键点:验证码要足够随机、有效期要短、校验次数要限制、校验成功后立即失效。
生成用密码学安全的随机数,不要用普通的random:
import secrets def generate_code(length=6): return ''.join(secrets.choice('0123456789') for _ in range(length))存储用Redis,设置5分钟过期。key的设计要包含手机号和场景,避免不同场景的验证码互相干扰:
def save_code(phone, scene, code): key = f"sms:code:{scene}:{phone}" r.setex(key, 300, code)校验的时候要限制尝试次数,防止暴力破解。6位数字有100万种组合,如果不限次数,脚本很快就能撞出来:
def verify_code(phone, scene, input_code): key = f"sms:code:{scene}:{phone}" fail_key = f"sms:code:fail:{scene}:{phone}" # 检查失败次数 fail_count = int(r.get(fail_key) or 0) if fail_count >= 5: return False, "验证次数过多,请重新获取验证码" stored_code = r.get(key) if not stored_code: return False, "验证码已过期" if stored_code.decode() != input_code: r.incr(fail_key) r.expire(fail_key, 300) return False, "验证码错误" # 校验成功,立即删除 r.delete(key) r.delete(fail_key) return True, "ok"注意:验证码校验成功后一定要删除,否则同一个验证码可以被重复使用。这个漏洞在很多系统里都存在,攻击者拿到一个验证码就能反复登录。
4.3 登录注册接口的防枚举设计
登录注册接口的防枚举,核心原则是不要通过返回信息泄露号码是否存在。具体做法:
- 登录失败时,统一返回“手机号或密码错误”,不要区分“用户不存在”和“密码错误”
- 注册时,如果号码已存在,不要直接提示“已注册”,可以走一个“该号码可能已注册,请尝试登录或找回密码”的模糊提示
- 找回密码接口同理,无论号码是否存在,都返回“如果该号码已注册,验证码已发送”
这些设计会增加一点开发成本,但能有效防止攻击者批量探测用户数据。另外,登录接口也要做人机校验和频率限制,尤其是密码错误次数要限制,防止撞库。
def login(phone, password): # 登录失败次数限制 fail_key = f"login:fail:{phone}" fail_count = int(r.get(fail_key) or 0) if fail_count >= 10: return error("账号已锁定,请15分钟后再试") user = find_user(phone) # 统一返回,不区分用户是否存在 if not user or not verify_password(user, password): r.incr(fail_key) r.expire(fail_key, 900) return error("手机号或密码错误") r.delete(fail_key) return success(create_token(user))4.4 短信通道的兜底与降级
即使防护做得再好,也要考虑短信通道本身出问题的情况。比如通道被攻击打满、运营商侧限流、余额不足等。这时候需要有降级方案:
- 多通道备份:接入至少两家短信服务商,主通道异常时自动切换
- 余额监控:余额低于阈值时告警,避免发不出短信
- 发送量熔断:单位时间内发送量超过阈值时自动暂停,人工确认后再恢复
熔断这个机制特别重要。攻击来的时候,如果没有任何熔断,短信会一直发到余额耗尽。设置一个合理的熔断阈值,比如“5分钟内发送量超过1000条自动暂停”,能帮你把损失控制在一定范围内。
def sms_circuit_breaker(): key = "sms:send:count:5min" count = int(r.get(key) or 0) if count > 1000: # 触发熔断,发送告警 alert("短信发送量异常,已自动熔断") return False return True5. 常见问题与排查技巧实录
防护体系上线之后,会遇到各种各样的问题。这一节把我实际踩过的坑和排查经验整理出来,希望能帮你少走弯路。
5.1 误伤正常用户怎么办
这是最常见的问题。限流太严,正常用户被拦;限流太松,攻击挡不住。我的经验是先松后紧,用数据说话。上线初期把阈值设得宽松一些,观察一周的正常流量分布,找到99%正常用户都在的范围,然后把阈值设在这个范围稍微靠外一点的位置。
另外,给正常用户留申诉和恢复的通道。比如用户被误拦了,可以走人工客服解封,或者等待一段时间后自动恢复。不要一棍子打死。
5.2 攻击者换IP太快,IP限流没用
这是代理池攻击的典型表现。解决办法是把限流维度从IP扩展到手机号+设备指纹。IP限流作为第一层,手机号限流作为第二层,设备指纹作为第三层。攻击者可以换IP,但换手机号和设备的成本高得多。
如果攻击者连手机号也在批量换,那就说明他在做号码枚举,这时候重点应该放在人机校验上,让每个号码的请求都必须通过验证。
5.3 验证码接口被刷但短信没发出去
这种情况通常是攻击者在探测接口,或者你的限流生效了但攻击者还在持续请求。虽然短信没发出去,但接口被大量无效请求占用,可能影响正常用户。这时候要检查限流是否在最外层生效,尽量在网关层就把请求拦掉,不要让它打到应用层。
5.4 短信费用还是涨了,但找不到原因
如果防护都做了费用还是异常,可能是这几个原因:一是限流参数配置错误,比如Redis key写错了导致限流没生效;二是短信网关被直接调用,绕过了你的业务接口;三是内部系统泄露,比如测试环境用了生产短信通道。
排查的时候先看短信网关的调用日志,确认请求来源。如果是业务接口来的,检查限流逻辑;如果是其他来源,检查密钥管理和环境隔离。
下面这张表把常见问题和排查方向做个汇总:
| 问题现象 | 可能原因 | 排查方向 |
|---|---|---|
| 正常用户被拦截 | 限流阈值过严 | 分析正常流量分布,调整阈值 |
| IP限流无效 | 代理池攻击 | 增加手机号、设备指纹维度 |
| 接口QPS高但短信量正常 | 接口被探测 | 检查网关层限流是否生效 |
| 短信费用异常 | 限流未生效或通道泄露 | 检查Redis key、网关日志、密钥管理 |
| 验证码被暴力破解 | 校验次数未限制 | 增加失败次数限制和锁定机制 |
5.5 几个容易被忽略的细节
最后分享几个我在实际项目里总结的细节,都是容易忽略但很关键的:
- Redis key的命名要规范,包含业务、维度、时间窗口,方便排查和清理。我见过因为key命名混乱导致限流互相覆盖的案例。
- 限流逻辑要做降级,Redis挂了不能导致整个短信接口不可用。可以降级到本地内存限流,虽然精度差一些但至少能用。
- 日志要脱敏,手机号在日志里要打码,避免日志泄露导致用户信息泄露。
- 压测很重要,上线前用压测工具模拟攻击流量,验证防护是否生效、性能是否扛得住。
- 定期复盘,每次攻击之后都要复盘,看看哪一层被突破了,怎么补强。防护体系是迭代出来的,不是一次做好的。
这套东西我在几个项目里都落地过,从最初的裸奔到后来的多层防护,短信费用从每月被刷掉几千块降到几乎为零。核心不是某一项技术多牛,而是把多个维度的防护组合起来,让攻击者的成本高于收益。攻击者也是要算账的,当他发现打你的成本比收益还高的时候,自然就换目标了。