buguge - Keep it simple,stupid

知识就是力量,但更重要的,是运用知识的能力why buguge?

导航

短信验证码接口防恶意攻击短信防盗刷策略

 

 

如下是用户页面交互。输入手机号,即可获取验证码。用户体验方面已经超级简单了。

不过,简单是要有成本的。安全控制方面,程序员得琢磨。

 

在系统安全、信息安全、系统安全防御领域,短信盗刷是老生常谈的话题了。我们公司的系统也经历过至少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.s**hui.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.s**hui.cn
Referer: http://che.s**hui.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 从以上的方案进一步脑洞大开,我们思考更靠谱一点的方案。先说一个在接口幂等性方面常用的一个技术实现套路:用户端页面初始化时先调用服务端拿到一个ticket,用户端提交数据的时候,服务端校验ticket,ticket匹配则删掉ticket,然后才执行业务逻辑;否则,服务端一旦发现ticket不存在,则视为非法请求。-->我们就围绕这个技术套路来改进。-->服务端增加一个api,根据手机号生成具有TTL的key。用户点击“获取验证码”时,先获取一个key,然后调用短验接口时上送这个key。这样,短验接口每次校验key是否一致就可以了。当然,这个获取key的api也有必要做幂等和限频控制。

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).并不是所有的人都知道我们系统的这个登陆页;2).并不是所有的人都能在浏览器的页面源码里找到那段js;3).并不是所有的人对前端都很熟。  因为这个前端VUE是他亲自写的,接口是他亲自调用的,所以,他很了解,不代表别人也了解。 让我想到一句话:手里有把锤子,看什么都是钉子。 一个人的思维会影响行动。也许,有些技术偏执的人,多少都具备一点个性和不羁吧。

posted on 2020-07-14 14:57  buguge  阅读(4139)  评论(1编辑  收藏  举报