Race Condition

Race condition pada aplikasi web: celah antara pemeriksaan dan pemakaian sumber daya.

Daftar isi

Race condition adalah keadaan ketika hasil program bergantung pada urutan antar alur eksekusi yang berlomba, sehingga keluarannya berubah-ubah. Pada aplikasi web, bentuk yang paling sering dijumpai adalah time-of-check to time-of-use (TOCTOU), yaitu jendela waktu antara pemeriksaan sebuah syarat dan pemakaian sumber dayanya yang dapat disisipi permintaan lain. Topik ini memodelkan penarikan ganda pada rekening bersama menggunakan threading Python, memperlihatkan saldo berakhir minus, lalu menutup celahnya dengan kunci eksklusif.

Jendela antara pemeriksaan dan pemakaian#

Pola cek-lalu-lakukan muncul di banyak tempat pada aplikasi web, misalnya memeriksa saldo sebelum mendebit, memeriksa stok sebelum memesan, memeriksa kuota sebelum mengunggah, dan memeriksa kode OTP sebelum menerbitkan token. Setiap pemeriksaan yang diikuti operasi tersendiri membuka jendela TOCTOU. Dua permintaan yang tiba hampir bersamaan dieksekusi berlindasan, keduanya lulus pemeriksaan atas nilai lama, lalu keduanya menjalankan aksinya atas nilai yang sudah tidak berlaku.

Kategori ini masuk A01 Broken Access Control pada OWASP Top 10:2025 karena akibatnya sama dengan kontrol akses yang rusak, yaitu operasi yang seharusnya ditolak tetap dijalankan. Berbeda dengan IDOR, tidak ada pengenal yang diubah dan tidak ada role yang dilanggar, sehingga kegagalannya tidak terlihat pada logika otorisasi mana pun. Celahnya murni pada urutan waktu, dan pemeriksaan manual satu permintaan demi satu permintaan tidak akan menemukannya.

Contoh di luar saldo pun mudah dijumpai. Kupon yang sama dapat digunakan dua kali ketika pemeriksaan status dan penandaan terpakai terpisah, kursi pada pemesanan tiket dapat terjual dua kali ketika pemeriksaan ketersediaan dan penyimpanan pesanan berlindapan, dan batas percobaan OTP dapat terlewati ketika penghitungnya baru ditambah setelah pemeriksaan batas. Semuanya berbagi satu akar yang sama, yaitu pemeriksaan dan aksi yang tidak dijalankan sebagai satu kesatuan.

Simulasi penarikan ganda pada rekening bersama#

Skrip berikut menjalankan dua thread yang masing-masing menarik 80 dari saldo 100 pada rekening yang sama. Versi cacat memeriksa saldo, menjeda 0,05 detik, baru kemudian mendebit, sehingga kedua pemeriksaan terjadi sebelum ada satu pun debit. Urutan antar langkah dikunci menggunakan threading.Event agar keluarannya deterministik, sedangkan jeda merepresentasikan jendela yang pada aplikasi nyata muncul sendiri karena penjadwalan dan latensi jaringan. Simpan berkas berikut sebagai race-condition.py, lalu jalankan dengan perintah python3 race-condition.py.

python race-condition.py
"""Simulasi race condition TOCTOU pada penarikan saldo bersama.

Dua thread menarik 80 dari saldo 100. Versi cacat memeriksa saldo,
menjeda, lalu mendebit, sehingga kedua pemeriksaan lolos sebelum ada
satu pun debit dan saldo berakhir minus. Versi perbaikan memeriksa
dan mendebit dalam satu bagian kritis menggunakan threading.Lock.
"""

import threading
import time

# ---- versi cacat: time-of-check to time-of-use (TOCTOU) ----

cek_pertama_selesai = threading.Event()
cek_kedua_selesai = threading.Event()
debit_pertama_selesai = threading.Event()
log_cacat = []


def tarik_cacat(saldo, nama, uang, tunggu_cek, setel_cek, tunggu_debit, setel_debit):
    if tunggu_cek is not None:
        tunggu_cek.wait()
    if saldo["nilai"] >= uang:
        log_cacat.append(f"[{nama}] cek: saldo {saldo['nilai']} >= {uang}, penarikan disetujui")
    else:
        log_cacat.append(f"[{nama}] cek: saldo {saldo['nilai']} < {uang}, ditolak")
        return
    time.sleep(0.05)  # jendela antara pemeriksaan dan pemakaian
    if setel_cek is not None:
        setel_cek.set()
    if tunggu_debit is not None:
        tunggu_debit.wait()
    saldo["nilai"] -= uang
    log_cacat.append(f"[{nama}] debit {uang}, saldo sekarang {saldo['nilai']}")
    if setel_debit is not None:
        setel_debit.set()


def skenario_cacat():
    saldo = {"nilai": 100}
    t1 = threading.Thread(target=tarik_cacat, args=(
        saldo, "T1", 80, None, cek_pertama_selesai, cek_kedua_selesai, debit_pertama_selesai))
    t2 = threading.Thread(target=tarik_cacat, args=(
        saldo, "T2", 80, cek_pertama_selesai, cek_kedua_selesai, debit_pertama_selesai, None))
    t1.start()
    t2.start()
    t1.join()
    t2.join()
    for baris in log_cacat:
        print(baris)
    print(f"saldo akhir versi cacat: {saldo['nilai']} (seharusnya 20)")


# ---- versi perbaikan: pemeriksaan dan debit dalam satu kunci ----

giliran = threading.Event()
log_aman = []


def tarik_aman(saldo, kunci, nama, uang, tunggu):
    if tunggu is not None:
        tunggu.wait()
    with kunci:
        if saldo["nilai"] >= uang:
            saldo["nilai"] -= uang
            log_aman.append(f"[{nama}] dalam kunci: cek lalu debit, saldo sekarang {saldo['nilai']}")
        else:
            log_aman.append(f"[{nama}] dalam kunci: saldo {saldo['nilai']} < {uang}, penarikan ditolak")
    if tunggu is None:
        giliran.set()


def skenario_aman():
    saldo = {"nilai": 100}
    kunci = threading.Lock()
    t1 = threading.Thread(target=tarik_aman, args=(saldo, kunci, "T1", 80, None))
    t2 = threading.Thread(target=tarik_aman, args=(saldo, kunci, "T2", 80, giliran))
    t1.start()
    t2.start()
    t1.join()
    t2.join()
    for baris in log_aman:
        print(baris)
    print(f"saldo akhir versi perbaikan: {saldo['nilai']}")


print("== Versi cacat: pemeriksaan dan debit terpisah jendela waktu ==")
skenario_cacat()
print()
print("== Versi perbaikan: pemeriksaan dan debit dalam satu kunci ==")
skenario_aman()
text Keluaran
== Versi cacat: pemeriksaan dan debit terpisah jendela waktu ==
[T1] cek: saldo 100 >= 80, penarikan disetujui
[T2] cek: saldo 100 >= 80, penarikan disetujui
[T1] debit 80, saldo sekarang 20
[T2] debit 80, saldo sekarang -60
saldo akhir versi cacat: -60 (seharusnya 20)

== Versi perbaikan: pemeriksaan dan debit dalam satu kunci ==
[T1] dalam kunci: cek lalu debit, saldo sekarang 20
[T2] dalam kunci: saldo 20 < 80, penarikan ditolak
saldo akhir versi perbaikan: 20

Keluaran versi cacat memperlihatkan kedua pemeriksaan lolos atas saldo 100, lalu kedua debit dijalankan dan saldo berakhir minus 60. Tidak ada thread yang menipu karena keduanya mengikuti alur program dengan setia, dan kerusakan lahir dari jarak antara pemeriksaan dan pemakaian. Pada versi perbaikan, pemeriksaan dan debit berada di dalam satu bagian kritis yang sama, sehingga thread kedua menilai saldo setelah thread pertama selesai, dan penarikan kedua ditolak.

Perbaikan: kunci eksklusif dan transaksi atomik#

threading.Lock menutup jendela TOCTOU dengan menuntut setiap thread melewati satu pintu masuk pada bagian kritis, sehingga pemeriksaan dan debit menjadi tak terpisahkan. Kunci semacam ini berlaku pada satu proses, sedangkan aplikasi web produksi umumnya berjalan pada banyak proses dan banyak mesin. Pada level itu, padanannya adalah transaksi atomik pada basis data, misalnya satu pernyataan UPDATE rekening SET saldo = saldo - 80 WHERE id = 1 AND saldo >= 80 yang menyertakan syarat saldo pada klausa WHERE, atau SELECT FOR UPDATE yang mengunci baris hingga transaksinya berakhir.

Kunci satu pernyataan juga membuat operasi menolak debit secara alami ketika syaratnya tidak terpenuhi, tanpa jendela di antara keduanya. Untuk alur pembayaran atau penerbitan token, kunci idempoten pada tingkat permintaan menambah lapisan kedua sehingga permintaan yang dikirim ulang tidak diproses dua kali. Jalankan demo berikut, tekan tombol penarikan dua kanal tanpa kunci lalu dengan kunci, dan bandingkan saldo akhirnya.

Untuk kasus yang tidak dapat dinyatakan sebagai satu pernyataan SQL, transaksi eksplisit mengambil peran yang sama, yaitu mengurung pemeriksaan dan pembaruan sampai selesai sebelum terlihat oleh transaksi lain. Kunci terdistribusi berbasis penyimpanan nilai-kunci dapat menjadi jalan tengah pada operasi yang melintasi banyak basis data, tetapi kunci tersebut menambah komponen yang bisa gagal. Untuk data bernilai seperti saldo, transaksi atomik tetap menjadi pilihan pertama.

Kesalahan umum#

Kesalahan berikut muncul ketika jendela TOCTOU diremehkan.

  • Menganggap interpreter atau basis data mengurutkan operasi antar permintaan. Tanpa kunci atau transaksi, permintaan berlindapan sesuai jadwalnya sendiri.
  • Menguji hanya dengan satu permintaan. Kerentanan ini baru tampak ketika dua permintaan hampir bersamaan, sehingga pengujian perlu mengirim keduanya sekaligus.
  • Meletakkan pemeriksaan pada satu fungsi dan pemakaiannya pada fungsi lain. Jarak semacam ini memperlebar jendela, sehingga keduanya digabung dalam satu bagian kritis.
  • Menggunakan kunci proses tunggal pada aplikasi multi-instance. Kunci lokal tidak dikenal instance lain, sehingga transaksi atomik basis data yang digunakan.

Ringkasan#

  • TOCTOU adalah jendela antara pemeriksaan syarat dan pemakaian sumber daya yang dapat disisipi permintaan lain, dan akibatnya, operasi tak sah tetap dijalankan.
  • threading.Lock menutup jendela pada satu proses, sedangkan transaksi atomik basis data menutupnya lintas proses.
  • Pemeriksaan dan pemakaian sumber daya harus menjadi satu bagian kritis yang tak terpisahkan.

Referensi#