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 zonesms_limit:10m rate1r/s; location /api/sms/send { limit_req zonesms_limit burst3 nodelay; limit_req_status 429; proxy_pass http://backend; }这段配置的意思是每个IP每秒最多1个请求允许突发3个超出直接返回429。burst3是给正常用户留的缓冲比如用户手抖点了两下不会立刻被拦。nodelay表示突发请求立即处理而不是排队避免正常用户等待。应用层限流可以用Redis做滑动窗口粒度更细能结合业务逻辑。比如限制单IP每分钟最多5次短信请求import redis import time r redis.Redis() def check_ip_limit(ip, limit5, window60): key fsms: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次。这个参数是根据正常用户行为统计出来的——正常人不会一分钟内反复点发送也不会一天要十几次验证码。超过这个频率的要么是攻击要么是异常用户都值得拦一下。实现上同样用Rediskey用手机号def check_phone_limit(phone): now int(time.time()) # 60秒内只能发1次 if r.get(fsms:phone:60s:{phone}): return False, 请求过于频繁请稍后再试 # 1小时内最多5次 hour_key fsms: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 fsms: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(fsms:phone:60s:{phone}, 60, 1) return True, ok提示这里的计数要在短信真正发送成功后才累加否则用户因为网络问题重试也会被计数体验很差。我一般把计数逻辑放在短信网关返回成功之后。3.3 第三层人机校验把脚本挡在门外频率限制能挡住大部分脚本但挡不住精心设计的低频攻击。这时候就需要人机校验出场了。常见的手段有图形验证码、滑块验证、行为验证等。核心思路是在发送短信之前先让用户完成一个“人类容易、机器困难”的任务。图形验证码是最基础的但现在的OCR识别已经很强了简单的字符验证码基本形同虚设。我更推荐滑块验证或行为验证这类验证通过分析鼠标轨迹、点击行为来判断是不是真人对抗成本高很多。接入方式通常是这样的流程前端先请求一个验证token用户完成验证后拿到token发送短信时带上这个token后端校验token的有效性和一次性。关键点是token必须一次性使用用完即废防止重放。def verify_captcha_token(token, phone): # token存在且未被使用 key fcaptcha: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(fdevice: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 验证码的生成、存储与校验验证码本身的安全也很重要。几个关键点验证码要足够随机、有效期要短、校验次数要限制、校验成功后立即失效。生成用密码学安全的随机数不要用普通的randomimport secrets def generate_code(length6): return .join(secrets.choice(0123456789) for _ in range(length))存储用Redis设置5分钟过期。key的设计要包含手机号和场景避免不同场景的验证码互相干扰def save_code(phone, scene, code): key fsms:code:{scene}:{phone} r.setex(key, 300, code)校验的时候要限制尝试次数防止暴力破解。6位数字有100万种组合如果不限次数脚本很快就能撞出来def verify_code(phone, scene, input_code): key fsms:code:{scene}:{phone} fail_key fsms: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 flogin: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挂了不能导致整个短信接口不可用。可以降级到本地内存限流虽然精度差一些但至少能用。日志要脱敏手机号在日志里要打码避免日志泄露导致用户信息泄露。压测很重要上线前用压测工具模拟攻击流量验证防护是否生效、性能是否扛得住。定期复盘每次攻击之后都要复盘看看哪一层被突破了怎么补强。防护体系是迭代出来的不是一次做好的。这套东西我在几个项目里都落地过从最初的裸奔到后来的多层防护短信费用从每月被刷掉几千块降到几乎为零。核心不是某一项技术多牛而是把多个维度的防护组合起来让攻击者的成本高于收益。攻击者也是要算账的当他发现打你的成本比收益还高的时候自然就换目标了。