Java代码审计之短信验证码模块

  • A+

常见实现方式

  一般的短信验证码功能都是通过与短信平台的相关接口进行交互实现的。例如下面是通过聚合数据的接口实现的:

```java
import java.io.BufferedReader;
import java.io.InputStreamReader;
importjava.net.URLEncoder;
importjava.net.URL;
importjava.net.URLConnection;
//用于调用短信接口的类
public class SMSCode {
//传递手机号码以及随机生成的验证码
public static boolean sendCode(String phoneNumber,String AppName,String code) throws Exception {
String code_Str = URLEncoder.encode("#code#="+code+"&#app#="+AppName,"utf-8");
//封装短信接口地址
URL url =new URL("http://v.juhe.cn/sms/send.php?mobile="+phoneNumber+"&tpl_id=userid&key=相关key&tpl_value="+code_Str);
//打开链接,得到链接对象
URLConnection connection = url.openConnection();
//发起请求
connection.connect();

    //获得服务器响应的数据
    BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(connection.getInputStream(), "utf-8"));
    StringBuffer buffer = new StringBuffer();
    String line = null;
    while((line=bufferedReader.readLine())!=null) {
        buffer.append(line);
    }
    System.out.println(buffer.toString());
    bufferedReader.close();
    if(buffer.toString().indexOf(""error_code":0")>=0) {
        return true;
    }
    return false;
}

}
```

常见漏洞场景及审计方法

验证码可枚举

  部分业务短信验证码是由4位数字组成,那么如果没有对验证码的失效时间和尝试失败的次数做限制,可以尝试对短信验证码的区间内的所有数字来进行暴力破解攻击。

  例如下面的案例,生成的短信验证码是4位的,若没有相关的安全防护措施的话会存在可枚举的风险:

java
//生成一个4位0-9之间的随机字符串
StringBuffer buffer = new StringBuffer();
Random random = new Random();
for(int i=0;i<4;i++) {
buffer.append(random.nextInt(10));
}
try {
if(!SMSCode.sendCode(phoneNumber,AppName,buffer.toString())) {
//发送验证码失败
......
}else{
//将验证码、手机号码存储到session中绑定会话
request.getSession().setAttribute("code", buffer.toString());
request.getSession().setAttribute("mobile", phoneNumber);
......
}

  所以生成的验证码需要具有随机性,长度大于等于6,并且每条短信认证有效时间需要有一定的限制,例如不能超过10分钟。

验证码可复用

  因为实际业务中短信验证码一般跟session容器进行绑定,若没有将session中的短信验证码字段及时清空,那么在会话有效期内多次进行枚举,从而进一步进行重置密码、交易等敏感操作。

  例如下面的代码,是一个简单的注册业务,在校验发送验证码的手机号和注册时提交的手机号码是否一致后,判断后端生成的验证码与用户输入是否一致,一致的话完成对应的注册业务,否则拒绝完成注册请求。可以看到,在验证失败后直接返回register页面,并没有及时将之前生成的验证码与当前会话解绑让其失效,那么即使验证失败,也可以再次进行注册尝试。

java
String phoneNumber = request.getParameter("phoneNumber");
String code = request.getParameter("code");
HttpSession session =request.getSession();
String code_Session = (String)session.getAttribute("code");
//校验发送验证码的手机号和注册时提交的手机号码是否一致
......
if(code_Session.trim().equalsIgnoreCase(code)) {
//省略一堆写入数据库的操作
return "success";
}else {
modelMap.addAttribute("message", "验证码验证失败,请重新请求!");
return "register";
}

  正确的做法是验证码在一次认证成功后,服务端应该及时将session中存储的验证码进行清除。

验证码response直接返回

  若验证码凭证直接返回,可直接使用返回验证码对任意用户进行类似找回密码等敏感操作。主要是看查看response返回,例如下面的代码:

  业务测试的时候避免发送真实的短信,所以直接在response里写入生成的验证码方便测试,但是实际部署上线时,忘记去掉对应的测试代码,导致了安全问题:

java
@RequestMapping(value = "/sendMessage", produces = "application/json;charset=UTF-8")
public @ResponseBody String sendMessage(@RequestParam String phone) {
//校验手机号格式
......
//限制短信发送频率
.....
//生成一个6位0-9之间的随机字符串
StringBuffer buffer = new StringBuffer();
Random random = new Random();
for(int i=0;i<6;i++) {
buffer.append(random.nextInt(10));
}
try {
if(!SMSCode.sendCode(phone, buffer.toString())) {
String resultString = "{"result":"验证码发送失败"}";
}else {
//将验证码和当前的系统时间存储到session中
request.getSession().setAttribute("code", buffer.toString());
String resultString = "{"result":"验证码发送成功","code":"+buffer.toString()+"}";
}
} catch (Exception e) {
// TODO: handle exception
e.printStackTrace();
}
return resultString;
}

短信炸弹

  短信炸弹漏洞发送大量的短信,大量的短信会增加企业短信费用。同时还可能造成类似客户投诉等不良影响。缺少防重放、限制发送频率等安全措施肯定是有风险的。直接定位对应的发送短信业务检查就可以了,但是有时候由于设计缺陷,可能也会存在绕过从而导致短信炸弹风险。

  例如下面的例子:

  验证码的生成是基于会话的,开发人员在进行发送限制的时候很多时候也会结合会话进行绑定。

```java
//限制手机短信验证码发送的频率
if(request.getSession().getAttribute("time")!=null) {
//如果能获取到存活时间的话说明短信已经发送了,此时判断是否还在有效期内,是的话则拒绝发送短信
long time = (Long) request.getSession().getAttribute("time");
if((System.currentTimeMillis()-time)/1000/60<=10) {
out.print("短信已发送过,有效期为10分钟");
return;
}
}

    //生成一个6位0-9之间的随机字符串
    StringBuffer buffer = new StringBuffer();
    Random random = new Random();
    for(int i=0;i<6;i++) {
        buffer.append(random.nextInt(10));
    }
    try {
        if(!SMSCode.sendCode(phoneNumber, buffer.toString())) {
           String resultString = "{"result":"验证码发送失败"}";
        }else {
            //将验证码、手机号码和当前的系统时间存储到session中
            request.getSession().setAttribute("code", buffer.toString());
            request.getSession().setAttribute("number", phoneNumber);
            request.getSession().setAttribute("time", System.currentTimeMillis());
            String resultString = "{"result":"验证码发送成功"}";
        }
    } catch (Exception e) {
        // TODO: handle exception
        e.printStackTrace();
    }

```

  上述代码在短信发送后,再次请求会提示需要时间间隔10分钟:

captcha_audit1.png

  但是这个时间间隔是可以绕过的,发送成功时会将验证码的值保存到session中,同时会将当前时间也保存在session中(用于校验验证码的有效期),在调用 SMSCode.sendCode方法进行短信发送时,会尝试从session中获取time参数的值,如果成功获取并且与当前时间的差值小于10分钟,则认为短信已经发送,且验证码仍在有效期内,拒绝调用短信发送接口以防止短信炸弹:

java
//限制手机短信验证码发送的频率
if(request.getSession().getAttribute("time")!=null) {
//如果能获取到存活时间的话说明短信已经发送了,此时判断是否还在有效期内,是的话则拒绝发送短信
long time = (Long) request.getSession().getAttribute("time");
if((System.currentTimeMillis()-time)/1000/60<=10) {
out.print("短信已发送过,有效期为10分钟");
return;
}
}

  time保存的是第一次正常请求时,根据sessionID保存在了对应的session的time参数中了,也就是说跟会话绑定了,那么如果请求时删除了cookie后,服务端无法获得sessionID,认为是一个新的会话,也就是跟上一次会话无关,会返回一个新的会话,此时新会话session中的time一直为null,也就是不存在发送过的标记,那么就绕过了时间限制达到短信炸弹的效果了。效果如下:

captcha_Audit2.png

  进一步延伸,可能开发会认为既然通过删除cookie绕过,那么可以加入如下代码进行加固:

java
if(request.getCookies()==null) {
return "register";
}

  整个手机验证码的发送过程的时间限制主要是通过会话进行处理的,虽然没法删除cookie字段,同理,可以随意设置一个不存在的cookie值,这样服务器端接受到该cookie后无法找到对应的session,也会重新分配一个session,然后进行Set-cookie操作,同样也可以达到对应的效果:

captcha_Audit3.png

  再比如有些防护措施是通过请求的IP进行的,一个IP一天内只能发送10次请求。但是获取IP的方式是通过HTTP Header中的X-Forwarded-For字段获取的。那么也就是说通过伪造X-Forwarded-For的值,便可绕过对应的防护措施造成短信炸弹了。

  所以在设计防护方案时候一定要考虑上述的因素,当然结合图形验证码会是一种比较有效的防护措施,但是更多的时候会考虑到实际用户体验,当然了还有广度短信炸弹的问题。那么可以考虑结合一些非关系型的数据库(例如redis),合理配置短信业务,对于同一手机号码,发送次数不超过3-5次,并且可对发送的时间间隔做限制。当然了,类似微信小程序/公众号等业务也可以结合openid进行限制,从而防止短信炸弹的产生。

短信验证码内容可控

   一般在设计时,通常会通过Ajax前台提取手机号等信息,发送给对应后台的短信调用接口,对相关参数进行整合后调用短信平台的发送功能。但是整个过程若设计不严谨的话,可能存在短信内容可控问题。

  例如如下场景,很多时候我们在注册时,都会根据对应的产品,发送对应的短信内容。

captcha_Audit4.png

  例如下面的案例代码:

javascript
function sendMessage() {
var appName = "安全测试";
var phoneNumber = $("#phoneNumber").val();
//ajax去服务器端校验
var data= {phoneNumber:phoneNumber,AppName:appName};
//向后台发送处理数据
$.ajax({
type: "POST", //用POST方式传输
dataType: "json", //数据格式:JSON
data:data,
url: '/MessageContent/message/send', //目标地址
success: function (data) {
//console.log(data);
if(data.success==true){
curCount = count;
//设置button效果,开始计时
$("#btnSendCode").attr("disabled", "true");
$("#btnSendCode").val("请在" + curCount + "秒内输入验证码");
InterValObj = window.setInterval(SetRemainTime, 1000); //启动计时器,1秒执行一次
SetRemainTime();
}else {
alert(data.msg);
}
}
});
}

  后台接口主要接受了phoneNumber和AppName两个参数。然后调用短信接口进行了短信的发送。短信接口内容:

java
String code_Str = URLEncoder.encode("#code#="+code+"&#app#="+AppName,"utf-8");
//封装短信接口地址
URL url =new URL("http://v.juhe.cn/sms/send.php?mobile="+phoneNumber+"&tpl_id=userid&key=相关key&tpl_value="+code_Str);
//打开链接,得到链接对象
URLConnection connection = url.openConnection();
//发起请求
connection.connect();

  可以看到,AppName回座位短信内容code_Str的一部分进行传入,因为前端用户可控,所以导致了短信验证码内容可控问题,这里直接通过修改AppName控制了短信内容:

captcha_Audit5.png

  禁止客户端编辑短信内容,直接把对应的内容硬编码,虽然粗暴,但是有效。

未绑定相关业务凭证

  正常的使用短信验证码进行用户注册和忘记密码流程是:填写好资料后,使用自己的手机号接收短信验证码,然后进行验证完成账号的注册或者密码重置操作。很多时候可能仅仅考虑到存储短信验证码,忽略了凭证绑定的问题,没有将例如发信人手机号这个凭证与验证码进行绑定,导致了业务逻辑缺陷。

  例如下面的例子:

java
String code = request.getParameter("code");
HttpSession session =request.getSession();
String code_Session = (String)session.getAttribute("code");
//验证码正确性判断
if(code_Session.trim().equalsIgnoreCase(code)) {
//验证成功,省略一堆写入数据库的操作
return "success"
}else {
modelMap.addAttribute("message", "验证码验证失败,请重新请求!");
return "register";
}

  可以看到,在进行注册验证操作时候仅仅验证了短信验证码,未对当前短信验证码是否属于需要注册的用户进行校验,导致了万能验证码的缺陷。例如在注册时,首先使用手机号A进行短信接收,然后在提交短信验证码校验时,将手机号A修改成手机号B,进而越权完成了手机号B的注册业务。

  因为短信验证码是绑定会话的,那么可以在每次请求短信时候,将当前请求短信验证码的手机号写入session中,在进行业务时,从会话中取得发送短信验证码的手机号,将session中获取的手机号以及用户提交的用于注册的手机号进行比较,防止上述的万能验证码缺陷。

java
request.getSession().setAttribute("number", phoneNumber);

  当然这里涉及到分步逻辑以及会话session,所以在设计时也要考虑类似session覆盖等安全问题,具体场景具体分析。

其他

  还有很多类似修改response绕过短信验证机制,或者说其他特殊场景的安全防护绕过,主要还是具体场景具体分析。

相关推荐: 持久性--COM劫持

简述 原文:https://pentestlab.blog/2020/05/20/persistence-com-hijacking/ by Administrator.In Persistence.Leave a Comment 微软在Windows 3.1…