+18

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.

image.png

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, ...

640-1.png

Thực hiện set số tiền có sẵn của tài khoản phith0n thành 10

640.png

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:

640.png

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.

image.png

Đ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)

image.png

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 😀

image.png

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

image.png

=> 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.

image.png

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

Viblo
Hãy đăng ký một tài khoản Viblo để nhận được nhiều bài viết thú vị hơn.
Đăng kí