Phòng ngừa Lỗ hổng Race Condition trong Ứng dụng Web: Khai thác Limit Overrun và Cơ chế Khóa Database

Security tutorial - IT technology blog
Security tutorial - IT technology blog

Bài toán thực tế: Chiếc coupon dọn sạch tài nguyên hệ thống

Quý trước, đội ngũ chúng tôi triển khai một chương trình khuyến mãi tặng voucher dịch vụ tính toán trị giá 50 USD sử dụng một lần cho các lập trình viên đã xác minh. Chúng tôi đã thiết lập rate limit theo IP tiêu chuẩn (5 request mỗi giây), xác thực API bearer token đầy đủ và viết unit test kỹ lưỡng để đảm bảo các yêu cầu kích hoạt trùng lặp đều trả về HTTP 400. Mọi bài kiểm tra trên môi trường staging đều vượt qua suôn sẻ.

Thế nhưng chỉ hai giờ sau khi phát hành, Datadog gửi cảnh báo đỏ. Một tài khoản duy nhất đã kích hoạt thành công cùng một mã voucher tới 14 lần chỉ trong khoảng thời gian vỏn vẹn 35 mili giây. Người dùng đó đã gom trọn 700 USD credit tính toán. Kiểm tra log từ Nginx và application server, chúng tôi phát hiện 14 request POST đồng thời từ cùng một địa chỉ IP gửi tới /api/v1/coupons/redeem vào cùng một thời điểm. Mọi worker thread đều đọc dữ liệu từ database, kiểm tra thấy is_used vẫn là False, cộng tiền vào số dư tài khoản, rồi mới ghi nhận is_used = True trở lại ổ đĩa.

Chúng tôi đã trở thành nạn nhân của lỗ hổng limit overrun race condition (chạy đua vượt giới hạn). Kẻ tấn công thường xuyên săn tìm các luồng xử lý nhiều bước dạng này. Những mục tiêu phổ biến bao gồm chuyển tiền, áp dụng mã giảm giá, thanh toán thương mại điện tử và tính năng upvote. Bất cứ nơi nào mà bước kiểm tra trạng thái và bước thay đổi trạng thái nằm ở hai câu truy vấn riêng biệt, một khoảng trống bảo mật chết người sẽ xuất hiện.

Phân tích nguyên nhân gốc rễ: Lỗi TOCTOU (Time-of-Check to Time-of-Use)

Thủ phạm kỹ thuật ở đây chính là lỗi Time-of-Check to Time-of-Use (TOCTOU). Trên các máy chủ đa nhân hiện đại, các app server như Gunicorn hay Puma xử lý các request gửi đến bằng connection pool và worker pool. Khi hai request chạm tới máy chủ chỉ cách nhau vài micro giây, tiến trình thực thi của chúng sẽ xen kẽ lẫn nhau.

Luồng thực thi tồn tại lỗ hổng

Dưới đây là phiên bản đơn giản hóa của hàm xử lý backend ban đầu bằng Python của chúng tôi:

def redeem_voucher(user_id, voucher_code):
    # 1. THỜI ĐIỂM KIỂM TRA (TIME OF CHECK)
    voucher = db.query(Voucher).filter_by(code=voucher_code, is_used=False).first()
    if not voucher:
        return {"error": "Voucher không hợp lệ hoặc đã được sử dụng"}, 400

    # 2. BUSINESS LOGIC (Khoảng trễ thực thi: ~12-25ms)
    account = db.query(Account).filter_by(user_id=user_id).first()
    account.balance += voucher.amount

    # 3. THỜI ĐIỂM SỬ DỤNG (TIME OF USE)
    voucher.is_used = True
    db.commit()
    return {"success": True, "balance": account.balance}, 200

Hãy chú ý đến khoảng trống giữa bước 1 và bước 3. Việc truy vấn số dư tài khoản và thực hiện kiểm tra nghiệp vụ mất khoảng 15 mili giây. Nếu Request A và Request B cùng rơi vào khoảng trống 15 mili giây đó, cả hai đều thực hiện bước 1 trước khi có request nào chạm tới bước 3. Cả hai truy vấn đều thấy voucher còn hiệu lực. Cả hai đều cộng tiền vào tài khoản. Cả hai đều commit. Toàn bộ quy tắc nghiệp vụ “chỉ dùng một lần” hoàn toàn bị vô hiệu hóa.

Kẻ tấn công khai thác xử lý đồng thời như thế nào: Turbo Intruder

Trước đây, độ trễ mạng khiến việc căn thời gian cho các request đồng thời này trở nên khá khó khăn. Các gói tin phân tán qua mạng internet công cộng thường bị chênh lệch tới hàng chục mili giây do jitter. Các công cụ khai thác hiện đại như Turbo Intruder (tiện ích mở rộng của Burp Suite viết bằng Jython) đã loại bỏ hoàn toàn hiện tượng lệch pha mạng này bằng kỹ thuật tấn công single-packet (gói tin đơn).

Thay vì mở 20 luồng TCP riêng rẽ, Turbo Intruder làm ấm trước (pre-warm) một kết nối HTTP/2 duy nhất. Nó chuẩn bị sẵn sàng từ 20 đến 30 request đổi voucher HTTP hoàn chỉnh, chỉ giữ lại byte dữ liệu cuối cùng của mỗi payload. Sau đó, nó giải phóng toàn bộ các byte còn lại vào đúng một gói tin TCP duy nhất. Web server nhận được toàn bộ cụm request trong cùng một chu kỳ xung nhịp vật lý và chuyển tiếp chúng song song tới các thread nội bộ.

Dưới đây là một đoạn script kiểm thử mà các kỹ sư bảo mật sử dụng để rà soát endpoint tìm lỗi xử lý đồng thời:

def queueRequests(target, wordlists):
    engine = RequestEngine(endpoint=target.endpoint,
                           concurrentConnections=1,
                           engine=Engine.BURP2,
                           pipeline=False
                           )

    # Chuẩn bị sẵn 20 request giống hệt nhau nhưng giữ lại byte cuối
    for i in range(20):
        engine.queue(target.req, gate='race1')

    # Giải phóng toàn bộ request chỉ trong một gói tin TCP duy nhất
    engine.openGate('race1')

def handleResponse(req, interesting):
    table.add(req)

Nếu có từ hai request trở lên trả về HTTP 200 và làm tăng số dư tài khoản, endpoint của bạn chắc chắn đã dính lỗi race condition.

So sánh các giải pháp kiểm soát đồng thời (Concurrency Control)

Chúng tôi đã đánh giá bốn phương án kỹ thuật để vá lỗ hổng này trên toàn bộ kiến trúc hệ thống.

1. Mutex cấp ứng dụng và In-Memory Lock

Nhiều lập trình viên thường nghĩ ngay đến các cơ chế có sẵn của ngôn ngữ như threading.Lock() trong Python hoặc sync.Mutex trong Go. Cách này ngăn chặn được race condition trong bộ test nội bộ chạy trên một tiến trình duy nhất. Nhưng trên production, nó lập tức thất bại. Các hệ thống thực tế chạy trên nhiều Kubernetes pod hoặc tiến trình WSGI đa worker. Một khóa nằm trong Worker 1 ở Pod A không thể đồng bộ hóa với Worker 3 ở Pod B.

2. Khóa phân tán với Redis (Redlock)

Một mẫu kiến trúc phổ biến thứ hai là ủy quyền đồng bộ hóa khóa cho Redis. Trước khi thực thi transaction, thread sẽ chiếm lấy một key như lock:voucher:CODE123 đi kèm thời gian sống (TTL) ngắn:

# Lấy khóa Redis với TTL 3 giây
lock = redis_client.lock("lock:voucher:" + voucher_code, timeout=3)
if lock.acquire(blocking=False):
    try:
        # Thực thi transaction
        process_redemption(user_id, voucher_code)
    finally:
        lock.release()
else:
    return {"error": "Yêu cầu đang được xử lý"}, 429

Giải pháp này hoạt động được trên nhiều pod, nhưng lại mang đến nhiều phiền toái trong vận hành. Hiện tượng chia cắt mạng Redis (network partition), failover node hoặc tạm dừng do garbage collection kéo dài có thể khiến TTL hết hạn trước khi database kịp ghi xong. Khi khóa bị giải phóng sớm, race condition lại tái xuất hiện. Bạn vô tình phải duy trì tính nhất quán giữa hai hệ thống phân tán cho một vấn đề vốn thuộc về trách nhiệm của database.

3. Khóa lạc quan (Optimistic Concurrency Control – Cột Version)

Optimistic locking phù hợp với các luồng nghiệp vụ hiếm khi xảy ra xung đột đồng thời. Bạn thêm một cột số nguyên version vào bảng và kiểm tra nó trong mỗi lần ghi:

UPDATE vouchers 
SET is_used = TRUE, version = version + 1 
WHERE id = 42 AND version = 1;

Nếu một thread khác đã ghi trước bạn, số lượng bản ghi được cập nhật sẽ là 0. Ứng dụng sẽ nhận biết việc 0 hàng được cập nhật để hủy bỏ hoặc thử lại. Tuy nhiên, trong các đợt flash sale nhu cầu cao hoặc các chiến dịch lan truyền mạnh, hàng ngàn request sẽ xung đột cùng lúc. Việc rollback và retry liên tục sẽ nhanh chóng vắt kiệt CPU và giới hạn I/O của database.

4. Khóa bi quan ở cấp độ Database (Pessimistic Locking)

Pessimistic locking giao toàn bộ việc đồng bộ hóa trực tiếp cho PostgreSQL hoặc MySQL. Bằng cách thực thi truy vấn SELECT ... FOR UPDATE bên trong một transaction rõ ràng, database engine sẽ khóa bản ghi mục tiêu ngay tại tầng lưu trữ. Các transaction khác cố gắng đọc hoặc chỉnh sửa bản ghi cụ thể đó đều phải chờ cho đến khi transaction đầu tiên commit hoặc rollback.

Giải pháp khuyến nghị: Khóa dòng bi quan và Thao tác Atomic

Đối với mã khuyến mãi, số dư tài chính và phân bổ hàng tồn kho, kỹ thuật khóa dòng bi quan cùng các thao tác SQL nguyên tử (atomic) mang lại khả năng bảo vệ tin cậy và gọn gàng nhất mà không cần thêm hạ tầng phức tạp.

Triển khai từng bước

Để vá luồng đổi voucher, chúng tôi đã cập nhật code SQLAlchemy để yêu cầu một khóa dòng độc quyền (exclusive row lock) ngay khi bắt đầu khối transaction:

from sqlalchemy.orm import Session

def redeem_voucher_secure(db: Session, user_id: int, voucher_code: str):
    try:
        with db.begin():
            # SELECT ... FOR UPDATE thiết lập khóa dòng độc quyền trên voucher này
            voucher = db.query(Voucher)\
                .filter_by(code=voucher_code)\
                .with_for_update()\
                .first()

            if not voucher or voucher.is_used:
                return {"error": "Voucher không hợp lệ hoặc đã được sử dụng"}, 400

            # Khóa dòng account để ngăn chặn hiện tượng sai lệch số dư (write skew)
            account = db.query(Account)\
                .filter_by(user_id=user_id)\
                .with_for_update()\
                .first()

            # Áp dụng các thay đổi một cách an toàn
            account.balance += voucher.amount
            voucher.is_used = True
            voucher.redeemed_by = user_id
            
        # Transaction tự động commit khi thoát khối context và giải phóng khóa
        return {"success": True, "balance": account.balance}, 200

    except Exception:
        return {"error": "Lỗi máy chủ nội bộ"}, 500

Tại sao cách này vô hiệu hóa hoàn toàn Single-Packet Attack

Khi 20 request được đồng bộ hóa truyền tới từ Turbo Intruder, Thread 1 sẽ chiếm giữ khóa độc quyền trên dòng voucher đó. Các Thread từ 2 đến 20 sẽ tạm dừng trong hàng đợi của database engine. Ngay khi Thread 1 cập nhật is_used = True và commit, khóa sẽ được chuyển tiếp cho Thread 2. Thread 2 đọc trạng thái dòng mới nhất, nhận thấy is_used đã là True, từ chối request và thoát. 18 thread còn lại cũng làm điều tương tự. Hoàn toàn không còn tình trạng chạy đua vượt giới hạn.

Phương án thay thế: Cập nhật SQL Atomic bằng một câu lệnh duy nhất

Nếu bạn muốn tránh hoàn toàn việc giữ transaction mở và phải chờ khóa dòng, một câu truy vấn UPDATE ... WHERE nguyên tử đơn lẻ thậm chí còn mang lại tốc độ nhanh hơn:

-- Truy vấn check-and-set nguyên tử (atomic)
UPDATE vouchers 
SET is_used = TRUE, redeemed_by = 105 
WHERE code = 'PROMO50' AND is_used = FALSE;

-- Kiểm tra số dòng bị ảnh hưởng:
-- 1 dòng bị ảnh hưởng -> Thành công. Cộng số dư tài khoản.
-- 0 dòng bị ảnh hưởng -> Đã được sử dụng. Từ chối ngay lập tức.

Các hệ quản trị cơ sở dữ liệu quan hệ luôn đảm bảo tính nguyên tử ở cấp độ dòng khi ghi dữ liệu. Chỉ có truy vấn đầu tiên chạm tới đích mới khớp với điều kiện is_used = FALSE và trả về số dòng tác động là 1. Mọi truy vấn đồng thời còn lại đều chỉ khớp 0 dòng và lập tức kết thúc mà không phải xếp hàng chờ sau các transaction lock.

Bảo mật hạ tầng bổ trợ

Gia cố logic ứng dụng đã khắc phục được lỗi trực tiếp, nhưng duy trì thói quen bảo mật tổng thể vẫn là điều tối quan trọng. Khi rà soát lại các tài khoản cơ sở dữ liệu sau đợt vá lỗi này, chúng tôi đã luân chuyển (rotate) lại toàn bộ thông tin xác thực của các service và tài khoản bảo trì.

Đối với quyền truy cập staging và quản trị, việc tạo mật khẩu máy chủ mạnh thông qua các công cụ phía client như toolcraft.app/vi/tools/security/password-generator giúp giữ kín bí mật ngay trong trình duyệt mà không lộ ra ngoài mạng. Việc cô lập credential của cơ sở dữ liệu đảm bảo rằng một lỗ hổng ứng dụng không thể bị liên kết để leo thang đặc quyền rộng hơn.

Bài học rút ra cho đợt Code Review tiếp theo của bạn

  • Cảnh giác với mô hình read-modify-write: Mọi khoảng trống giữa việc truy vấn điều kiện và ghi kết quả đều có thể bị khai thác khi thực thi đồng thời.
  • Xử lý tương tranh ngay tại nơi lưu trữ dữ liệu: Trước khi bổ sung các hệ thống điều phối khóa bên ngoài như Redis, hãy xem xét liệu câu lệnh SQL atomic hoặc SELECT ... FOR UPDATE có thể xử lý triệt để ngay tại database hay không.
  • Kiểm thử bằng các công cụ single-packet: Tích hợp Turbo Intruder hoặc các script kiểm thử tương tranh tùy chỉnh vào quy trình bảo mật định kỳ để phát hiện race condition trước khi lưu lượng truy cập thực tế khai thác chúng.
Share: