• 作者:老汪软件技巧
  • 发表时间:2024-08-26 11:02
  • 浏览量:

引言

背景是这样的,最近我们新开发了一个系统即将上线,在上线前我们的系统都需要找第三方的安全公司做一次渗透测试,主要是为了找到项目中潜在的安全问题,防止上线后被坏人找到利用漏洞做坏事。请大公司做一次渗透测试价格要好几万块钱,说白了就是给“白帽”黑客们钱,在系统上线前让他们干黑客的事情,“白帽”们会帮你找到系统的漏洞,在上线前修复漏洞。我收到的一个安全问题是发短信接口可以被轰炸,在收到渗透报告后测试妹子们也按照流程验证了一下然而并没能复现,然后测试妹子们就找到了我,让我帮看看是什么问题,能不能复现,我仔细研究了代码,并尝试各种操作方法最后终于复现并修复了问题,本文主要介绍如何复现并修复此类并发情况下的安全问题。

二、问题复现2.1、业务场景

出现问题的业务场景是一个使用短信验证码登录的场景,使用短信验证码登录几乎是每个系统都有的功能,基本流程就是用户先输入手机号码,然后调用后端接口,后端接口收到手机号码后先验证用户是否合法,然后生成一个4位或者6位的验证码,再调用短信服务发送短信, 一般为了防止短信被不法分子轰炸,都会有一个一分钟一个手机号码能只能发送一次的限制,就是这样的场景被批量发短信轰炸了。

▲短信验证码登录场景

2.2、接口代码

我简化一下不必要的代码,核心的代码是这样的,接口接收一个手机号码参数,收到手机号码后先验证手机号码是不是合法的用户,然后调用stringRedisTemplate看一下近1分钟内这个手机号码有没有发送过短信,如果有则返回失败,提示用户1分钟内只能发送一条,如果Redis里没值则调用发短信接口,在发送短信成功后,将当前手机号码及验证码写入Redis,为接下来登录验证做校验,Redis超时时间为60S,这样就能保证一个手机号码一分钟内只能发送一条短信了,当然这里为了用户体验就没有增加图形验证码了。接口代码如下:

@Resource
private StringRedisTemplate stringRedisTemplate;
@PostMapping("/send/{phone}")
public ResponseEntity> send(@PathVariable String phone) throws Exception {
    //TODO 验证手机号码合法性
    String redisCode = stringRedisTemplate.opsForValue().get(phone);
    if (redisCode != null) {
        log.error("{ } 1分钟内只能发送一条",phone);
        return ResponseEntity.ok(Map.of("code", "500","msg","1分钟内只能发送一条"));
    }
    sendMsg(phone, RandomUtil.randomString("1234567890", 6));
    return ResponseEntity.ok(Map.of("code", "0"));
}
private void sendMsg(String phone, String code) {
    log.info("send msg {} to {}", code, phone);
    //TODO 调用短信服务发送短信
    stringRedisTemplate.opsForValue().set(phone,code,60L,TimeUnit.SECONDS);
}

第一眼看,这个代码好像并没有什么问题,测试妹子们通过人工页面点击发送验证码也没法多次发送,一分钟内也只能发送一次。不过这可是花了两万大洋请来“白帽”测试出来,钱肯定不是白花的,所以代码肯定是有问题,应该不能走常规流程,于是我使用压测工具Jmeter开了6个线程并发请求,然后Tomcat最大线程设置成3,结束有3条短信发出去了,3条短信没有发出去

server:
  tomcat:
    threads:
      max: 3

▲ Jemter开6个线程压测

如果不想用Jemter直接使用Java代码并发请求也可以复现:

 
@Test
void testSendMsg() {
    IntStream.range(1, 100).parallel().forEach(x -> {
        String api="http://localhost/send/18072344122";
        String res=HttpUtil.post(api,"");
        log.info("res: {}",res);
    });
}

我把Tomcat线程数量设置为1个,再次使用Jemter并发压测是没有问题的。

▲ Tomcat线程数量设置为1压测

三、问题分析3.1、单线程情况

这是一个典型的多线程并发请求情况下未加分布式锁导致的问题,我们先看如果Tomcat最大线程设置为1的情况

▲ Tomcat最大线程设置为1请求情况

因为Tomcat最大线程数量为1,所以响应请求只有一个线程,不管Jemter设置了多少个并发请求,到了Tomcat这里都要串行一个一个执行,我们从请求时间轴可以看出

3.2、多线程情况

我们看Tomcat最大线程数设置为3的情况,

▲ Tomcat最大线程设置为3的情况

从上面的分析来看,Tomcat最大线程设置为3,一次可以发3个短信出去,如果设置更大理论上应该还会同时发出更多的短信出去。

四、解决方案4.1、分布式锁

_花2万块买来的BUG!你的系统是不是也埋着相同的BUG?_花2万块买来的BUG!你的系统是不是也埋着相同的BUG?

解决这种并发问题通用的方案是增加分布式锁,常用分布式锁实现方案主要有以下三种:

第一种:基于数据库利用数据库表的行锁或乐观锁机

优点:

缺点:

第二种:基于 Redis 的分布式锁

优点:

缺点:

第三种:基于 Zookeeper 的分布式锁

优点:

缺点:

4.2、基于RedisTemplate实现

因为项目中有现成的stringRedisTemplate,这里可以使用最简单的基于Redis的SETNX来实现一个简单的分布式锁

Redis Setnx(SET if Not eXists) 命令在指定的 key 不存在时,为 key 设置指定的值。

 
(integer) 0
redis> SETNX job "programmer"    # job 设置成功
(integer) 1
redis> SETNX job "code-farmer"   # 尝试覆盖 job ,失败
(integer) 0
redis> GET job                   # 没有被覆盖
"programmer"

stringRedisTemplate有一个方法setIfAbsent,可以很好完成此功能,以下是简单的多线程并发测试

 
@Test
void contextLoads() {
    IntStream.range(1, 100).boxed().parallel().forEach(x -> {
        log.info("res: {}", redisTemplate.opsForValue().setIfAbsent("公众号", "赵侠客", 10, TimeUnit.SECONDS));
    });
}

▲ redisTemplate中的setIfAbsent完成简单分布式锁

4.3、接口修改

这样我们只需简单的修改发短信的接口就能达到多线程并发安全,在发送短信前先setIfAbsent()如果成功说明拿到锁了发送短信,如果失败了说明没有拿到锁,发送失败,代码如下:

 
@PostMapping("/send/{phone}")
public ResponseEntity> send(@PathVariable String phone) throws Exception {
    String code = RandomUtil.randomString("1234567890", 6);
    if (Boolean.FALSE.equals(stringRedisTemplate.opsForValue().setIfAbsent(phone, code, 60, TimeUnit.SECONDS))) {
        throw new Exception("1分钟内只能发送一条");
    }
    sendMsg(phone, code);
    return ResponseEntity.ok(Map.of("code", "0"));
}

修复完代码后,使用Jmeter压测后只有第一条成功发出,后面都是发送失败

▲ 使用Jemter6线程Tomcat最线线程为3压测没有问题

▲ 使用SETNX请求流程图

使用了SETNX我可以从流程图中看到因为Redis是单线程的,所以在请求1、2、3到达Redis调用SETNX时必须要串行排队,当请求1,SETNX成功后,请求2、3都会失败,后面其它请求也自然会失败,这样短信也就不会超发了。

总结

可以说大部分系统接口都是有并发安全问题,特别是修改接口,如果你的修改接口没有添加分布式锁那一定是有并发问题的,即使增加了分布式锁如果没有对修改内容进行版本校验也会有内容版本冲突问题的,不过我们要具体问题具体分析,并不是所有应用场景都需要添加分式锁或者内容版本校验,但是有的场景是必须要添加的,如本文中的发短信接口、多人同时修改文章内容,如果不加可能会造成比较大的安全隐患或者内容版本丢失,我觉得需要添加分布式锁的应用场景有: