原文鏈接:https://mp.weixin.qq.com/s/9f5Hxoyw5ne8IcYx4uwwvQ
作者:Phith0n
在v2ex上看到一個帖子,講述自己因為忘記加分布式鎖導致了公司的損失:

我曾在《從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_name
def __str__(self):
return self.username
class 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):
一個是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.WithdrawForm
def get_form_kwargs(self):
kwargs = super().get_form_kwargs()
kwargs['user'] = self.request.user
return kwargs
class WithdrawView1(BaseWithdrawView):
success_url = reverse_lazy('ucenter:withdraw1')
def form_valid(self, form):
amount = form.cleaned_data['amount']
self.request.user.money -= amount
self.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:

然后來到前臺,輸入Amount即可進行提現。我們嘗試輸入100提交,此時會因為余額不足而報錯:

運行正常,我們可以開始進行實驗。
0x02 無鎖無事務時的競爭攻擊
觀察我前面寫的WithdrawView1,可以發現整個操作即沒有使用事務,也沒有加鎖,理論上是存在Race Condition漏洞的。
Race Condition的原理很簡單,下圖是用戶提現時的流程:

在經過amount <= user.money檢查后,服務端執行提現操作,本無可厚非。但如果某個用戶同時發起兩次提現請求,在第一個請求經過檢查到達Withdraw handler之前,此時該用戶的user.money是還沒有減少的;此時第二個請求如果也經過了檢查,兩個請求同時到達Withdraw handler,就會導致user.money -= amount執行兩次。
那么如果用戶的余額只夠提取一次,這里執行了兩次,就實現了競爭攻擊,造成了資產損失的結果。
測試方法也很簡單,我們下載Yakit,新建一個Web Fuzzer,貼入提現的數據包。這里,我賬戶余額是10,我設置的提現金額amount也為10。正常情況下,我只能提現一次,第二次就會因為余額不足而失敗。
然后我在數據包中添加{{repeat(100)}},并把并發線程調高到100發送,此時Yakit就會使用100個線程重復發送100次這個數據包:

見上圖,發送結果里,前4個請求返回了302跳轉,說明提現成功。我們來到后臺Withdraw Log頁面,有4個提現日志:

這意味著,我余額雖然只有10,但我成功提現了4次,也就是40元,造成的資產損失為30元。
0x03 無鎖有事務時的競爭攻擊
很多Django初學者會認為,這種情況只要我們加上事務就可以解決了。
原因也比較有趣,Django里增加事務的操作名字叫transaction.atomic,atomaic嘛就是“原子”的意思,原子操作不是可以解決并發問題嗎?
我們也可以來做試驗,新編寫一個WithdrawView2,加上@transaction.atomic修飾符:
class WithdrawView2(BaseWithdrawView):
success_url = reverse_lazy('ucenter:withdraw2')
@transaction.atomic
def form_valid(self, form):
amount = form.cleaned_data['amount']
self.request.user.money -= amount
self.request.user.save()
models.WithdrawLog.objects.create(user=self.request.user, amount=amount)
return redirect(self.get_success_url())
同樣使用Yakit做測試,結果和剛才并無區別:

后臺也是4條提現記錄:

這也可以說明,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.user
return kwargs
@transaction.atomic
def 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 -= amount
self.user.save()
models.WithdrawLog.objects.create(user=self.user, amount=amount)
return redirect(self.get_success_url())
對于當前這個場景,我們可以再次嘗試使用Yakit進行競爭攻擊:

可見,此時返回包只有一個302響應了,再查看后臺也只成功添加一條提現記錄:

這意味著程序是按照預期運行,沒有發生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.atomic
def 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返回:

查看后臺,也只成功添加一個提現日志,符合預期:

樂觀鎖的優點就是不會鎖住數據庫記錄,也就不會影響其他線程查詢該用戶。
0x06 總結
本文主要從v2ex一個帖子的例子入手,闡述了如何使用Yakit進行Race Condition攻擊,以及在Django中如何使用悲觀鎖和樂觀鎖對該攻擊進行防御。
本文涉及的代碼,可以在 https://github.com/phith0n/race-condition-playground 找到。
本文由 Seebug Paper 發布,如需轉載請注明來源。本文地址:http://www.bjnorthway.com/3049/
暫無評論