我接到的是一个常规授权测试任务,目标是某安全厂商的官网。前端的注册、登录、密码找回、绑定手机号,这些都是验证码短信的高频入口。测试还没深入后台,一个不起眼的短信发送接口就暴露了明显问题:它既没有来源校验,也没有有效的频控,手机号参数完全由前端决定。这类接口问题在安全测试里属于典型的“低技术含量、高业务影响”,但造成的损失非常直观——短信通道按条计费,脚本一旦跑起来,可以在很短的时间内把预充值费用刷掉数万元。
这篇文章就以这次测试为背景,聊聊短信接口安全测试该怎么测、这类隐患为什么反复出现、以及从发送源头到业务侧应该怎么堵。适合三类人看:正在做渗透测试的安全工程师、负责账号体系或消息系统的后端开发,还有想自己检查官网短信成本风险的运维和业务负责人。
1. 短信接口为什么值得专门做一轮安全测试
大多数团队对短信接口的态度是“能用就行”,它能发出去、验证码能校验通过,就算完成需求。可在安全视角下,短信接口是一个直接与资金挂钩的对外暴露面,它的失控成本远超普通接口漏洞。
1.1 一条短信背后牵着的真金白银
先算一笔账。假设通道商的结算价是0.045元/条,这个价格在市场上很常见。一个没有任何频控的短信接口,自动化脚本用并发线程持续调用,实际打出来的速率可以非常夸张。即便按单线程每200毫秒发一条来算,一小时也能发出1.8万条,成本差不多810元。如果无人发现,持续一个晚上就是8000多元。配合代理池和号码池把速度提上去,几小时内烧掉数万元完全不是夸张说法。
更麻烦的是通道封停。很多通道商对发送量、发送频率、投诉率都有硬性指标,短时间爆发会被直接关停。通道一封,全平台的验证码业务跟着停摆,所有用户都没法正常登录注册。这时的损失就不只是短信费了,还有整个平台的可用性。
1.2 短信接口在业务里的暴露面比想象中大
登录、注册、找回密码、更换手机号、修改支付密码、二次验证……只要业务有账号体系,短信接口就会出现在多个页面。每个入口都是同一个后端能力的复用,只要其中一处没有控制好,等于所有入口都开放。
测试时尤其要注意“找回密码”这类场景。登录注册页好歹有图形验证码,找回密码往往为了“用户体验”把验证前置条件做得极简,有的连图形验证码都没有,只有手机号和发送按钮。这种接口一旦被脚本盯上,发出去的每一条短信都是成本,而且面向的用户群体里有大量真实手机号,还会引发骚扰投诉。
1.3 这次测试的边界与前提
这次测试是在书面授权范围内进行的,目标是“某安全厂商官网”,本文统一称为被测站点A。需要强调一句:对任何站点做这类测试,都必须提前确认授权范围,尤其是短信接口会真实消耗费用、真实触达用户,测试量必须严格控制在最小范围内。
这篇文章里所有验证动作,都是在授权环境下少量请求、用自查号码完成的,不会提供批量发送的利用脚本。目的是让开发和安全同学理解漏洞原理和修复方案,而不是教人刷接口。
2. 从信息收集到风险验证:一次完整的短信接口测试实录
短信接口的测试链路并不长,但每一步都可以拆出细节。下面按我实际操作时的顺序拆开讲。
2.1 第一步:在页面上找到所有会触发短信的下发点
打开被测站点A的域名,注册一个新账号,用浏览器开发者工具的网络面板盯着,点击“获取验证码”按钮,观察发出的Ajax请求。常见路径有/api/sms/send、/captcha/send、/auth/sendCode这一类的,具体的路径名不重要,关键是要抓到原始的请求包。
请求包里面的参数,常见的几类是这样的:
| 参数名 | 含义 | 测试关注点 |
|---|---|---|
| mobile / phone / tel | 手机号 | 是否由前端直接指定而不经服务端重新获取 |
| type / scene / businessType | 短信场景类型 | 是否可以被改成其他场景,拿到不同的模板 |
| token / ticket | 前端人机验证通过的凭证 | 是否可以缺失、是否可以重放 |
| source / clientType | 来源标记 | 是否被服务端信任,能否随意伪造 |
抓包之后第一步不是急着改参数,而是先看看有没有明显的异常。用浏览器直接访问这个发送接口的URL,如果返回了“请通过页面访问”之类的提示,说明至少有一道校验;如果直接返回参数错误或成功的JSON,那入口就是裸奔的。
2.2 第二步:把请求拆开,逐项确认参数是否可信
拿到原始请求后,我习惯一个个参数试,原则是“一次只改一个变量,观察服务端反应的变化”。
第一项是手机号。把mobile参数替换成一个虚构的测试号码,如果服务端仍然返回“发送成功”,说明这个参数完全可信。这里的关键不是替换本身,而是确认服务端在生成验证码时,是否真的拿这个参数作为接收方。有的系统会用会话里的手机号,前端传的参数只是展示用,那问题就不在这。
第二项是场景参数。短信接口经常用type来区分“注册验证码”“登录验证码”“营销通知”,服务端根据type加载不同的短信模板。把type从注册改成其他值,如果返回成功并且收到的短信内容风格发生了明显变化,说明模板被成功切换了。这个风险再往前走一步,就是有人可以通过控制模板内容把一个验证码接口变成自由短信网关。
第三项是来源校验。很多接口不带登录态就能调用,因为验证码发送本身就是匿名场景。这时就看服务端有没有校验来源页面。现实中相当多的实现只检查了Referer请求头,或者干脆不检查。而Referer这种请求头是客户端随意可控的,用命令行工具指定一个页面地址就能轻松绕过。
2.3 第三步:低噪声验证限频与来源校验是否生效
限频是否生效,是这次测试里最核心的判断。做法很简单:同一个测试号码,大约间隔一分钟请求一次,连续请求三到四次,观察每次的响应。如果每次都返回成功,说明按号码维度的短时频控不存在或阈值很高。
再换一种测法:用不同的号码去请求,但都走同一个出口IP,观察单位时间内IP维度的限频是否存在。如果接口从同一样IP连续发出去几十条都成功,那基本可以判断IP维度的频控也缺失。
这里我强烈建议,验证动作做到这个程度就足够了。安全测试的目的是判定风险是否存在,而不是证明它到底能达到多大的破坏力。我曾见过有测试人员为了写报告,把一个短信接口打了上千条,最后通道被封,业务方直接炸锅。正确的做法是:少量请求、观察状态码变化、用响应差异推导逻辑。比如第一次请求成功、第二次也成功、第三次仍然成功,就已经可以判定“无频控”,不需要再发更多条去证实。
2.4 测试过程中的自我保护:量级控制与数据脱敏
短信接口测试最容易踩的坑就是“测着测着把自己变成攻击者”。
除了控制请求量,还要注意使用的号码。最稳妥的方式是准备一个专用测试号码,而不是拿真实用户手机号测。如果被测环境支持测试白名单,那更好,提前在通道商后台把号码加进去,既能验证功能,又不会产生费用和骚扰。其次,观察接口响应时,注意检查是否存在“验证码明文放在响应里”的低级问题。有一次测试,我点开发送验证码接口的响应JSON,里面直接把四位数字验证码放到data字段里了。这种问题不是漏洞利用的难点问题,但影响面极广,属于必然要写进报告的高危项。
3. 隐患的根因:为什么这类问题总是反复出现
短信接口漏洞在代码层面并不复杂,但它在真实项目里的出现频率高得吓人。总结起来,根子还是出在开发对这块功能的定位上。
3.1 发送与校验被当成“简单功能”,而不是“资金出口”
很多后端的Controller代码里,发送验证码就是一个十几行的接口:接收手机号、生成随机数字、调用短信SDK、落库。开发者没有意识到,这十几行代码背后是一条按条计费、面向全网开放的通道。同样一段代码,如果是发送电子邮件的接口,被脚本刷一下最多就是邮件服务商的风控提示,损失几乎为零。但换成短信,每一条都在产生成本,单位时间内对外可被调用的频率直接对应资金消耗速度。
这种定位偏差导致了一系列连锁缺失:不做频控、不做总量控制、不设熔断、不接入告警。常规功能测试又只会验证“成功发送”和“验证码能登录”,不会有人去关心“这个接口能不能被陌生设备连续调用一小时”。
3.2 限频设计只覆盖单维度,绕过思路一抓一大把
即便有的团队做了频控,也常常只做一个维度。最常见的是只限制同一手机号一分钟只能发一次。这个限制在真实攻击场景面前基本等于没有:攻击者手里有号码池,换一个号码继续调用,一分钟一次的限制完全不影响批量发送。而且这类限频通常是用本地内存计数器实现的,只对单实例生效,应用一扩容、负载一均衡,频控各自独立,效果又在打折。
更隐蔽的问题是频控键设计不恰当。有些系统把频控key设计成拼接了手机号和场景,例如register:138xxxx和login:138xxxx是两条独立的计数。如果一个业务有九个场景,攻击者按顺序轮询九个场景,每个场景的限额都是充足的,等于是把限制拆成了九份。
3.3 参数表面上是“场景标识”,实际上可以被任意改写
开发给type参数加注释时通常会写“短信场景类型:1=注册,2=登录”。可在服务端,如果只拿这个type去查模板,而不校验它跟当前页面流程是否匹配,那这个参数就成了可被任意利用的开关。攻击者完全可以把“注册验证码”接口改成发营销短信、发通知短信,内容模板都由参数决定。
同理,来源校验如果只检查Referer请求头,也是非常薄弱的。请求头来自客户端,用curl、浏览器插件、脚本都能随意指定。真正可靠的来源校验是服务端生成的一次性票据:页面先调用人机验证接口,拿到服务端签发的短期token,发送短信时再校验这个token。token要在使用后立即失效,并且与手机号、场景绑定,防止重放和窜改。
4. 从发送源头到业务侧:短信接口加固的完整方案
发现问题之后,修复方案不能只停留在“加个if,限制一下”。真正有效的加固,需要覆盖参数校验、频控、人机校验、熔断、监控五个层面。
4.1 服务端强制参数校验与模板化下发
短信内容永远不应该由前端传进来。前端传的只能是场景类型,后端根据场景类型从模板库加载内容,模板库里的参数也要做白名单处理。手机号参数必须做严格的格式和号段校验,这一点上代码里很常见,但经常被漏掉的是国家码:有些接口允许传国家码参数,如果不做白名单,攻击者可以把一条短信发到任意境外号码上,成本更高。
下面这段是发送前检查的最小化的逻辑,核心点在于先校验参数,再检查频控,最后才真正调通道:
public SendResult sendVerifyCode(String mobile, String type, String clientIp, String deviceId) { // 1. 手机号格式校验,符合中国大陆11位手机号规范 if (!Validator.isValidMobile(mobile)) { return SendResult.invalid("手机号不合法"); } // 2. 模板必须存在于模板库,且处于启用状态 SmsTemplate tpl = smsTemplateService.getByType(type); if (tpl == null || !tpl.isEnabled()) { return SendResult.invalid("模板不可用"); } // 3. 前置票据校验,这里校验从人机验证服务拿到的 ticket if (!ticketService.verify(ticket, mobile, type)) { return SendResult.invalid("人机验证未通过"); } // 4. 频控:同一号码、同一 IP、同一设备三个维度都查 if (rateLimiter.isLimited("sms:m:" + mobile, 1, Duration.ofMinutes(1))) { return SendResult.busy("同一号码请求过于频繁"); } if (rateLimiter.isLimited("sms:ip:" + clientIp, 20, Duration.ofMinutes(10))) { return SendResult.busy("当前网络请求过多"); } if (rateLimiter.isLimited("sms:d:" + deviceId, 5, Duration.ofHours(1))) { return SendResult.busy("设备使用频率超限"); } // 5. 全站当日总量检查 if (dailyCounter.isReachedMax("sms:total", 100000)) { return SendResult.busy("今日短信总量已达上限"); } return smsChannel.send(mobile, tpl, buildParams(mobile, type)); }这段代码里比较容易被忽略的是票据校验放在频控前面。如果先查频控,再有票据校验,攻击者可以用大量无效票据做探测,把频控计数打满,造成合法用户发送失败。顺序这种事,在真实故障里经常是坑。
4.2 多维频控:用户维度、设备维度、IP维度、全站总量
频控维度需要按风险等级区分,不是说所有维度都要紧。我的建议是四层都设,但阈值从宽到严排列:
| 限制维度 | 限制粒度 | 建议阈值 | 说明 |
|---|---|---|---|
| 手机号维度 | 1条/分钟 | 严格 | 正常业务下同一号码一分钟内不应收到第二条验证码 |
| 设备维度 | 5条/小时 | 适中 | 设备ID可以从请求头或前端埋点获取,篡改成本略高 |
| IP维度 | 20条/10分钟 | 适中 | 考虑出口NAT场景,阈值要大于办公网和家庭宽带的正常量 |
| 全站总量 | 日上限 | 动态 | 按历史日峰值乘以1.2到1.5倍,超限自动停线,次日恢复 |
频控实现层面,不要用本地内存计数器,多实例部署时会失效。推荐用Redis这类集中式存储,配合原子自增和过期时间。注意频控的“单位时间窗口”最好用滑动窗口,固定窗口在临界点会被打满双倍请求。
4.3 发送源头的前置人机校验
短信接口前面扣一道图形验证码或滑块,是目前性价比很高的防线。人机验证的目的不是让攻击者过不去,而是显著提高自动化调用的成本。
关键在于人机校验必须在服务端完成,并且要返回一次性票据。前端的图形验证码再怎么炫,只要后台不校验结果,就是摆设。票据要有有效期,一般在60到90秒内有效,使用后立即失效,还要跟手机号绑定。如果票据没绑手机号,攻击者可以通过先过一遍人机验证拿票据,再把这个票据用到任意手机号上。
4.4 成本熔断与异常监控
限频和验证码拦不住所有情况,所以最后一道防线是成本熔断。
通道商后台一般都有日发送量限额设置,这个必须开。比如平台正常一天发10万条,就把通道商的限额设到15万条,达到即停发。这是外部兜底,防止应用层逻辑被绕过时短信费失去控制。平台内部还要有总量监控,用Redis或数据库统计当天的发送量,一旦超过阈值自动触发熔断开关。同时接入告警,把发送量异常增长告警发送到值班群,比如环比前一天同时段增长超过50%就告警。
这是我见过很多公司忽略的一环:应用层的频控做得再严,一旦上线配置错了,或者被绕过了,没有任何熔断机制兜底,损失还是会发生。正规的安全测试报告也会建议把“成本熔断”列为必须整改项。
4.5 修复后的回归验证
加固完成后,不能只让研发说一句“改好了”就结束。测试人员要复测同样的路径:用同一个测试号码连续请求,确认第二次开始返回频率限制;换不同号码同IP请求,确认IP维度限制生效;绕过人机验证直接调用接口,确认返回未通过;修改type参数,确认模板仍然不对外暴露。
回归的重点是确认“限制策略没有误伤正常业务”。频控阈值设得太低,会导致用户在WiFi环境下登录大量手机号时被限制,这种问题在测试环境不容易暴露。建议复测的时候模拟一下“一个手机号两台设备同时登录”这类正常场景,确保风控规则不会把真实用户挡在门外。
5. 测试之外:报告输出与后续跟进的经验
漏洞发现和修复之外,还有一批容易被忽略的工作,恰恰是决定一次安全测试价值能不能落地的关键。
5.1 报告要写清楚“为什么会损失数万”
给业务方和研发看的安全测试报告,不能只写“短信接口存在未授权访问漏洞,等级高危”,这句话太干。更有说服力的写法是:先说业务影响,再给技术细节。
我在报告里一般会这样组织:
- 漏洞位置:被测站点A的短信发送接口,路径
/api/sms/send - 风险描述:接口未校验来源、未限制频率、手机号参数可任意指定,且响应中可直接看到发送结果
- 影响范围:全平台所有依赖验证码登录、注册、找回密码的业务入口
- 风险等级:严重(可直接导致资金损失和通道封禁)
- 复现条件:任意可联网设备,无需登录,无需图形验证码
- 加固建议:服务端模板化下发、多维频控、人机校验、全站总量熔断、监控告警
- 复测结果:修复后同一场景验证,限制生效,风险关闭
把“按0.045元/条计费”的成本模型放到报告里,业务方才会意识到这不是“一个技术小Bug”,而是与预算直接挂钩的风险项。
5.2 与研发沟通的节奏:先讲业务后果,再讲技术细节
短信接口的问题,研发第一反应往往是“这只是一个内部接口,外网不知道路径”。这种认知需要先纠正。要让研发明白:路径可以被枚举、前端页面源码会泄露、历史版本的JS文件可能还保留着旧接口地址。沟通时先讲“这个接口可以被脚本无限刷、每个小时可能烧掉几千块钱、通道会被封停”,再讲具体修复方案。
实际项目里,跟研发对齐“频控要在服务端做而不是只在前端做”“票据要一次性使用”“模板参数要做白名单”这三个点,修复质量基本就有保证了。我遇到的大多数修复不到位的情况,都是研发只理解了其中一两点,导致上线后还是能绕过。
5.3 特别提醒:别在测试阶段把自己变成攻击者
做短信接口测试时,身份很微妙:测试人员既有责任验证风险,又很容易在验证过程中越界。我的原则是三条:请求量控制在个位数到两位数之间,只使用自己可控的测试号码,不保存任何真实用户信息。报告里也只提风险和修复方案,不放批量利用代码。授权范围之外的动作,哪怕再顺手,也不应该做。
这些规矩不只是为了保护被测系统,也是保护测试人员自己。安全测试圈子里因为测试行为过界引发争议的例子不在少数,原因基本都是忽略了“最小验证”原则。
最后分享一个我自己的小习惯。每次测完短信接口,我都会在报告末尾加一条额外提醒——让负责通道的同事在通道商后台把测试号码加进白名单,同时确认日发送量限额真的已经开启。这两个动作都落实了,短信接口这轮测试才算真正收尾。看完这篇文章,你也可以自己检查一下平台的短信发送接口:它有没有做服务端频控、有没有模板白名单、有没有成本熔断。别把它当成一个普通的业务辅助功能,它的失控成本,远比想象中直接。