Eksploitasi sistem matchmaking sering terjadi akibat celah konkurensi (race condition) antara client dan backend worker. Masalah klasik yang pernah mencuat pada game kompetitif seperti seri FIFA adalah manipulasi sinyal pembatalan (cancel) atau disconnect saat server sedang memasangkan pemain. Ketika client mengirim request cancel tepat di milidetik yang sama saat matchmaker menetapkan lawan, sistem tanpa kontrol konkurensi ketat dapat memicu ghost match, pembatalan sepihak tanpa penalti, atau tiket yang tertinggal dalam status tidak konsisten.

Anatomi Celah: Race Condition Cancel vs Match Found

Pola serangan manipulasi antrian umumnya memanfaatkan asinkronisitas jaringan:

  1. Pemain A masuk antrian pencarian (status: QUEUED).
  2. Matchmaker backend menemukan pasangan untuk Pemain A dan Pemain B.
  3. Sebelum worker matchmaker mengunci tiket dan mengubah status menjadi MATCHED, Pemain A secara sengaja mengirim perintah pembatalan antrian (HTTP POST /matchmaking/cancel) atau memutus soket TCP.
  4. Jika endpoint pembatalan hanya membaca status dari cache lokal atau query DB non-transaksional, server memvalidasi tiket masih QUEUED lalu mengembalikan status sukses pembatalan.
  5. Worker matchmaker menyelesaikan tugasnya dan menganggap Pemain A siap bertanding, sementara client Pemain A telah menutup koneksi. Pemain B terjebak dalam room tanpa lawan (ghost match).

Solusi untuk masalah ini adalah menjamin linearizability: transisi status tiket harus bersifat deterministik, terurut, dan atomik.

State Machine Tiket Matchmaking

Sistem antrian membutuhkan finite state machine (FSM) yang kaku. Transisi status yang diizinkan:

  • QUEUED → MATCHED (hanya diizinkan oleh Matchmaker Worker)
  • QUEUED → CANCELLED (diizinkan oleh Client request)
  • MATCHED → CONFIRMED (kedua client mengirim heartbeat/readiness)
  • MATCHED → EXPIRED (salah satu client gagal confirm dalam batas TTL)

Kunci pengamanan: client dilarang membatalkan tiket jika status sudah berpindah ke MATCHED. Begitu pula worker tidak boleh memasangkan tiket yang sudah berstatus CANCELLED.

Atomic State Transition dengan Redis Lua Script

Pendekatan multi-step (GET tiket → validasi di aplikasi → SET tiket) di Redis rentan terhadap interleaving. Evaluasi kondisi dan mutasi state wajib dilakukan dalam satu atomic step menggunakan Lua script.

Simpan data tiket menggunakan Redis Hash dengan key ticket:{ticket_id} yang memiliki field status, player_id, dan idempotency_token.

-- KEYS[1]: Nama key tiket (contoh: ticket:abc-123)
-- ARGV[1]: Expected current status (contoh: QUEUED)
-- ARGV[2]: Target new status (contoh: MATCHED)
-- ARGV[3]: Idempotency token baru dari worker atau client

local current_status = redis.call('HGET', KEYS[1], 'status')

if not current_status then
    return redis.error_reply('ERR_TICKET_NOT_FOUND')
end

if current_status ~= ARGV[1] then
    -- Mengembalikan status terkini saat ini jika transisi gagal
    return { 'REJECTED', current_status }
end

redis.call('HSET', KEYS[1], 'status', ARGV[2], 'idempotency_token', ARGV[3])
return { 'OK', ARGV[2] }

Script di atas menjamin worker matchmaker dan request cancel client saling mengunci secara implisit melalui single-threaded execution Redis. Jika cancel request tiba lebih dulu, status berubah menjadi CANCELLED; saat worker mencoba merubahnya ke MATCHED, Redis mengembalikan {'REJECTED', 'CANCELLED'} sehingga worker dapat mencari kandidat lain tanpa membuat match invalid.

Distributed Locking untuk Matchmaking Worker

Ketika matchmaking worker memproses sekelompok tiket secara batch, worker harus memegang kunci eksklusif atas ID pemain yang sedang diproses. Gunakan perintah standar Redis SET NX PX untuk mengunci resource:

// Pseudocode Worker Go/Node.js
func LockPlayer(redisClient, playerID, requestID string, ttlMs int) bool {
    key := fmt.Sprintf("lock:player:%s", playerID)
    // PX: milidetik expiry, NX: hanya set jika key belum ada
    ok, err := redisClient.SetNX(ctx, key, requestID, ttlMs).Result()
    return err == nil && ok
}

Pelepasan distributed lock juga wajib menggunakan Lua script untuk memastikan worker hanya menghapus lock miliknya sendiri (mencegah release lock yang sudah expired dan diambil alih worker lain):

-- KEYS[1]: Lock key (lock:player:12345)
-- ARGV[1]: Request ID / Owner token unik worker
if redis.call('GET', KEYS[1]) == ARGV[1] then
    return redis.call('DEL', KEYS[1])
else
    return 0
end

Handling Timeout dan Ghost Match

Celah exploit disconnect sering terjadi saat pemain mematikan game tepat saat match dibuat untuk menghindari lawan yang berat. Terapkan strategi berikut:

  1. Two-Phase Confirmation: Saat status berubah menjadi MATCHED, room server tidak langsung memulai game. Berikan TTL 10-15 detik pada Redis key match. Klien kedua belah pihak wajib mengirim packet ACK (Confirm).
  2. Cleanup Worker: Jika salah satu klien gagal mengirimkan konfirmasi sebelum TTL habis, transisikan state ke EXPIRED. Pemain yang gagal confirm diberikan cooldown penalty (penalti antrian), sedangkan pemain yang lolos confirm dikembalikan ke antrian terdepan dengan prioritas tinggi.
Catatan Keamanan: Jangan percaya paket disconnect dari client. Setiap pembatalan wajib diverifikasi melalui server-side state machine.