短信验证码接口防恶意攻击短信防盗刷策略
如下是用户页面交互。输入手机号,即可获取验证码。用户体验方面已经超级简单了。
不过,简单是要有成本的。安全控制方面,程序员得琢磨。
在系统安全、信息安全、系统安全防御领域,短信盗刷是老生常谈的话题了。我们公司的系统也经历过至少3次盗刷。每次动辄损失2万~5万条的短信。
近几年,随着qq授权登录、微信授权登录等登录方式的流行,短信盗刷的情况似乎是少了。不过,互联网企业总是习惯要留下用户的手机号的,毕竟这么做非常利于流量获取。
短信验证码登陆,通常的做法是图形验证码。简单实现的话,就是 当用户输入的手机号发生变化时,页面异步请求服务端生成图形验证码的接口,服务端返回图片文件流,页面生成验证码图片。用户输入验证码,然后请求服务端获取验证码的接口。服务端会校验用户输入的验证码是否正确,正确了才会发送短信验证码。
因为图形验证码是通过文件流传输的,所以很难破解。当然,倒是有识别图片的工具,不管怎么说,还是有一定难度的。不识别图片呢?随机生成4位验证码,用撞库的方式来恶搞?显然,命中的几率也很小。就是说,用图形验证码的方式,恶意攻击的难度比较大。 我们看12306或其他的互联网网站,动不动让选特定的图形,或滑动拼图,或依次选特定的文字,这种安全性都是相当高的。
据说,阿里的招数更绝!可以记录鼠标在页面的轨迹,进而识别出来是人在操作,而非机器模拟。
所谓安全,安防,说白了,是防君子不防小人的,道高一尺魔高一丈。我们只能做到更安全一些,最大程度减少恶意攻击导致的短信资源浪费。没办法做到100%最安全。
言归正传!
我们这种需求是一个乘客注册/登陆的页面。乘客输入手机号,然后点击获取验证码,系统会判断,如果是新用户,或用户状态正常,就会发送短信验证码。考虑到较好的用户体验,没加图形验证码。
这种简洁的操作,如果被非正常的用户利用,那可就麻烦了。那么,如何最大程度规避短信盗刷呢?
我们先分析一下非正常的场景:
┣ 短信接口泄露出去了。日常办公大家疏于信息传递,导致接口泄露。
┣ 接口在网络上被截获。
┣ 短信服务商作祟。不排除这种情况呵~
┣ “内鬼”,江湖险恶呀~
以上情况,短信接口如果是裸奔的,就会被当做小白鼠为人所恶搞。
裸奔的短信验证码接口:
GET /api/sendSmsCode?phone=*** HTTP/1.1
Host a.b.com
只要拼一个类似于 a.b.com/api/sendSmsCode?phone=18812345678 的url就可以触发一条短信。恶搞这种小白鼠接口,是不是很刺激?
接下来,我们对这个接口来做安全控制。
【首先】必备的参数校验不可少
0.1 手机号合法性校验。➀不能为空 ➁11位 ➂以1开头 ➃校验前两位或前三位号段,比如13、15、18、131/2、152、183/6/8/9...(可选,稍有不慎,就有可能会过滤掉正常的号码)。➄过滤特殊号码,比如88888888、11111111、22222222、12345678、38383838...
【其次】我们来分析正常的浏览器请求:
▼Request Headers:
POST /api/passenger/sendSms HTTP/1.1
Host: che.shenbianhui.cn
Connection: keep-alive
Content-Length: 43
Accept: application/json, text/plain, */*
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/83.0.4103.116 Safari/537.36
Content-Type: application/json;charset=UTF-8
Origin: http://che.shenbianhui.cn
Referer: http://che.shenbianhui.cn/
Accept-Encoding: gzip, deflate
Accept-Language: zh-CN,zh;q=0.9,en;q=0.8
Cookie: pgv_pvi=2428115968; UM_distinctid=170257b17e51b3-01ea5235ff274e-b383f66-e1000-170257b17e6177; Hm_lvt_cb56ec9ce26d8a82ead7aa15af69e6e0=1581176790,1581304635,1581499290; Hm_lvt_29c6c62e8f0bd1061bcc0e3cb6b3d53d=1583891251,1585192233,1586140252
▼Request Payload:
{"phone":"17813270522","userType":"driver"}
1.1. 方法用POST请求
1.2. 判断请求头参数。服务端只接收正常浏览器请求。
1.2.1 校验User-Agent。使用User-Agent防止HttpClient发送http请求时403 Forbidden和安全拦截
1.2.2 校验Reffer
1.2.3 Header里增加额外参数。后文有关于key或ticket的策略。可以把这些参数追加到请求Header里。
至于点对点的攻击,也可以伪造User-Agent、Reffer的值,伪装成正常的浏览器请求。所以,这远远不够。继续往下看。
【再次】请求次数限制
分布式系统直接利用redis的incby来实现计数即可。
redisUtil.set(key:CommonConstant.MSG_TIMES.concat(today), value:0, seconds:60*5);
redisUtil.incr(key:CommonConstant.MSG_TIMES.concat(today), delta:1L);
2.1 增加IP次数限制。B/S型的对外网站,我们无法做IP白名单控制。不过,同一IP,在指定时间段之内,请求次数要做上限控制。比如5分钟之内不超过50次。这要根据业务情况来评估。
恶意请求有时会用代理IP,当然,使用代理IP本身是有成本的。
2.2 “一刀切”“限流” 在指定时间段之内,总的请求次数不能超过阈值。比如,5分钟内总请求量不能超过1000次。这要根据业务情况来评估。
需要说明的是,这种对请求次数做限制的策略,时间段一定要“合理”,否则可能形同虚设。拿上面的IP限制来说,如果设定成1天内同一IP不超过500次,那估计没什么卵用。别人要攻击你,肯定不是细水长流那样的,而是突袭,可能是0点突然来一炮或12点突然来一炮。
2.3 同一个手机号,特定时间内(比如30秒)不可重复请求验证码。我们在用户页面是经常可以看到的。点击完获取验证码后,会有按秒的倒计时提示。在此时间内是不能重新发起的。自然,服务端也要做这个校验。(前后端双重校验)
【第四】接口参数复杂化
3.1 增加一个key参数,就像支付接口中常见的签名一样。
3.1.1生成key的规则:前后端约定。同时,尽量保证每次请求的key都不同。比如:手机号=18612345678,则key=MD5(手机号前3位186 + 手机号后3位678 + 当前时间/分钟)
前后端都用这种方式生成key,前端页面通过js脚本生成“签名”,服务端“验签”。
需要注意的是:时间校验要留buffer,客户机时间与服务器时间并不完全相同。可以用循环或递归算法搞定。
3.2 更靠谱一点的方案。从以上的方案进一步脑洞大开。服务端增加一个生成key的api。一旦用户修改了手机号,就调用api获取一个key,获取验证码的时候同时上送这个key。这样,短验接口每次校验key是否一致就可以了。
我们通常的保证幂等性的方案,也是生成一个ticket,用户端提交数据的时候,服务端校验ticket,ticket匹配才持久化数据,然后删除ticket;服务端一旦发现ticket不存在,则视为非法请求。
3.3 如果能做到让短验下发的接口里不用传手机号,岂不是更安全呀! 我曾经让组里的小伙伴们思考过这个问题。好,公布答案——上面3.2的方案,利用手机号生成key之后,服务端保存手机号和key的关系,然后在短信下发接口直接上送这个key,就像生物界的“拟态”,借以蒙蔽敌害,保护自身。
另外,上面提到的加key策略,可以同时加2个或者3个key,从而给人一种“视觉”混淆,更大程度防御攻击。我们知道,很多互联网系统的用户密码采用加盐(salt)加密的方式来实现,也是利用了这种思想。
我们再看看这个api很有可能会是下面这个样子。
POST /api/sendSmsCode HTTP/1.1
Host a.b.com
{"phone":"***","key":"092E080F5845904EBCFF5F242A87F4DD","code":"O7b1"}
Header["ticket"]:171149199774508c7f17787b1711252400
甚至华丽变身成如下的样子。
POST /api/sendSmsCode HTTP/1.1
Host a.b.com
{"key":"092E080F5845904EBCFF5F242A87F4DD","ticket":"171149199774508c7f17787b1711252400"}
综合以上方案的控制,我们就能很大程度保证接口的安全。
只要思想不滑坡,方法总比困难多。
BTW,3.1和3.2的方案,需要前后端配合。当我们找项目组的前端小伙伴讨论时,前端小伙写VUE、NODEJS、JavaScript脚本相当醇熟,他觉得这样做没有什么意义。他打开浏览器的调试工具,说别人一看就知道怎么回事了。这种方案,充其量也就能有1%的改善。我的观点:1.并不是所有的人都知道这个页面的存在;2.并不是所有的人都能找到那段js代码;3.并不是所有的人对前端都很熟。 因为这个页面是他亲自做的,接口是他亲自调用的,所以,他很了解。并不是所有的人都像他一样了解的,包括我们这些后端的程序员。 让我想到一句话:手里有把锤子,看什么都是钉子。 一个人的思维会影响行动。也许,有些技术偏执的人,多少都具备一点个性和不羁吧。