SecLab
Server-sideĐầy đủ

Race conditions

A10CWE-362
01

Là gì

Race condition là lỗi khi hai request đồng thời đọc cùng một trạng thái cũ rồi cùng hành động dựa trên nó. Mẫu kinh điển là kiểm rồi mới dùng (TOCTOU): cả hai request thấy "mã giảm giá còn hiệu lực", cả hai áp dụng nó, và mã dùng được hai lần. Không có payload nào — chỉ có thời điểm.

02

Vì sao bạn quan tâm

Mức liên quan: Bắt buộcKỳ vọng: L2

Đây là lỗi mà mọi phép kiểm bảo mật thông thường đều mù: không có ký tự đặc biệt để grep, không có chuỗi độc hại, không có gì cho WAF nhìn thấy. Request thứ hai giống hệt request thứ nhất — và trong log nó trông như một người dùng bấm nút hai lần.

Ba lý do nó phổ biến hơn người ta tưởng:

  • Test đơn vị không bao giờ tìm ra nó. Test chạy tuần tự. Lỗi chỉ tồn tại khi hai luồng giao nhau đúng một cửa sổ vài milli-giây.
  • Nó không xuất hiện trên máy dev. Một request một lần thì cửa sổ không mở. Nó xuất hiện ở production, nơi có nhiều instance và người dùng biết bấm nút nhanh.
  • Nó nằm ở chỗ có tiền. Hoàn tiền, rút tiền, mã giảm giá, điểm thưởng, chuyển số dư, đặt vé cuối, giới hạn lượt dùng — mọi chỗ có một con số phải giảm đi.

Và khác biệt quan trọng nhất so với các topic khác: bản vá nằm ở DB, không ở code ứng dụng. Một câu if trong C# không thể là ranh giới nguyên tử cho hai process khác nhau. Nếu bản vá của bạn không có một trong bốn thứ — transaction có isolation đúng, SELECT … FOR UPDATE, ràng buộc UNIQUE, hay UPDATE … WHERE có điều kiện — thì nó chưa vá gì.

03

Cơ chế hoạt động

Cửa sổ nằm giữa đọcghi. Mọi thứ xảy ra trong khoảng đó là thứ request kia không thấy.

Nguồn sơ đồ
sequenceDiagram    autonumber    participant R1 as Request 1    participant DB    participant R2 as Request 2    R1->>DB: SELECT uses_left FROM coupon WHERE code='X'    DB-->>R1: 1    R2->>DB: SELECT uses_left FROM coupon WHERE code='X'    Note over R1,R2: ⚠ CỬA SỔ — cả hai đều thấy 1,<br/>và không bên nào biết bên kia tồn tại.    DB-->>R2: 1    R1->>DB: UPDATE coupon SET uses_left=0    R1->>DB: INSERT discount (order 1)    R2->>DB: UPDATE coupon SET uses_left=0    R2->>DB: INSERT discount (order 2)    Note over DB: uses_left = 0, nhưng mã đã dùng HAI lần.

Điểm cốt lõi: uses_left cuối cùng vẫn bằng 0, nên dữ liệu trông đúng. Không có gì trong DB nói rằng đã có lỗi xảy ra — đó là lý do những lỗi này thường chỉ được phát hiện khi kế toán đối chiếu.

Bốn dạng, và chúng cần bốn bản vá khác nhau:

DạngVí dụBản vá
Limit overrunDùng mã giảm giá 2 lần, rút quá số dưUPDATE … WHERE uses_left > 0 nguyên tử
Ghi trùngHai tài khoản cùng email, hai đơn cùng mãRàng buộc UNIQUE
Multi-step bypassĐổi giá giỏ hàng giữa lúc thanh toánMột transaction bao cả luồng
Single-packet attack20 request cùng lúc trong một TCP packetKhông có bản vá riêng — nó chỉ làm cửa sổ dễ trúng hơn

Hàng cuối đáng biết vì nó xoá bỏ lập luận "cửa sổ quá hẹp để khai thác": kỹ thuật single-packet attack (James Kettle, 2023) gửi 20–30 request trong một packet duy nhất qua HTTP/2, nên jitter mạng bị loại bỏ hoàn toàn và các request tới server trong cùng một micro-giây. Cửa sổ vài trăm micro-giây giờ là cửa sổ trúng được, và trúng được từ ngoài Internet.

Mô tả sơ đồ: Sơ đồ tuần tự hai request cùng thao tác trên một mã giảm giá. Request 1 đọc uses_left và nhận 1. Request 2 cũng đọc và cũng nhận 1 — đây là cửa sổ, cả hai thấy cùng trạng thái cũ và không bên nào biết bên kia tồn tại. Sau đó cả hai cùng UPDATE uses_left về 0 và cùng INSERT một bản ghi giảm giá cho đơn của mình. Kết quả là uses_left bằng 0 nên dữ liệu trông đúng, nhưng mã đã được dùng hai lần.

04

Ví dụ cụ thể

Endpoint áp mã giảm giá dùng một lần. Request giống hệt nhau, gửi cùng lúc.

Shell
# 20 request song song, cùng một mã. Với single-packet attack chúng tới# server trong cùng một micro-giây; ở đây curl là đủ để thấy lỗi.for i in $(seq 1 20); do  curl -s -X POST https://shop.example.com/api/orders/$i/coupon \    -H "Authorization: Bearer $TOKEN" \    -d '{"code":"SAVE50"}' &done; wait
JSON
// 7 trong 20 request trả 200:{"applied":true,"discount":"50%","orderId":3}{"applied":true,"discount":"50%","orderId":7}{"applied":true,"discount":"50%","orderId":11}
SQL
-- Và trong DB, dữ liệu trông hoàn toàn bình thường:SELECT code, uses_left FROM coupon WHERE code = 'SAVE50';--  SAVE50 | 0        ← đúng như mong đợi SELECT count(*) FROM order_discount WHERE coupon_code = 'SAVE50';--  7                 ← đây mới là con số nói ra vấn đề

Không một request nào trong 20 request đó có gì bất thường. Chúng chỉ đến cùng lúc.

C#Cửa sổ nằm giữa dòng 10 và dòng 20. ORM không làm nó nhỏ hơn.
[HttpPost("/api/orders/{orderId:long}/coupon")]public async Task<IActionResult> ApplyCoupon(long orderId, [FromBody] CouponRequest req, CancellationToken ct){    // ❌ ĐỌC. Từ đây tới lúc GHI ở dòng 20 là cửa sổ, và mọi request khác tới trong    //    khoảng đó cũng đọc được đúng giá trị này.    var coupon = await _db.Coupons        .FirstOrDefaultAsync(c => c.Code == req.Code, ct);     if (coupon is null) return NotFound();     // ❌ KIỂM. Đúng về logic, và vô dụng về đồng thời: hai mươi request cùng lúc    //    đều thấy UsesLeft = 1 và đều đi qua dòng này.    if (coupon.UsesLeft <= 0)        return BadRequest(new { error = "Coupon exhausted" });     coupon.UsesLeft--;    _db.OrderDiscounts.Add(new OrderDiscount(orderId, req.Code, coupon.Percent));     // ❌ GHI, ở một round-trip KHÁC. Transaction của SaveChanges không cứu được:    //    mặc định là Read Committed, và Read Committed cho phép đúng cửa sổ này.    await _db.SaveChangesAsync(ct);     return Ok(new { applied = true, discount = $"{coupon.Percent}%" });}
PythonCùng mẫu trong Python: đọc, kiểm, ghi — ba round-trip, hai cửa sổ.
@app.post("/api/accounts/<int:account_id>/withdraw")def withdraw(account_id):    amount = Decimal(request.json["amount"])     with db.session.begin():        # ❌ ĐỌC. Mặc định của Postgres là Read Committed, và Read Committed cho        #    phép một transaction khác đọc cùng giá trị này.        acct = db.session.get(Account, account_id)         # ❌ KIỂM. Hai request đồng thời đều thấy balance = 100 và đều đi qua.        if acct.balance < amount:            return {"error": "insufficient funds"}, 400         # ❌ GHI. Gán từ một giá trị đã ĐỌC trước đó: nó ghi đè kết quả của        #    transaction kia thay vì trừ tiếp lên nó.        acct.balance = acct.balance - amount        db.session.add(Ledger(account_id=account_id, amount=-amount))     return {"balance": str(acct.balance)}
05

Chuyện đã xảy ra

The DAO, tháng 6/2016 — ~3,6 triệu ETH (~50 triệu USD lúc đó). Hàm splitDAO gửi tiền đi trước khi cập nhật số dư, nên một hợp đồng gọi lại (reentrancy) rút được nhiều lần từ cùng một số dư. Đây là race condition ở dạng thuần khiết nhất: thứ tự "hành động rồi mới ghi" thay vì "ghi rồi mới hành động". Hậu quả lớn tới mức Ethereum phải hard-fork, tách ra thành ETH và ETC.

Starbucks gift card, 2015 (Egor Homakov). Chuyển số dư giữa hai thẻ đồng thời tạo ra tiền từ không khí — cùng mẫu limit overrun ở khối 3. Đáng đọc vì nó cho thấy lỗi này không cần kỹ thuật gì: chỉ cần hai tab trình duyệt và một chút thời điểm.

Kỹ thuật single-packet attack (James Kettle, PortSwigger, 2023) không phải một sự cố, nhưng nó thay đổi mức rủi ro của toàn bộ họ lỗi này: nó biến những cửa sổ trước đây được coi là "quá hẹp để khai thác trên Internet" thành khai thác được ổn định.

06

Cách phòng chống

Lớp 1

Một câu lệnh nguyên tử — kiểm và ghi trong cùng một `UPDATE`

bắt buộc

Đây là bản vá đúng cho dạng limit overrun, và nó là bản vá tốt nhất vì nó xoá bỏ cửa sổ thay vì bảo vệ nó.

SQL
-- Điều kiện nằm TRONG câu UPDATE. DB bảo đảm không có hai transaction nào-- cùng thấy uses_left > 0 rồi cùng ghi: câu thứ hai thấy 0 và khớp 0 hàng.UPDATE coupon   SET uses_left = uses_left - 1 WHERE code = @code AND uses_left > 0RETURNING uses_left;

Rồi kiểm số hàng bị ảnh hưởng: 0 hàng nghĩa là mã đã hết, và đó là câu trả lời cho request. Bước kiểm này là phần hay bị bỏ — một ExecuteUpdate mà không ai đọc giá trị trả về là một bản vá không hoạt động.

Trong EF Core 8: ExecuteUpdateAsync sinh ra đúng một câu UPDATE … WHERE và trả về số hàng. Không dùng context.Coupons.First() rồi sửa property rồi SaveChanges() — đó chính là kiểm-rồi-dùng qua hai round-trip.

C# · Layer 1Hai lớp bảo đảm: UPDATE nguyên tử có điều kiện, và UNIQUE bắt phần còn lại.
/// <summary>/// Hai bảo đảm, không phải một, vì chúng bảo vệ hai thứ khác nhau://////   • UPDATE … WHERE uses_left > 0  → bộ đếm không xuống dưới 0 (limit overrun)///   • UNIQUE (coupon_code, order_id) → không có hai dòng giảm giá cho cùng cặp////// Chỉ có cái đầu thì hai request cho CÙNG một đơn vẫn tạo hai dòng discount./// Chỉ có cái sau thì hai request cho HAI đơn khác nhau vẫn vượt max_uses./// </summary>public sealed class CouponService{    private readonly SecLabDbContext _db;     public CouponService(SecLabDbContext db) => _db = db;     public async Task<CouponResult> ApplyAsync(long orderId, string code, CancellationToken ct)    {        // Transaction bao cả hai bước: nếu INSERT vi phạm UNIQUE thì việc trừ bộ đếm        // cũng phải mất theo, nếu không mã bị "tiêu" mà không ai được giảm giá.        await using var tx = await _db.Database.BeginTransactionAsync(ct);         // MỘT câu lệnh: điều kiện nằm TRONG UPDATE. Không có khoảng nào giữa kiểm và        // ghi để một request khác chen vào — DB bảo đảm điều đó, không phải code này.        //        // ExecuteUpdateAsync sinh đúng một câu UPDATE … WHERE và KHÔNG nạp entity nào,        // nên không có bản sao nào trong bộ nhớ để ai đó sửa rồi SaveChanges.        var claimed = await _db.Coupons            .Where(c => c.Code == code && c.UsesLeft > 0)            .ExecuteUpdateAsync(s => s.SetProperty(c => c.UsesLeft, c => c.UsesLeft - 1), ct);         // ĐỌC SỐ HÀNG. Đây là dòng hay bị bỏ, và bỏ nó thì cả bản vá trên vô nghĩa:        // câu UPDATE đúng, rồi ta bỏ đi câu trả lời của nó và vẫn trả 200.        if (claimed == 0)        {            await tx.RollbackAsync(ct);            return CouponResult.Exhausted;        }         try        {            _db.OrderDiscounts.Add(new OrderDiscount(orderId, code));            await _db.SaveChangesAsync(ct);            await tx.CommitAsync(ct);            return CouponResult.Applied;        }        catch (DbUpdateException e) when (IsUniqueViolation(e))        {            // Ràng buộc DB là nguồn sự thật, và cách nó nói "trùng" là ném lỗi. Đây là            // chỗ duy nhất trong codebase mà bắt exception làm luồng điều khiển là ĐÚNG:            // không có cách nào hỏi trước mà không quay lại kiểm-rồi-dùng.            await tx.RollbackAsync(ct);            return CouponResult.AlreadyApplied;        }    }     /// <summary>23505 = unique_violation. Mã của chuẩn SQL, không phải của Npgsql.</summary>    private static bool IsUniqueViolation(DbUpdateException e) =>        e.InnerException is PostgresException { SqlState: "23505" };} // ── Migration: ràng buộc là một nửa của bản vá, và nó sống ở đây ─────────────// Nửa này nằm trong migration chứ không trong code, nên nó có thể bị một migration// khác xoá đi mà không ai thấy — đó là lý do khối 7 có một phép kiểm riêng cho nó.migrationBuilder.Sql("""    ALTER TABLE core.order_discount      ADD CONSTRAINT ux_order_discount_order_coupon UNIQUE (order_id, coupon_code);     ALTER TABLE core.coupon      ADD CONSTRAINT ck_coupon_uses_left_nonneg CHECK (uses_left >= 0);    """);
Python · Layer 1UPDATE có điều kiện + RETURNING, đọc rowcount, và CHECK ở DB làm lưới cuối.
from decimal import Decimal from sqlalchemy import text  class InsufficientFunds(Exception):    pass  def withdraw(account_id: int, amount: Decimal) -> Decimal:    """Một câu lệnh nguyên tử. Điều kiện nằm TRONG UPDATE, không trong một if.     `balance = balance - :amt` trừ trên giá trị HIỆN TẠI trong DB, không trên giá trị    ta đã đọc trước đó — nên hai transaction đồng thời cộng dồn đúng thay vì ghi đè    kết quả của nhau. Đây là khác biệt giữa `SET x = x - 1` và `SET x = <đã đọc> - 1`,    và nó là toàn bộ bản vá.    """    with db.session.begin():        row = db.session.execute(            text("""                UPDATE account                   SET balance = balance - :amt                 WHERE id = :id                   AND balance >= :amt             RETURNING balance            """),            {"id": account_id, "amt": amount},        ).first()         # ĐỌC KẾT QUẢ. None nghĩa là điều kiện không khớp — số dư không đủ. Bỏ dòng        # này đi thì câu UPDATE vẫn đúng và endpoint vẫn trả 200 cho mọi request.        if row is None:            raise InsufficientFunds()         db.session.add(Ledger(account_id=account_id, amount=-amount))     return row.balance  # Và lưới cuối ở tầng DB. Nó không thay bản vá trên — nó bắt câu truy vấn TIẾP THEO,# do người khác viết, ở một endpoint mà trang này chưa tồn tại lúc họ viết nó.MIGRATION = """    ALTER TABLE account      ADD CONSTRAINT ck_account_balance_nonneg CHECK (balance >= 0);     -- Idempotency: một Idempotency-Key chỉ tạo được một lệnh, nên retry của client    -- không thành một lần rút thứ hai.    CREATE UNIQUE INDEX ux_ledger_idempotency        ON ledger (idempotency_key) WHERE idempotency_key IS NOT NULL;"""
Lớp 1b

Ràng buộc `UNIQUE` — cho dạng ghi trùng, và DB là nơi duy nhất bảo đảm được

bắt buộc

Với dạng ghi trùng (hai tài khoản cùng email, một mã dùng hai lần cho hai đơn), không có if nào ở tầng ứng dụng đủ: if (!await _users.AnyAsync(...)) rồi Add là kiểm-rồi-dùng.

SQL
-- Chỉ số duy nhất một phần: mỗi mã chỉ được dùng một lần trên toàn hệ thống.CREATE UNIQUE INDEX ux_order_discount_coupon    ON core.order_discount (coupon_code); -- Hoặc theo người dùng: mỗi người một lần.CREATE UNIQUE INDEX ux_order_discount_coupon_user    ON core.order_discount (coupon_code, user_id);

Rồi bắt lỗi vi phạm ràng buộc và biến nó thành một câu trả lời nghiệp vụ, không để nó thành 500. Trong Npgsql: PostgresException với SqlState == "23505". Đây là chỗ duy nhất mà bắt exception làm luồng điều khiển là đúng: ràng buộc DB là nguồn sự thật, và cách nó nói "trùng" là ném lỗi.

Idempotency key là cùng một kỹ thuật cho một mục đích khác: client gửi Idempotency-Key, server có UNIQUE trên cột đó, nên retry của cùng một lệnh không tạo ra tác dụng thứ hai.

Lớp 1c

Khoá hàng khi luồng buộc phải nhiều bước

Khi logic không nhét được vào một câu lệnh (tính giá theo nhiều bảng rồi mới trừ số dư), khoá hàng trong một transaction:

SQL
BEGIN;-- FOR UPDATE khoá hàng tới hết transaction. Transaction thứ hai ĐỢI ở đây,-- và khi nó đi tiếp thì nó thấy trạng thái ĐÃ cập nhật, không thấy trạng thái cũ.SELECT balance FROM account WHERE id = @id FOR UPDATE;-- … tính toán nhiều bước …UPDATE account SET balance = balance - @amount WHERE id = @id;COMMIT;

Hai điều bắt buộc, và cả hai đều hay bị bỏ:

  • Khoá theo thứ tự nhất quán khi khoá nhiều hàng (luôn theo id tăng dần). Không có nó, hai transaction chuyển tiền ngược chiều nhau tạo ra deadlock.
  • Timeout cho khoá (SET LOCAL lock_timeout = '3s'). Một khoá không timeout biến một hàng nóng thành điểm nghẽn của cả hệ thống, và đó là DoS không cần lỗ hổng nào.

Serializable isolation là lựa chọn thay thế: DB tự phát hiện xung đột và abort một bên. Nó đúng và đơn giản hơn, nhưng caller phải có vòng retry — nếu không, tải cao biến thành lỗi 500.

Lớp 2

Rate limit theo tài nguyên, không chỉ theo IP

Lớp này không vá lỗi — nó thu hẹp cửa sổ và giới hạn số lần thử. Nhưng nó phải đúng chiều: rate limit theo IP không giúp gì với 20 request đến từ một IP trong một packet.

Cái cần là khoá theo tài nguyên đang tranh chấp: "mã SAVE50 chỉ được xử lý một luồng tại một thời điểm". Một khoá phân tán trên Redis (SET key NX PX 5000) theo coupon:{code} làm được điều đó.

Quan trọng: khoá Redis không thay được ràng buộc DB. Nó là tối ưu (giảm số transaction bị abort), không phải bảo đảm — Redis có thể mất khoá khi failover, và lúc đó chỉ còn lớp 1 đứng lại.

Lớp 3

Phát hiện: đối soát định kỳ, và alert theo tốc độ

Khối 3 nói rằng sau khi lỗi xảy ra, dữ liệu trông đúng. Nên phát hiện phải là một phép đối soát so hai con số đáng lẽ phải khớp:

SQL
-- Chạy hằng đêm. Mọi dòng trả về là một lần race condition đã xảy ra.SELECT c.code, c.max_uses, count(d.*) AS actual  FROM core.coupon c  JOIN core.order_discount d ON d.coupon_code = c.code GROUP BY c.code, c.max_usesHAVING count(d.*) > c.max_uses;

Và alert theo tốc độ, không theo tổng: N request cùng một coupon_code trong một giây từ cùng một tài khoản là chữ ký của một lần thử, và nó là tín hiệu độ nhiễu thấp. Đây là lớp 3 vì nó không chặn gì — nhưng với họ lỗi này nó là thứ duy nhất cho bạn biết lỗi đã xảy ra, vì DB không nói.

07

Kiểm chứng đã vá

Đây là topic mà test tuần tự vô dụng, và đó là điều quan trọng nhất cần nhớ về việc kiểm chứng nó. Một test gọi endpoint hai lần liên tiếp sẽ pass trên cả code lỗi.

1. Test song song thật — N luồng, một Barrier. Barrier là chi tiết quyết định: nó giữ mọi luồng lại cho tới khi tất cả đã sẵn sàng, rồi thả cùng lúc. Không có nó, luồng đầu đã xong trước khi luồng cuối bắt đầu và cửa sổ không bao giờ mở. Xem tab csharp / test.

2. Khẳng định đúng con số. Test phải kiểm số tác dụng đã xảy ra (count(*) trong bảng hệ quả), không kiểm trạng thái cuối của bộ đếm — vì uses_left = 0 là đúng trong cả hai trường hợp. Đây là sai lầm làm phần lớn test race condition trở nên vô nghĩa.

3. Chạy test đó nhiều lần trong CI. Một lần pass không chứng minh gì: race condition là xác suất. dotnet test --filter Race -- xunit.execution.Repeat=50, hoặc một vòng lặp trong chính test. Và chạy trên nhiều core:

Shell
for i in $(seq 1 30); do  dotnet test --filter Concurrency --nologo -v q || { echo "đỏ ở lần $i"; exit 1; }done

4. Kiểm ràng buộc DB thật sự tồn tại — bản vá lớp 1b sống trong migration, không trong code, nên nó bị xoá bởi một migration khác mà không ai thấy:

SQL
-- Phải trả về đúng một hàng:SELECT indexname FROM pg_indexes WHERE schemaname = 'core' AND tablename = 'order_discount'   AND indexdef ILIKE '%unique%coupon_code%';

5. Thử bằng single-packet attack trên staging. Burp Repeater (chế độ "send group in parallel") hoặc turbo-intruder. Đây là phép kiểm duy nhất mô phỏng đúng điều kiện thật, và nó là thứ phân biệt "cửa sổ hẹp" với "cửa sổ đã đóng".

6. Query đối soát ở lớp 3 phải chạy trong CI trên dữ liệu test, không chỉ trong một runbook. Nó là bất biến nghiệp vụ, và bất biến thì nên có test.

C#`Barrier` là chi tiết quyết định — không có nó, test pass trên cả code lỗi.
public class CouponConcurrencyTests : IClassFixture<PostgresFixture>{    private readonly PostgresFixture _fx;     public CouponConcurrencyTests(PostgresFixture fx) => _fx = fx;     /// <summary>    /// Test PHẢI song song thật. Một test gọi endpoint hai lần liên tiếp pass trên    /// code lỗi — cửa sổ không bao giờ mở, vì lần gọi đầu đã ghi xong trước khi lần    /// thứ hai bắt đầu đọc.    ///    /// Barrier là thứ làm test này có nghĩa: nó giữ cả 20 task ở vạch xuất phát cho    /// tới khi task cuối cùng đã sẵn sàng, rồi thả tất cả trong cùng một nhịp. Đây là    /// bản mô phỏng của single-packet attack ở tầng test.    /// </summary>    [Theory]    [InlineData(20)]    [InlineData(50)]    public async Task Single_use_coupon_applies_exactly_once(int concurrency)    {        await _fx.SeedCouponAsync("SAVE50", maxUses: 1);        var orders = await _fx.SeedOrdersAsync(count: concurrency);         using var barrier = new Barrier(concurrency);        var results = new CouponResult[concurrency];         await Task.WhenAll(Enumerable.Range(0, concurrency).Select(i => Task.Run(async () =>        {            // Mỗi task có scope DI riêng → connection riêng → transaction riêng.            // Dùng chung một DbContext thì test đo sai thứ: nó đo hành vi của            // change tracker, không đo hành vi đồng thời của DB.            await using var scope = _fx.Services.CreateAsyncScope();            var svc = scope.ServiceProvider.GetRequiredService<CouponService>();             barrier.SignalAndWait();          // ← tất cả xuất phát cùng lúc            results[i] = await svc.ApplyAsync(orders[i].Id, "SAVE50", default);        })));         // Khẳng định 1: đúng MỘT request thành công.        Assert.Equal(1, results.Count(r => r == CouponResult.Applied));         // Khẳng định 2 — QUAN TRỌNG NHẤT: đếm TÁC DỤNG, không đếm bộ đếm.        //        // uses_left = 0 là ĐÚNG trong cả hai trường hợp (đã vá và chưa vá), nên một        // test chỉ kiểm uses_left sẽ xanh trên code lỗi. Con số nói ra sự thật là số        // dòng discount thật sự đã được ghi.        Assert.Equal(1, await _fx.CountDiscountsAsync("SAVE50"));    }     /// <summary>    /// Mã cho phép 5 lượt: 50 request đồng thời phải cho ra ĐÚNG 5. Test này bắt được    /// một lỗi mà test max_uses=1 ở trên bỏ qua — bản vá đúng cho 1 nhưng sai dấu    /// so sánh (>= thay vì >) vẫn pass ở trên và đỏ ở đây.    /// </summary>    [Fact]    public async Task Multi_use_coupon_never_exceeds_its_limit()    {        await _fx.SeedCouponAsync("SAVE10", maxUses: 5);        var orders = await _fx.SeedOrdersAsync(count: 50);         using var barrier = new Barrier(50);        await Task.WhenAll(orders.Select((o, i) => Task.Run(async () =>        {            await using var scope = _fx.Services.CreateAsyncScope();            var svc = scope.ServiceProvider.GetRequiredService<CouponService>();            barrier.SignalAndWait();            await svc.ApplyAsync(o.Id, "SAVE10", default);        })));         Assert.Equal(5, await _fx.CountDiscountsAsync("SAVE10"));        Assert.Equal(0, await _fx.GetUsesLeftAsync("SAVE10"));    }     /// <summary>    /// Ràng buộc UNIQUE sống trong migration, không trong code — nên một migration    /// khác xoá nó đi mà không test nào đỏ. Test này là thứ làm nó đỏ.    /// </summary>    [Fact]    public async Task Unique_constraint_exists()    {        var found = await _fx.QueryScalarAsync<long>("""            SELECT count(*) FROM pg_indexes             WHERE schemaname = 'core' AND tablename = 'order_discount'               AND indexdef ILIKE '%UNIQUE%coupon_code%'            """);         Assert.True(found > 0, "ràng buộc UNIQUE trên order_discount đã biến mất");    }}
08

Sai lầm thường gặp

"Bản vá"Vì sao không đúng
if (coupon.UsesLeft > 0) { coupon.UsesLeft--; SaveChanges(); }Kiểm-rồi-dùng qua hai round-trip. Đây chính là code lỗi, viết bằng ORM
lock / Monitor / SemaphoreSlim trong C#Chỉ khoá trong MỘT process. Hai pod thì có hai khoá độc lập, và bản vá biến mất lúc scale ra
Transaction nhưng vẫn Read CommittedRead Committed cho phép đúng cửa sổ ở khối 3. Transaction một mình không phải bản vá — cần FOR UPDATE hoặc điều kiện trong UPDATE
ExecuteUpdateAsync mà không đọc số hàngCâu lệnh nguyên tử đúng, rồi bỏ đi câu trả lời của nó. Request thứ hai vẫn nhận 200
Rate limit theo IP20 request trong một packet đến từ một IP. Nó là hành vi hợp lệ theo mọi rate limit hợp lý
Debounce ở clientKẻ tấn công không dùng UI của bạn
Khoá Redis một mìnhRedis mất khoá khi failover. Nó là tối ưu, không phải bảo đảm — ràng buộc DB vẫn phải có
Retry khi Serializable abort — nhưng không có retryChuyển một lỗi nghiệp vụ thành 500 dưới tải cao
Test gọi endpoint hai lần liên tiếpPass trên code lỗi. Cửa sổ không bao giờ mở

Sai lầm về mức độ, phổ biến nhất: "cửa sổ chỉ vài milli-giây, không khai thác được từ Internet". Single-packet attack đã trả lời câu đó dứt điểm năm 2023.

Sai lầm về phạm vi: vá đúng endpoint được báo cáo. Cùng mẫu kiểm-rồi-dùng gần như luôn xuất hiện ở mọi chỗ có một con số phải giảm đi — hoàn tiền, điểm thưởng, giới hạn lượt dùng, số suất còn lại. Cách tìm là grep các bảng có cột dạng *_left, *_remaining, balance, quota.

09

Nguồn tham khảo

Bậc 1Transaction Isolation · PostgreSQL Global Development Group · PostgreSQL documentation · 16
Bậc 1Explicit Locking — SELECT FOR UPDATE · PostgreSQL Global Development Group · PostgreSQL documentation · 16
Bậc 1ExecuteUpdate and ExecuteDelete · Microsoft · EF Core docs · EF Core 8
Bậc 2Race conditions · PortSwigger · Web Security Academy
Bậc 3Analysis of the DAO exploit · Phil Daian
Nằm trong lộ trình
Secure Backend DeveloperXem lộ trình

Bình luận

Tham gia thảo luận
Đăng ký để bình luận

Bình luận cần tài khoản đã hoàn thành ít nhất một bài học. Điều kiện đó là thứ giữ cho luồng thảo luận này còn đáng đọc: mỗi ý kiến gắn với một người có thể bị hỏi lại, và reputation tích luỹ theo thời gian.

Đăng kýĐăng nhập

Bạn vẫn đọc được toàn bộ bình luận dưới đây mà không cần tài khoản. Đăng nhập xong bạn sẽ quay lại đúng chỗ này, không phải đầu trang.

Đang tải bình luận…