Race-Condition漏洞的防御(观V2EX热搜有感)
slug
series-status
status
summary
date
series
type
password
icon
tags
category
简介
今天在v2ex上,有这么一篇文章挂了一整天:

随后p佬在知识星球针对这个事件总结了一下其中的教训,本文主要是总结一下P佬的知识点以作后续复习使用~
其实并发漏洞这个漏洞由来已久,在去年四五月份的时候我刚刚接触逻辑漏洞挖掘的时候王老师就已经给我详细讲解过并发漏洞的测试思路和常见漏洞点以及它的危害了。不过在当时对于这个漏洞的修复思路王老师也只是一笔带过介绍为“让开发给数据库上锁即可”,这一点说的确实没有任何问题,只是对于当时的我来讲对数据库上锁这个事情并没有一个很清晰的认知,这里正好接着这个事情简单整理一下这个漏洞的修复方案。
今天这里先简单总结一下p佬的思路,代码部分暂时不放在这里,毕竟是老师私发在星球里的,所以今天的文章可能比较简洁,后续等有时间亲自复现了代码我会将我自己的代码和运行截图放上来。
正文
项目环境搭建
这里可以使用Django-Cookiecutter(https://github.com/phith0n/django-cookiecutter)脚手架启动一个项目,并设定一个余额、体现的页面功能。
这里有四种常见的开发方式去面对这个竞争攻击:
- 无锁无事务
- 无锁有事务
- 悲观锁加事务
- 乐观锁加事务
接下来分别介绍这四种方案。
无锁无事务
在最开始的环境搭建中,假设我们没有做任何的针对高并发情况的处理,那么我们的代码中理论上是会存在Race Condition漏洞的,也就是在面对用户多线程同时请求的时候会存在并发漏洞。
这个逻辑比较简单,在用户提现的时候是以下的逻辑:

在经历过用户余额检查后,服务端进行提现操作,这个操作本身是没有问题的,但是如果某个用户利用多线程进行请求的时候,就会产生这种情况:第一个请求刚到达判断语句的时候是还没有提现过的,而在它这个请求执行体现代码逻辑之前;此时第二的请求也到了这个步骤,它也通过了检查,也能继续执行提现代码逻辑,这就会导致提现操作会被执行两次。
那如果用户的余额此时仅支持体现一次,那就实现了竞争攻击,造成了公司资产的损失,也就出现了原文中的事故。
用来测试的方法也很简单,一个是原文中老师提到的Yakit工具,这个工具是开发过程中常用的压测工具;另一个是我们前边的文章中也提到过的Burpsuite的插件“Turbo Intruder”也可以实现这一操作,Burp插件的文章见:点击查看。
这里我们无论是利用哪个工具,对我们之前写的提现功能进行一次并发测试即会发现我们的日志中会执行多次提现操作。
这也就是在“无锁无事务”的时候造成的问题。
无锁有事务
在Django里,对于事务的操作名为“transaction.atomic”,也就是说带有原子性。因此很多同学会以为加入了事务就能解决并发的问题,但是实际上是不行的,这一操作只能保证在当前上下文中的数据库操作在出错的时候是能够回滚的,并不能解决并发问题。
悲观锁加事务
在Django的ORM里提供了对数据库Select for Update的支持,在Postgre SQL、MySQL、Oracle三种数据库中都可以使用,结合Where语句可以实现行级别的锁。
使用
SELECT FOR UPDATE
获取到的数据库记录,不会再被其他事务获取,例如我们针对 id = 1
的用户的数据进行查询,在我们提交事务之前,其他事务如果执行同一个SQL语句就会陷入阻塞状态:这样就可以保证在同一个事务内部的原子性,这也是一个典型的悲观锁。
“悲观锁”的意思是,我们先假设其他线程都会修改数据,那我们在操作数据库前就会对数据库加锁,直到当前线程释放掉这个锁以后,其他线程才能再次获得这个锁。
但是这里其实有个问题,如果是面临大量的读操作的场景下,使用悲观锁就会造成比较严重的性能问题。因为每次访问这个数据的时候都会锁住这个对象,导致其他需要使用到这个数据的场景都会卡住。
另外也不是每个数据库都支持
select for update
,所以我们也可以尝试使用乐观锁区解决这个问题。乐观锁的意思就是暂时不假设其他进程会修改数据,所以不上锁,而是等到要更新数据的时候,在使用数据库本身的
UPDATE
操作来更新数据库,由于 UPDATE
语句本身是一个原子操作,所以可以用来防御并发问题。我们只要在执行 UPDATE
语句的时候为其增加过滤器即可。这里乐观锁的优点就是不会锁住数据库记录,不会影响数据库其他的相关操作。
分布式锁
这里老师的文章中没有提到原文中提到的一个概念:
分布式锁
。在我们真实的生产环境中,为了缓解后端服务器的处理压力,我们一般会做Tomcat集群然后通过Nginx负载均衡的方式去缓解高并发压力,但是在这个情况下如果我们在代码中使用的是进程级别的例如Java中的
synchronized
去保证进程级的操作原子性是没有意义的,因为我们会存在多个机器都在执行相同的代码,这台机器上的 synchronized
是解决不了另外一台机器导致的竞争问题的,因此我们需要引入分布式锁的概念。而最常见的实现分布式锁的方法就是基于Redis去实现这个设计。
而在Redis中我们就可以借助数据库中的
setnx
操作去实现一个分布式锁,例如Java中的实现代码如下:这里我们实现了一个持续时间为10秒的分布式锁,但是会存在一个问题,就是如果这个时间中我们后续的代码没有执行完怎么办,就会导致其他进程抢到锁从而导致竞争问题。而这里我们也不能简单的通过给这个锁设定一个较长的过期时间来解决这个问题,比较常见的一个解决方案就是进行
锁续命
,简单来说就是轮询在自己的代码里检查自己的代码是否锁还在,如果还在就进行延期(这里的轮询时间自然是要比原始设定的时间段),这样即使存在机器宕机的情况,锁还是会自动消除,而如果没有宕机则是会持续占用资源。这里我们的代码中还有一个问题其实还需要注意一下,就是说有一个锁名相同的情况,但是这里我们通过在请求中携带随机字符串或引入客户端id即可解决这个问题。
另外,前文我们提到的这个锁续命的逻辑其实知识一个最简单的逻辑,现在市面上各大公司对这个机制的研究已经非常透彻了,所以也有一些比较好用的开源的框架,例如
redission
,我们可以利用这些框架很好地解决这个问题。总结
本文只是针对这个竞争漏洞的修复方案进行一个简单的讲解,并对悲观锁、乐观锁、分布式锁的概念进行一个简单的讲解,顺便笔者最近也在学习Redis相关的知识,也就此机会总结一下~
Loading...