Phòng chống Race Condition trong Django
Bài viết được viết lại theo ý hiểu của người viết, nội dung chủ yếu được lấy từ bài viết gốc https://mp.weixin.qq.com/s/9f5Hxoyw5ne8IcYx4uwwvQ của tác giả @phith0n
Lỗ hổng Race Condition là một lỗ hổng bảo mật, xảy ra khi hai hoặc nhiều luồng (threads) hoặc quy trình cùng truy cập và thay đổi dữ liệu chia sẻ mà không có cơ chế kiểm soát. Điều này có thể dẫn đến tình trạng không đồng bộ và gây ra những hậu quả ngoài ý muốn, từ việc mất dữ liệu đến việc thực hiện các hành động không mong muốn.
Một ví dụ Race Condition trong thực tế xảy ra như sau:
Bối cảnh:
- Alice và Bob là nhân viên của chuỗi tiệm bánh ngọt
Sự kiện Race Condition:
- Alice và Bob là nhân viên của 2 tiệm bánh ngọt khác nhau (cùng một chuỗi tiệm bánh ngọt) sử dụng chung fanpage để sử dụng cho việc đặt bánh của khách hàng. Ngày hôm đó, Alice và Bob đều cùng đọc được thông tin đơn hàng trên fanpage. Tuy nhiên, cả 2 đều đã nhận đơn và cùng lúc thực hiện giao bánh mà không thông báo rằng Alice (hoặc Bob) nhận đơn và đi giao bánh.
Hậu quả:
- Khách hàng có thể nhận được 2 lần đơn hàng hoặc trả lại một trong số chúng.
- Mất thời gian và nhân lực của Alice và Bob
Đấy là việc Race Condition xảy ra trong tự nhiên, vậy trong code thì sao. Chúng ta cùng đến với phần ví dụ xử lý Race Condition với Django.
Build Playground
Tạo 2 model mới:
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):
return str(self.created_time)
Một là bảng User
, sử dụng để lưu trữ người dùng, các thông tin cơ bản, trường money là số dư trong tài khoản của người dùng này. Bảng còn lại là WithdrawLog
, sử dụng để lưu trữ các log khi người dùng thực hiện rút tiền. Giả định rằng công ty sẽ trả tiền cho người dùng dựa trên bảng WithdrawLog
này. Vậy nếu số tiền rút ra được lưu trong bảng WithdrawLog
mà nhiều hơn số dư trong tài khoản của người dùng thì cuộc tấn công đã thành công.
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
Một đoạn code WithdrawForm
cho phép người dùng nhập số dư mà mình muốn rút tại thời điểm này, số dư phải là số nguyên.
WithdrawForm.clean_amount
sẽ thực hiện kiểm tra số dư người dùng, nếu phát hiện số tiền người dùng muốn rút lớn hơn số dư của người dùng thì sẽ trả về lỗi insufficient user balance
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())
Và thêm một vài đoạn code sử dụng để xử lý thông tin người dùng nhập vào, kiểm tra việc số dư có đủ hay không và thực hiện rút tiền sau quá trình kiểm tra thành công.
Cuối cùng thêm UI, router, admin, ...
Thực hiện set số tiền có sẵn của tài khoản phith0n
thành 10
sau đó đến phần Withdraw, thực hiện rút số tiền lớn hơn số tiền hiện có => hệ thống báo lỗi do số dư không đủ.
Race Condition khi không sử dụng lock hoặc transaction
Với WithdrawView1
, có thể thấy toàn bộ quá trình rút tiền không sử dụng lock hay transaction, về mặt lý thuyết là có lỗ hổng Race Condition ở đây.
Nguyên tắc của Race Condition rất đơn giản, mô hình dưới đây là quá trình người dùng rút tiền:
Vậy khi kiểm tra xong amount <= user.money
(Time of check), server sẽ tiếp tục handler tới phần rút tiền user.money -= amount
(Time of use). Điều gì xảy ra khi có 2 request trở lên được gửi cùng một lúc tới server? Nếu request thứ 2 tới khi request thứ nhất vẫn chưa thực hiện rút tiền xong, request thứ 2 sẽ được server kiểm tra tiền và lúc này, số tiền vẫn còn đó => 2 request này sẽ được Withdraw handler xử lý cùng lúc => người dùng rút tiền được 2 lần.
Có thể kiểm tra với Turbo Intruder trong BurpSuite tại đây, thực hiện mở 30 connection và gửi đồng thời 30 request tới server với request rút tiền. Lúc này, tài khoản người dùng đang có 10 money, thực hiện rút 10 money.
Đoạn script mở 30 connection với 30 request được gửi cùng một lúc với Turbo Intruder
def queueRequests(target, wordlists):
engine = RequestEngine(endpoint=target.endpoint,
concurrentConnections=30,
requestsPerConnection=100,
pipeline=False
)
for i in range(30):
engine.queue(target.req, target.baseInput, gate='race1')
engine.openGate('race1')
engine.complete(timeout=60)
def handleResponse(req, interesting):
if interesting:
table.add(req)
Kết quả, có 22 request rút tiền được thành công (status trả về 302
)
Kiểm tra kết quả tại màn hình admin, chính xác có tới 22 lần rút 10 money từ user tuannguy
. Mặc dù tài khoản tuannguy
có 10 money, tuy nhiên đã rút tiền thành công được 22 lần, tức 220 money
Race Condition khi sử dụng transaction nhưng không sử dụng lock
Django có Database transactions được sử dụng để quản lý các transactions trong database
Nguyên văn: Django gives you a few ways to control how database transactions are managed.
Vậy điều này có thể giải quyết vấn đề trong Race Condition hay không?
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())
chỉ cần thêm @transaction.atomic
trước hàm xử lý rút tiền là được. Tiếp tục sử dụng Turbo Intruder để kiểm tra, và kết quả ứng dụng vẫn bị Race Condition
=> transaction.atomic
không có khả năng xử lý vấn đề với Race Condition
Race Condition khi sử dụng pessimistic lock + transaction
select_for_update() có thể giải quyết vấn đề về Race Condition, đảm bảo rằng chỉ có một request có thể cập nhật thông tin money tại một thời điểm, giảm khả năng xảy ra Race Condition
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())
Trước khi xử lý request, phương thức dispatch
được sử dụng để đảm bảo rằng người dùng được chọn để thực hiện rút tiền được khóa lại, tránh race condition.
Kiểm tra với Turbo Intruder, có thể thấy rằng chỉ một request trả về 302
, tức người dùng chỉ rút tiền được duy nhất 1 lần. Và có các response khác trả về 500
báo database is locked
.
Khi bạn gọi select_for_update()
trong Django, nó sinh ra một câu lệnh SQL SELECT ... FOR UPDATE
tương ứng. Câu lệnh này có ý nghĩa là "chọn row này và đặt một khoá (lock) trên row này trong quá trình transaction".
Khi một row được khoá bằng FOR UPDATE
, các transaction khác không thể thực hiện các thao tác UPDATE
hoặc DELETE
trên row đó cho đến khi transaction hiện tại được commit hoặc rollback. Điều này giúp đảm bảo tính nhất quán của dữ liệu và tránh race condition trong các tình huống mà nhiều transaction cùng thao tác trên cùng một row.
Race Condition khi sử dụng optimistic lock + transaction
Trong bối cảnh của WithdrawView3
, nếu nhiều truy vấn đọc dữ liệu xảy ra đồng thời trong khi dữ liệu đang bị khóa, việc sử dụng pesimistic lock có thể tạo ra vấn đề về hiệu suất. Điều này xảy ra vì mỗi khi chúng ta truy cập dữ liệu này, người dùng hiện tại sẽ bị "khóa", làm ảnh hưởng đến các tình huống khác như xem hồ sơ của người dùng này, vì nó sẽ bị giữ lại và các query sẽ được liệt vào hàng chờ.
Trên thực tế, không phải tất cả databases hỗ trợ select_for_update()
, chúng ta cần thử sử dụng kỹ thuật optimistic locking để có thể giải quyết bài toán Race Condition.
Trong khía cạnh của "Optimistic Locking", chúng ta không giả định rằng các quy trình khác sẽ thay đổi dữ liệu, nên chúng ta không khóa dữ liệu đó. Thay vào đó, khi cần cập nhật dữ liệu, chúng ta sử dụng UPDATE của cơ sở dữ liệu để tiến hành cập nhật. Bởi vì câu lệnh UPDATE chính nó là một atomic operation, nó cũng có thể được sử dụng để ngăn chặn các vấn đề xử lý đồng thời. Vậy thay vì lock row trong quá trình update như pessimistic lock, optimistic lock chỉ lock khi commit việc update.
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())
Code tương tự như WithdrawView2
. Tuy nhiên điều kiện được kiểm tra ở chính câu query (filter
) đảm bảo rằng chỉ những người dùng có đủ tiền mới được cập nhật, và số hàng dữ liệu được cập nhật (rows) được trả về > 0.
Lúc này, giả sử có nhiều yêu cầu rút tiền đến câu lệnh UPDATE cùng một lúc. Do tính atomic của chính câu lệnh UPDATE, sau khi thực hiện lần cập nhật đầu tiên, số dư của người dùng đã bị giảm đi số tiền tương ứng. Khi lần cập nhật thứ hai được thực hiện, điều kiện money__gte=amount
sẽ không thành công, và số tiền sẽ không giảm đi nữa.
Ưu điểm của optimistic lock là nó sẽ không khóa các bản ghi cơ sở dữ liệu và sẽ không ảnh hưởng đến các luồng khác đang truy vấn người dùng. Tuy nhiên nó cũng có nhược điểm, bạn đọc có thể đọc thêm tại https://viblo.asia/p/009-optimistic-lock-va-pessimistic-lock-L4x5xr7aZBM
Tham khảo
All rights reserved