Race condition dan TOCTOU (CWE-362, CWE-367): bagaimana request paralel menembus pemeriksaan dan cara memperbaikinya
Kode yang membaca sebuah nilai, memeriksanya, lalu menulis kembali membuat dua request yang tiba pada saat bersamaan sama-sama lolos pemeriksaan, sehingga kartu hadiah ditukar dua kali atau saldo menjadi negatif. Bagaimana jendela antara memeriksa dan bertindak terjadi, mengapa pemeriksaan di aplikasi tidak bisa menutupnya, dan bagaimana satu UPDATE ... RETURNING bersyarat, kunci baris, dan constraint database membuat operasi menjadi atomik, dengan contoh Python dan PostgreSQL.
Penjelasan Sederhana (ELI5)
Sebuah bioskop punya dua kasir dan hanya satu kursi kosong di baris F. Keduanya melihat denah kursi pada detik yang sama, sama-sama melihat F7 kosong, dan sama-sama menjualnya. Setiap kasir sudah memeriksa sebelum menjual; masalahnya, memeriksa dan menjual adalah dua momen terpisah, dan kasir lain bertindak di antaranya. Perbaikannya adalah satu bundel tiket yang harus disobek keduanya: siapa pun yang menyobek tiket F7 terakhir mendapatkannya, dan yang lain mendapati bundelnya kosong. Memeriksa dan mengambil menjadi satu tindakan.
Konsep Kunci & Istilah
- TOCTOU (dari saat pemeriksaan ke saat penggunaan)
- Keadaan yang sudah diperiksa bisa berubah sebelum dipakai. Dalam kode web, pemeriksaannya biasanya berupa
SELECTdan penggunaannyaUPDATEsesudahnya, dengan request lain berjalan di antaranya. - Jendela race dan request paralel
- Jendelanya sering hanya beberapa milidetik, tetapi penyerang bisa menyusun puluhan request agar tiba bersamaan. Riset PortSwigger tahun 2023 menunjukkan bahwa mengirim byte terakhir dari banyak request HTTP/2 dalam satu paket membuat semuanya tiba dalam selisih sekitar satu milidetik.
- Update bersyarat yang atomik
UPDATE ... WHERE code = %s AND NOT redeemed RETURNING ...memeriksa dan mengubah baris dalam satu pernyataan. Hanya satu pemanggil yang mendapat baris kembali; yang lain tidak mendapat apa pun.- Kunci baris pada Read Committed
- Di PostgreSQL,
UPDATEkedua pada baris yang sama menunggu transaksi pertama, lalu mengevaluasi ulang klausaWHERE-nya terhadap baris yang sudah di-commit. Pemeriksaan ulang inilah yang membuat update bersyarat aman tanpa menaikkan tingkat isolasi. - Constraint dan kunci idempotensi
- Indeks
UNIQUEdanCHECK (balance_cents >= 0)membuat race yang kalah gagal dengan jelas alih-alih memakai saldo dua kali.Idempotency-Keyyang disimpan dengan indeks unik membuat klien bisa mengulang pembayaran tanpa menjalankannya dua kali.
Alur Serangan Langkah demi Langkah
Penyerang memegang satu kartu hadiah yang sah
Ia punya satu kartu bernilai tetap dan sebuah akun untuk menerima saldonya.
Ia mengirim banyak penukaran sekaligus
Dua puluh request dengan kode yang sama tiba dalam satu milidetik. Dua request paralel dengan kode uji TEST-0001 menggambarkan kasus ini.
Setiap request lolos pemeriksaan
Setiap SELECT berjalan sebelum ada request yang menandai kartu sebagai sudah ditukar, sehingga semuanya melihat redeemed = false.
Saldo dikreditkan berkali-kali
Setiap request menambahkan nilai kartu sebelum penanda ditulis. Pada 2015 Egor Homakov menunjukkan bahwa transfer paralel antar kartu hadiah Starbucks bisa menciptakan saldo dari ketiadaan.
Kode Sumber: Rentan vs Aman
# gift_cards.py: pemeriksaan dan penggunaan adalah pernyataan terpisah
from .db import pool
# setiap request paralel bisa melihat redeemed = false sebelum ada yang menulis
def redeem_gift_card(user_id: int, code: str) -> int:
with pool.connection() as conn:
card = conn.execute("SELECT id, balance_cents, redeemed FROM gift_cards WHERE code = %s", (code,)).fetchone()
if card is None or card[2]:
raise ValueError("invalid or used card")
conn.execute("UPDATE accounts SET balance_cents = balance_cents + %s WHERE user_id = %s", (card[1], user_id))
conn.execute("UPDATE gift_cards SET redeemed = true WHERE id = %s", (card[0],))
return card[1]
# gift_cards.py: pemeriksaan dan penggunaan dalam satu pernyataan
from .db import pool
# hanya satu pemanggil yang mendapat baris; sisanya menunggu kunci lalu gagal di NOT redeemed
def redeem_gift_card(user_id: int, code: str) -> int:
with pool.connection() as conn, conn.transaction():
card = conn.execute(
"UPDATE gift_cards SET redeemed = true, redeemed_by = %s, redeemed_at = now() "
"WHERE code = %s AND NOT redeemed RETURNING balance_cents",
(user_id, code),
).fetchone()
if card is None:
raise ValueError("invalid or used card")
# kredit dalam transaksi yang sama, jadi kedua perubahan di-commit bersama atau tidak sama sekali
conn.execute("UPDATE accounts SET balance_cents = balance_cents + %s WHERE user_id = %s", (card[0], user_id))
return card[0]
Daftar Periksa Penguatan Sistem Rekayasa
- Ganti urutan baca-periksa-tulis pada uang, stok, kupon, dan kuota dengan satu
UPDATE ... WHERE ... RETURNINGbersyarat, atau denganSELECT ... FOR UPDATEdi dalam transaksi. - Dukung tindakan sekali pakai dengan constraint database (
UNIQUE,CHECK (balance_cents >= 0)) agar race yang kalah gagal alih-alih berhasil dua kali. - Terima
Idempotency-Keydi endpoint pembayaran dan penukaran, dan simpan dengan indeks unik. - Jangan mengandalkan kunci atau flag di dalam proses ketika lebih dari satu worker atau server menangani request.
- Tambahkan tes konkurensi yang mengirim 20 penukaran paralel untuk satu kartu uji dan memastikan tepat satu yang berhasil.