Django下防御Race Condition漏洞
创始人
2025-05-31 01:28:53
0

今天下午在v2ex上看到一个帖子,讲述自己因为忘记加分布式锁导致了公司的损失:

b89a076b97115eb62608d848ec673730.png

我曾在《从Pwnhub诞生聊Django安全编码》一文中描述过关于商城逻辑所涉及的安全问题,其中就包含并发漏洞(Race Condition)的防御,但当时说的比较简洁,也没有演示实际的攻击过程与危害。今天就以v2ex上这个帖子的场景来讲讲,常见的存在漏洞的Django代码,与我们如何正确防御竞争漏洞的方法。

0x01 Playground搭建

首先,使用我这个Django-Cookiecutter脚手架创建一个项目,项目名是Race Condition Playground。

创建两个新的Model:

class User(AbstractUser):username = models.CharField('username', max_length=256)email = models.EmailField('email', blank=True, unique=True)money = models.IntegerField('money', default=0)USERNAME_FIELD = 'email'REQUIRED_FIELDS = ['username']class Meta(AbstractUser.Meta):swappable = 'AUTH_USER_MODEL'verbose_name = 'user'verbose_name_plural = verbose_namedef __str__(self):return self.usernameclass WithdrawLog(models.Model):user = models.ForeignKey('User', verbose_name='user', on_delete=models.SET_NULL, null=True)amount = models.IntegerField('amount')created_time = models.DateTimeField('created time', auto_now_add=True)last_modify_time = models.DateTimeField('last modify time', auto_now=True)class Meta:verbose_name = 'withdraw log'verbose_name_plural = 'withdraw logs'def __str__(self):return str(self.created_time)

一个是User表,用以储存用户,其中money字段是这个用户的余额;一个是WithdrawLog表,用以储存提取的日志。我们假设公司财务会根据这个日志表来向用户打款,那么只要成功在这个表中插入记录,则说明攻击成功。

然后,我们编写一个WithdrawForm,其字段amount,表示用户此时想提取的余额,必须是整数:

class WithdrawForm(forms.Form):amount = forms.IntegerField(min_value=1)def __init__(self, *args, **kwargs):self.user = kwargs.pop('user', None)super().__init__(*args, **kwargs)def clean_amount(self):amount = self.cleaned_data['amount']if amount > self.user.money:raise forms.ValidationError('insufficient user balance')return amount

我将检查用户余额的逻辑放在WithdrawForm.clean_amount中,如果发现用户要提取的金额大于用户的余额,则抛出一个forms.ValidationError异常。

最后我们编写一个用于提现的View:

class BaseWithdrawView(LoginRequiredMixin, generic.FormView):template_name = 'form.html'form_class = forms.WithdrawFormdef get_form_kwargs(self):kwargs = super().get_form_kwargs()kwargs['user'] = self.request.userreturn kwargsclass WithdrawView1(BaseWithdrawView):success_url = reverse_lazy('ucenter:withdraw1')def form_valid(self, form):amount = form.cleaned_data['amount']self.request.user.money -= amountself.request.user.save()models.WithdrawLog.objects.create(user=self.request.user, amount=amount)return redirect(self.get_success_url())

这个WithdrawView1非常简单,因为使用了django的generic.FormView,所以Django在接收到POST请求后会正常使用form的方法进行检查(包含上面提到的余额充足的检查),检查通过后执行form_valid()函数完成提现操作:

  • request.user.money进行自减

  • WithdrawLog中添加一条新记录

最后再添加一些必要的前端、路由、Admin等即可完工。

跑起来Web应用,访问后台,给自己的账户设置余额为10:

4bdba2c8ef3cb4593593569e287a5e88.png

然后来到前台,输入Amount即可进行提现。我们尝试输入100提交,此时会因为余额不足而报错:

49d715fc41b35965051a345b937b179b.png

运行正常,我们可以开始进行实验。

0x02 无锁无事务时的竞争攻击

观察我前面写的WithdrawView1,可以发现整个操作即没有使用事务,也没有加锁,理论上是存在Race Condition漏洞的。

Race Condition的原理很简单,下图是用户提现时的流程:

c3f19acd1a17bc5f26e9760ec683fde3.png

在经过amount <= user.money检查后,服务端执行提现操作,本无可厚非。但如果某个用户同时发起两次提现请求,在第一个请求经过检查到达Withdraw handler之前,此时该用户的user.money是还没有减少的;此时第二个请求如果也经过了检查,两个请求同时到达Withdraw handler,就会导致user.money -= amount执行两次。

那么如果用户的余额只够提取一次,这里执行了两次,就实现了竞争攻击,造成了资产损失的结果。

测试方法也很简单,我们下载Yakit,新建一个Web Fuzzer,贴入提现的数据包。这里,我账户余额是10,我设置的提现金额amount也为10。正常情况下,我只能提现一次,第二次就会因为余额不足而失败。

然后我在数据包中添加{{repeat(100)}},并把并发线程调高到100发送,此时Yakit就会使用100个线程重复发送100次这个数据包:

da4aad5f9aa9a48457fad7c1ea0ceacc.png

见上图,发送结果里,前4个请求返回了302跳转,说明提现成功。我们来到后台Withdraw Log页面,有4个提现日志:

c2c042b6fb8a48c1916692d30d4f2708.png

这意味着,我余额虽然只有10,但我成功提现了4次,也就是40元,造成的资产损失为30元。

0x03 无锁有事务时的竞争攻击

很多Django初学者会认为,这种情况只要我们加上事务就可以解决了。

原因也比较有趣,Django里增加事务的操作名字叫transaction.atomic,atomaic嘛就是“原子”的意思,原子操作不是可以解决并发问题吗?

我们也可以来做试验,新编写一个WithdrawView2,加上@transaction.atomic修饰符:

class WithdrawView2(BaseWithdrawView):success_url = reverse_lazy('ucenter:withdraw2')@transaction.atomicdef form_valid(self, form):amount = form.cleaned_data['amount']self.request.user.money -= amountself.request.user.save()models.WithdrawLog.objects.create(user=self.request.user, amount=amount)return redirect(self.get_success_url())

同样使用Yakit做测试,结果和刚才并无区别:

8e05913f9ec92368ed4544545203c66e.png

后台也是4条提现记录:

9e444f3870541b6505548f1201bc6bfa.png

这也可以说明,transaction.atomic并无处理并发的能力,只是保证当前上下文中的数据库操作在出错的时候能够回滚。

0x04 悲观锁加事务防御Race Condition

Django在ORM里提供了对数据库Select for Update的支持,在PostgreSQL、Mysql、Oracle三个数据库中都可以使用,结合Where语句,可以实现行级的锁。

使用SELECT FOR UPDATE获取到的数据库记录,不会再被其他事务获取。比如,我查询id = 1的用户,在提交事务前,其他事务执行同一个SQL语句就会block:

START transation;
SELECT * FROM user WHERE id = 1 FOR UPDATE;
COMMIT;

这样就可以保证我们在同一个事务内执行的操作的原子性,这是一个典型的悲观锁。“悲观锁”的意思是,我们先假设其他线程会修改数据,所以在操作数据库前就加锁,直到当前线程释放锁后,其他线程才能再次获取这个锁。

我们使用select_for_update()来实现一个WithdrawView3

class WithdrawView3(BaseWithdrawView):success_url = reverse_lazy('ucenter:withdraw3')def get_form_kwargs(self):kwargs = super().get_form_kwargs()kwargs['user'] = self.userreturn kwargs@transaction.atomicdef dispatch(self, request, *args, **kwargs):self.user = get_object_or_404(models.User.objects.select_for_update().all(), pk=self.request.user.pk)return super().dispatch(request, *args, **kwargs)def form_valid(self, form):amount = form.cleaned_data['amount']self.user.money -= amountself.user.save()models.WithdrawLog.objects.create(user=self.user, amount=amount)return redirect(self.get_success_url())

对于当前这个场景,我们可以再次尝试使用Yakit进行竞争攻击:

fe0a71fad93791b939348acebe736f0e.png

可见,此时返回包只有一个302响应了,再查看后台也只成功添加一条提现记录:

e425d4e9d226a23f0414b99d6935da79.png

这意味着程序是按照预期运行,没有发生Race Condition问题。

0x05 乐观锁加事务防御Race Condition

我们观察上述的WithdrawView3代码,其实会发现一个问题,如果有大量读操作的场景下,使用悲观锁会有性能问题。因为每次访问这个view都会锁住当前用户对象,此时其他要使用这个用户的场景(如查看用户主页)也会卡住。

另外,也不是所有数据库都支持select for update,我们也可以尝试使用乐观锁来解决Race Condition的问题。

乐观锁的意思就是,我们不假设其他进程会修改数据,所以不加锁,而是到需要更新数据的时候,再使用数据库自身的UPDATE操作来更新数据库。因为UPDATE语句本身是原子操作,所以也可以用来防御并发问题。

我们新增一个WithdrawView4

class WithdrawView4(BaseWithdrawView):success_url = reverse_lazy('ucenter:withdraw4')@transaction.atomicdef form_valid(self, form):amount = form.cleaned_data['amount']rows = models.User.objects.filter(pk=self.request.user, money__gte=amount).update(money=F('money')-amount)if rows > 0:models.WithdrawLog.objects.create(user=self.request.user, amount=amount)return redirect(self.get_success_url())

代码基本是从WithdrawView2来的,只是将其中的self.request.user.money -= amount改成了用update,并且在修改数据行数大于0的情况下再添加提现日志。

此时,假设有多个提现请求同时到达update语句,因为update本身的原子性,执行第一次update后,用户的余额已经减少amount,再执行第二次update时,money__gte=amount这个条件就不会成功,就不会再次减少amount了。

使用Yakit进行测试,只有一次302返回:

664cacbb5e2493191b86e696d3d07b40.png

查看后台,也只成功添加一个提现日志,符合预期:

44bf90c88db9498f231ca7d82dab9f7c.png

乐观锁的优点就是不会锁住数据库记录,也就不会影响其他线程查询该用户。

0x06 总结

本文主要从v2ex一个帖子的例子入手,阐述了如何使用Yakit进行Race Condition攻击,以及在Django中如何使用悲观锁和乐观锁对该攻击进行防御。

本文涉及的代码,可以在https://github.com/phith0n/race-condition-playground找到。

相关内容

热门资讯

走进小城看消费丨江西资溪:低碳...   夏日时节下午4点,江西省抚州市资溪县大觉山景区漂流终点依然热闹。来自南昌的游客余鑫漂流结束后没有...
【中原晨会0625】市场分析专... 来源:市场资讯 (来源:中原证券研究所) 本期重点研报目录 【中原策略】市场分析:电子半导体领涨 ...
南向资金连买4日!低费率+可月... 6月25日早盘,港股红利资产震荡整理。截至11时14分,港股红利低波ETF招商(520550)下跌0...
618成交破百万!紫荆花用一套... 一年一度的618年中大促,是消费市场的晴雨表,也是品牌间最激烈的角力场。当各大品牌在直播间里铆足了劲...
原创 黄... 2026年6月25日的国际金价已经从前期的5500美元高点跌到4200美元下方,累计跌幅超过22%,...
英伟达CEO:Vera Rub... 截至9:38,中证半导体材料设备主题指数(931743)涨2.36%创新高;权重股中,中微公司涨3....
再被催债16亿!“钢铁大王”戴... 澎湃新闻记者 贺梨萍 因“铁本事件”入狱五年的戴国芳重返钢铁行业,但他并没有完成从阶下囚再到“钢铁大...
周三原油价格下跌 随着美国和伊朗在和平谈判中取得进展,越来越多的油轮公开穿越霍尔木兹海峡,原油在战时的价格上涨已经蒸发...
这种蛋白是大脑衰老的开关 这种蛋白是大脑衰老的开关 清晨,假设一位五十岁左右的王女士发现自己常常把手机放在熟悉的抽屉里又找不到...
信通院牵头算力Token出海生... 盘面上,截至11:04,中证科创创业50指数(931643)涨1.68%,创历史新高;权重股中,芯原...
海外 774 亿营收背后:日本... 文 | 游戏价值论 6月23日,彭博社报道了腾讯正在围绕出售多家日本游戏工作室少数股权开展谈判,包...
餐饮“抢人”大战:把店开到公交... 作者 |餐饮老板内参 内参君 医院、公交站、演唱会…餐饮品牌,正在无孔不入 在北京儿童医院,肯德基...
快讯 | 外资扫货!陈翊庭:港... 港交所行政总裁陈翊庭在接受《中国证券报》专访时指出,国际资本对中国资产的看法已彻底扭转,布局中国市场...
2777.77元!A股“股王”... 25日早盘,昨天创下历史新高的A股“股王”联讯仪器,今天上午继续走强,盘中股价再度刷新历史新高。 截...
原创 今... 欧洲自己的媒体直接下结论,欧盟衰退躲不掉,内部分裂拦不住,现在就连欧洲顶尖工业巨头,都偷偷在用中国的...
黄仁勋股东大会放言:本轮AI基... 在当地时间6月24日的英伟达(NVDA.O)2026年度股东大会上,股东批准了该公司全部10名董事会...
国际油价大跌 新华社消息, 纽约原油期货主力合约价格24日盘中跌破每桶70美元,为伊朗战事爆发以来首次。 市场分析...
马云带队插秧,什么信号? 一场别开生面的“务农”,让外界看到了一个不一样的阿里巴巴。 近日,阿里巴巴合伙人、高德董事长刘振飞在...
全球最大产能,最高丰度达99.... 本文转自【科技日报】; 6月23日,高丰度硼-10同位素技术暨产业化成果发布会在山东省东营市举办,全...
黄金大跳水!金饰克价年内暴跌近... 25日,现货黄金盘中震荡,截至发稿,报3985.070美元/盎司,跌0.17%。 当地时间24日,...