首发公众号:赵侠客
引言
背景是这样的,最近我们新开发了一个系统即将上线,在上线前我们的系统都需要找第三方的安全公司做一次渗透测试,主要是为了找到项目中潜在的安全问题,防止上线后被坏人找到利用漏洞做坏事。请大公司做一次渗透测试价格要好几万块钱,说白了就是给“白帽”黑客们钱,在系统上线前让他们干黑客的事情,“白帽”们会帮你找到系统的漏洞,在上线前修复漏洞。我收到的一个安全问题是发短信接口可以被轰炸,在收到渗透报告后测试妹子们也按照流程验证了一下然而并没能复现,然后测试妹子们就找到了我,让我帮看看是什么问题,能不能复现,我仔细研究了代码,并尝试各种操作方法最后终于复现并修复了问题,本文主要介绍如何复现并修复此类并发情况下的安全问题。
二、问题复现
2.1、业务场景
出现问题的业务场景是一个使用短信验证码登录的场景,使用短信验证码登录几乎是每个系统都有的功能,基本流程就是用户先输入手机号码,然后调用后端接口,后端接口收到手机号码后先验证用户是否合法,然后生成一个4位或者6位的验证码,再调用短信服务发送短信, 一般为了防止短信被不法分子轰炸,都会有一个一分钟一个手机号码能只能发送一次的限制,就是这样的场景被批量发短信轰炸了。
▲短信验证码登录场景
2.2、接口代码
我简化一下不必要的代码,核心的代码是这样的,接口接收一个手机号码参数,收到手机号码后先验证手机号码是不是合法的用户,然后调用stringRedisTemplate看一下近1分钟内这个手机号码有没有发送过短信,如果有则返回失败,提示用户1分钟内只能发送一条,如果Redis里没值则调用发短信接口,在发送短信成功后,将当前手机号码及验证码写入Redis,为接下来登录验证做校验,Redis超时时间为60S,这样就能保证一个手机号码一分钟内只能发送一条短信了,当然这里为了用户体验就没有增加图形验证码了。接口代码如下:
@Resource
private StringRedisTemplate stringRedisTemplate;
@PostMapping("/send/{phone}")
public ResponseEntity<Map<String, String>> 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这里都要串行一个一个执行,我们从请求时间轴可以看出
- 请求1, 请求1查Redis中没有发送过短信所以成功发出,并设置Redis值
- 请求2, 请求2是在请求1结束后才响应的,所以在查Redis时已经有值了,发送失败
- 其它请求,后面的请求都和请求2一样,都是发送失败的,所以没有超发
3.2、多线程情况
我们看Tomcat最大线程数设置为3的情况,
▲ Tomcat最大线程设置为3的情况
- 00:0000, Jemter同时发出了6个发短信请求,因为Tocmat只有3个线程能响应请求,所以有3个请求分别进入了线程1、线程2和线程3,其它3个请求在等待。
- 00:0001, 这里三个请求都进入了接口,三个线程同时去查Redis,因为之前没有发送过短信,所以这三个线程查到的都为空
- 00:0002, 请求1、2、3 都完成了短信发送
- 00:0003, 请求1、2、3发送短信成功后,同时设置到了Redis中,完成接口响应
- 00:0004, 前面三个线程完成短信发后就可以响应接下来的请求4、5、6了,这里再去查Redis是有值的
- 00:0006, 请求4、5、6 发送失败
从上面的分析来看,Tomcat最大线程设置为3,一次可以发3个短信出去,如果设置更大理论上应该还会同时发出更多的短信出去。
四、解决方案
4.1、分布式锁
解决这种并发问题通用的方案是增加分布式锁,常用分布式锁实现方案主要有以下三种:
第一种:基于数据库利用数据库表的行锁或乐观锁机
- 创建一张专门用于锁的表,表中有一个唯一索引字段。
- 当获取锁时,向表中插入一条记录,如果插入成功则获得锁,插入失败说明锁已被占用。
- 释放锁时,删除对应的记录
优点:
- 简单易实现。
- 适用于小规模分布式场景。
缺点:
- 性能较低,特别是在高并发情况下,存在数据库瓶颈。
- 无法处理锁的自动失效问题。
第二种:基于 Redis 的分布式锁
- 使用 SET key value NX PX ,其中 NX 表示仅在键不存在时设置,PX 指定过期时间,防止锁因未释放而死锁。
- 释放锁时,确保只有持有锁的线程才能删除对应的 key。
优点:
- 性能高,Redis 是内存级存储,适合高并发场景。
- 支持锁的自动过期,防止死锁。
- Redisson 和 RedisTemplate都是基于Redis封装的分布式锁
缺点:
- 需要额外配置 Redis 集群,且需要注意 Redis 的单点故障问题。
第三种:基于 Zookeeper 的分布式锁
- 通过创建临时顺序节点来竞争锁,节点顺序最小的客户端获得锁。
- 当锁被释放时(节点被删除),其他客户端会监听到变化,并重新竞争锁。
优点:
- 可靠性高,Zookeeper 保证一致性,适合复杂的分布式场景。
- 支持锁的自动失效(临时节点)。
缺点:
- 实现复杂,性能较 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<Map<String, String>> 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都会失败,后面其它请求也自然会失败,这样短信也就不会超发了。
总结
可以说大部分系统接口都是有并发安全问题,特别是修改接口,如果你的修改接口没有添加分布式锁那一定是有并发问题的,即使增加了分布式锁如果没有对修改内容进行版本校验也会有内容版本冲突问题的,不过我们要具体问题具体分析,并不是所有应用场景都需要添加分式锁或者内容版本校验,但是有的场景是必须要添加的,如本文中的发短信接口、多人同时修改文章内容,如果不加可能会造成比较大的安全隐患或者内容版本丢失,我觉得需要添加分布式锁的应用场景有:
- 账户资金操作,确保转账、充值、提现等操作中账户余额的准确性,防止并发操作导致的资金问题。
- 订单状态变更,保证订单支付、取消、发货等状态更新的顺序和一致性,避免错乱或重复操作。
- 秒杀抢购活动,控制用户秒杀资格验证、抢购确认等环节,防止并发问题导致的超额抢购。
- 消息队列消费处理,确保消息的幂等性处理,避免高并发下的重复消费和乱序问题。
- 分布式任务调度,避免分布式系统中定时任务、数据同步等操作的重复执行,保证任务唯一性。
- 缓存重建与热点数据更新,防止缓存击穿,在缓存失效后确保只有一个请求执行缓存重建。
- 支付流程幂等性处理,确保支付回调和扣款操作的幂等性,防止重复支付或扣款。
- 数据同步与一致性控制,跨系统、跨数据库的数据同步中确保一致性,避免数据冲突。
- 活动奖品发放与抽奖,控制红包、奖品的发放顺序和数量,避免超发或重复发放。
- 共享资源限流与排他访问,控制对文件、设备、外部API等资源的并发访问,防止过载或冲突。