SQL injection adalah kerentanan yang terdengar abstrak sampai Anda benar-benar melihatnya bekerja. Bukan sekadar nama CWE di daftar temuan, tapi momen ketika satu tanda kutip tunggal membuat seluruh tabel akun terbuka. Artikel ini membedah SQL injection lewat satu temuan nyata: F2 pada aplikasi LabKeu, di mana sebuah endpoint login gagal total — bukan karena kode yang rumit, tapi karena satu baris query yang dirakit dengan konkattenasi string.
- Definisi: input pengguna masuk ke query SQL tanpa dipisah dari sintaksnya, sehingga penyerang ikut menentukan bentuk query (CWE-89).
- Vektor: endpoint POST /login-noportal merakit query dengan operator +, bukan placeholder.
- Auth bypass: satu payload OR membatalkan seluruh cek password — hasilnya 302 → /dashboard tanpa kredensial valid.
- Ekstraksi data: UNION SELECT membaca isi kolom password plaintext dan menampilkannya di halaman dashboard.
- Fix: prepared statement dengan placeholder ? — terbukti di endpoint kembar yang aman.
🎯 Apa Itu SQL Injection (CWE-89)?
Bayangkan Anda menulis surat dalam bahasa Indonesia, tapi memotong kata-kata dari surat orang lain di tengah kalimat. Kalimat Anda tetap sah secara tata bahasa, tapi maksudnya sudah berubah. SQL injection bekerja persis begitu: input pengguna masuk ke dalam query dan ikut menentukan strukturnya.
Secara teknis, SQL injection terjadi ketika nilai dari input pengguna disisipkan langsung ke string query tanpa dipisahkan dari sintaks SQL. Akibatnya, input yang seharusnya diperlakukan sebagai data dapat diperlakukan sebagai instruksi — dan database tidak bisa membedakan keduanya.
OWASP mengklasifikasikan ini sebagai CWE-89, dan dampaknya bisa naik dari sekadar “baca data” hingga kendali penuh atas database, tergantung pada hak akses akun database yang dipakai aplikasi. Pada kasus LabKeu, dampaknya persis di batas itu.
Kondisi WHERE dipalsukan agar selalu benar. Login lolos tanpa password.
Baris tabel lain disisipkan ke hasil query dan ditampilkan ke pengguna.
Jika endpoint menerima write, DROP dan UPDATE bisa dieksekusi lewat jalur yang sama.
Perhatikan bahwa ketiga risiko itu tidak butuh kode yang rumit. Semuanya berasal dari satu keputusan: bagaimana input masuk ke query.
🧪 Konteks: LabKeu dan Ruang Lingkup Temuan
Studi kasus ini diambil dari pentest terotorisasi terhadap LabKeu — aplikasi web replika yang sengaja dibangun rapuh untuk latihan, berjalan di 192.168.1.70 pada jaringan VirtualBox. Lab ini sengaja dirancang dengan kelas kerentanan yang beragam; artikel ini sengaja hanya membahas satu, agar fokus.
| Aspek | Nilai |
|---|---|
| Kelas kerentanan | CWE-89 — Improper Neutralization of Special Elements in SQL Command |
| Endpoint | POST /login-noportal |
| Lokasi di source | routes/auth.js:35-40 |
| Skor | CVSS 9.8 — AV:N / AC:L / PR:N / UI:N / S:U / C:H / I:H / A:H |
| Bukti tersimpan | evidence-1.70/web/sqli-bypass.html, sqli-leak-dashboard.html, sqli-control-login.html |
Skor 9.8 bukan angka kosmetik. Vektor PR:N berarti tidak butuh akun apa pun untuk memulai, dan C:H / I:H berarti data bisa dibaca sekaligus dimodifikasi. Endpoint yang gagal menjaga batas antara data dan kode adalah akar masalahnya.
🔬 Akar Masalah: Dua Baris yang Membedakan Aman dan Bahaya
Aplikasi ini punya dua endpoint login yang hampir identik. Satu merakit query dengan menyambung string, satu lagi menggunakan parameter. Membandingkan keduanya membuat penyebabnya tidak perlu ditebak — keduanya ada di source yang sama, berdampingan.
Versi rentan — konkattenasi string
// routes/auth.js:35-40 — POST /login-noportal
const sql = "SELECT * FROM users WHERE username = '" + username + "' AND password = '" + password + "' AND account_type = 'perusahaan'";
const [rows] = await db.query(sql);Perhatikan tanda kutip tunggal yang mengapit username dan password.kwijit Trap-nya ada di sana: input yang mengandung ‘ bisa menutup kutip tersebut lebih awal, dan seluruh teks berikutnya diperlakukan sebagai sintaks SQL, bukan sebagai nilai.
Versi aman — parameterized query
// POST /login — prepared statement
const [rows] = await db.query(
'SELECT * FROM users WHERE username = ? AND password = ? AND account_type = ?',
[username, password, 'perusahaan']
);Versi kedua mengirim query dan data secara terpisah. Database menerima ? sebagai placeholder, lalu mengisi setiap ? dengan nilai literal yang tidak pernah di-parse sebagai SQL. Kutip di dalam input tetap kutip — dia jadi data, bukan sintaks.
💥 Bukti 1: Auth Bypass Tanpa Password
Payload pertama tidak bothered mencoba menebak kredensial. Ia membuat kondisi WHERE selalu bernilai benar.
username = admin' OR '1'='1' --
password = apa sajaSetelah konkattenasi, query yang benar-benar dieksekusi database menjadi:
SELECT * FROM users WHERE username = 'admin' OR '1'='1' -- ' AND password = 'apa saja' AND account_type = 'perusahaan'Dipecah menjadi tiga bagian:
- ‘admin’ — kutip penutup yang ditulis payload menutup nilai username lebih awal.
- OR ‘1’=’1′ — kondisi yang selalu bernilai benar, jadi seluruh baris lolos.
- — — komentar baris di MySQL. Semua setelah spasi ini diabaikan, termasuk cek password dan account_type.
Hasilnya benar-benar cuplikan, bukan asumsi:
Found. Redirecting to /dashboard💥 Bukti 2: Membaca Password Plaintext dari Tabel users
Auth bypass baru membuka pintu. Bukti kedua menunjukkan apa yang terjadi setelah pintu terbuka: kolom password bisa dibaca langsung dari database.
Konsepnya sederhana — UNION SELECT menggabungkan hasil query aplikasi dengan hasil query buatan penyerang. Karena hasil SELECT * dari users dirender di dashboard, kolom yang kita slip-in pun ikut tampil.
username = ' UNION SELECT 111,username,password,'perusahaan',CONCAT('PW:[',password,']'),NULL FROM users WHERE username='user_b' --
password = apa sajaMembaca payload ini
- ‘ — menutup nilai username.
- UNION SELECT — menggabungkan hasil kedua query.
- 111 — nilai untuk kolom id.
- username, password — membaca dua kolom dari tabel users.
- ‘perusahaan’ — nilai untuk kolom account_type, harus cocok dengan filter endpoint.
- CONCAT(‘PW:[‘,password,’]’) — membungkus nilai asli agar mudah dikenali di layar.
- NULL — nilai untuk kolom perusahaan_id, tidak dipakai untuk tampilan.
- WHERE username=’user_b’ — memilih satu baris spesifik agar bukti mudah dibaca.
- — — mengakhiri sisa query asli.
Respons dashboard yang tersimpan menunjukkan kebocoran itu nyata, bukan inferensi:
<h2>Dashboard</h2>
<p>Masuk sebagai: PW:[password123] (perusahaan)</p>Nilai PW:[password123] diambil langsung dari kolom password pada tabel users. Ini bukan hash — password tersimpan plaintext. Satu SQLi dengan demikian membocorkan seluruh kredensial yang bisa diakses akun database aplikasi, sekaligus memberi tahu penyerang password mana yang bisa dipakai ulang di layanan lain.
🧪 Bukti 3: Uji Kontrol di Endpoint yang Sama
Payload yang sama dikirim ke POST /login — endpoint kembar yang memakai parameterized query:
Username atau password salah.| Endpoint | Implementasi | Hasil |
|---|---|---|
| POST /login-noportal | Konkattenasi string | 302 → /dashboard, dashboard menampilkan PW:[password123] |
| POST /login | Prepared statement | 200, pesan “Username atau password salah.” |
Uji kontrol ini yang membuat temuan menjadi bukti, bukan dugaan. Input identik, dua implementasi berbeda, dua hasil berbeda — penyebabnya terisolasi ke satu hal: cara parameter dikirim. Di laporan yang sama, payload yang sama pada /login-noportal juga bisa memunculkan pesan Query error: Host ‘172.18.0.2’ is not allowed to connect to this MySQL, menandakan aplikasi juga menumpahkan pesan error database mentah ke klien.
📊 Dampak dan Rantai Serangan
Login tanpa kredensial valid, tanpa akun, tanpa percobaan tebak.
Isi seluruh tabel users, termasuk password plaintext.
Kredensial bocor dipakai masuk ke area lain milik akun yang sama.
Satu temuan tidak berdiri sendiri. Dalam aplikasi yang sama, kredensial yang bocor lewat SQLi menjadi kunci untuk modul lain yang punya masalah berbeda — setiap satu kerentanan memperbesar jangkauan kerentanan berikutnya. Rantai yang terverifikasi di lab ini:
Tanpa kredensial
→ SQLi auth bypass (F2)
→ baca users.password (plaintext)
→ login sebagai akun mana pun
→ akses data milik entitas lain
→ muat konten pada origin aplikasi
→ eksekusi skrip di sisi klienTitik berangkat dari kerentanan paling dangkal — input yang tidak dipisah dari sintaks — menuju kendali penuh atas sesi orang lain. Semua langkah berikutnya punya akar masalah yang sama.
🛡️ Remediasi: Bukan Celah, Tapi Konfigurasi
Kabar baiknya: kerentanan ini tidak butuh arsitektur ulang. Prioritas P0 di laporan pentest ini hanya satu hal — mengganti konkattenasi dengan prepared statement — dengan perkiraan usaha satu jam.
- Ganti seluruh query yang merakit string — pakai prepared statement di setiap titik yang mengambil input dari pengguna, tanpa kecuali.
- Terapkan least-privilege pada user database — akun aplikasi hanya butuh SELECT pada tabel yang benar-benar diakses, bukan ALL PRIVILEGES. Di LabKeu, user database dibuat dengan wildcard host %; untuk produksi, batasi ke IP kontainer aplikasi atau jaringan tertentu.
- Berhenti merender pesan error database — log detail di sisi server, kirim pesan generik ke klien. Pesan Host … is not allowed adalah peta internal gratis untuk penyerang.
- Hash password, jangan simpan plaintext — gunakan bcrypt atau argon2. SQLi yang bocor isi tabel tidak lagi berarti kompromi password.
- Audit titik masuk yang belum tercakup — setiap endpoint yang menerima filter, sorting, atau pencarian adalah kandidat SQLi berikutnya.
| Tindakan | Effort | Dampak |
|---|---|---|
| Prepared statement di /login-noportal | ± 1 jam | Menutup bypass dan ekstraksi data |
| Scope user database ke host aplikasi | ± 15 menit | Memblokir akses lateral antar host |
| Pesan error generik | ± 30 menit | Menghentikan kebocoran struktur DB |
🔁 Pencegahan Sistemik dan Checklist Review
Memperbaiki satu endpoint tidak mencegah yang berikutnya. Yang perlu diubah adalah aturan yang membuat vuln ini sulit terjadi sama sekali.
- Bawa ORM atau query builder yang selalu parameterized — kalau seluruh akses data lewat layer itu, konkattenasi tidak pernah ditulis. Di LabKeu, jalur /login justru sudah aman persis karena itu.
- Larang query builder mentah di code review — sediakan helper yang hanya menerima query parameterized, sehingga opsi tidak aman tidak tersedia sejak awal.
- Anggap WAF sebagai lapisan tambahan, bukan pengganti — WAF bisa dilewati lewat encoding dan variasi payload, dan tidak bertahan saat Anda menambah endpoint baru.
- Uji tiap endpoint yang punya input — login, signup, filter, sorting, pencarian, ekspor. Kandidat SQLi ada di mana pun nilai dinamis masuk ke query.
- Cari pola, bukan hanya satu file di code review: pemakaian + atau query() dengan string yang menyatu adalah smell yang konsisten.
⚙️ Alur Validasi Manual yang Diterapkan
Kasus ini dibuktikan tidak dengan satu payload, tapi dengan lima langkah berurutan. Urutan inilah yang membedakan temuan yang bisa ditindaklanjuti dari temuan yang hanya berupa dugaan.
- Petakan input —DAFTARKAN setiap parameter yang masuk ke query, dan perhatikan apakah nilainya disisipkan lewat string atau placeholder.
- Uji tanda kutip tunggal — masukkan satu ‘ di awal. Perubahan perilaku atau pesan error baru adalah sinyal pertama.
- Konfirmasi logika, bukan hanya error — payload OR ‘1’=’1′ membuktikan kondisi bisa dikendalikan, bukan sekadar error acak.
- Demonstrasikan non-destruktif — UNION SELECT untuk membaca, tanpa INSERT, UPDATE, atau DROP. Bukti cukup dengan satu baris data yang terbaca.
- Jalankan uji kontrol — kirim payload identik ke jalur yang aman. Hasil berbeda mengisolasi penyebab secara definitif.
🎯 Kesimpulan
SQL injection bukan masalah yang mysterious. Ia muncul ketika input pengguna diperlakukan sebagai bagian dari sintaks SQL, dan hilang ketika input itu dipisahkan secara tegas. Tidak ada algoritma eksotis, tidak ada konfigurasi yang sophisticated — hanya satu keputusan di dalam satu baris query.
Yang membuat temuan F2 ini tajam adalah pengujiannya yang langsung: endpoint yang sama, payload yang sama, dua implementasi berbeda. Hasilnya bukan “kemungkinan ada SQLi” melainkan “SQLi terbukti ada di baris ini, dan aman di baris itu”. Perbedaan faktor ini yang membuat perbaikan bisa ditunjuk dengan presisi — dan yang membuat laporan pentest bisa dipercaya.
- Gejalanya: satu tanda kutip dapat mengubah perilaku halaman — dari pesan error menjadi login berhasil.
- Bukti terkuat: payload identik pada endpoint rentan dan endpoint aman.
- Perbaikannya: prepared statement dengan placeholder, plus least-privilege untuk user database.
- Konfirmasi dengan uji kontrol: payload identik pada endpoint aman memastikan penyebabnya, bukan kebetulan.
❓ FAQ: SQL Injection
Apa bedanya SQL injection dan XSS?
Keduanya adalah injeksi, tapi targetnya berbeda. SQL injection menyisipkan kode ke query database, sehingga browsing data yang tidak seharusnya. XSS menyisipkan skrip ke halaman yang dirender di browser, sehingga penyerang memakai browser korban — atau, seperti pada temuan lain di lab yang sama, skrip bisa membaca cookie sesi karena penanda httpOnly dimatikan.
Apakah escaping tanda kutip sudah cukup?
Tidak. Escaping bergantung pada konteks — tipe data, charset, aturan escaping database, dan cara input masuk. Ia bisa berhasil hari ini dan gagal setelah ada refactor kecil. Parameterisasi bekerja di level yang benar: kode dan data dipisahkan, sehingga input tidak pernah jadi sintaks dalam keadaan apa pun.
Kenapa prepared statement dianggap wajib, bukan sekadar bagus?
Karena ia menutup kelas kerentanan ini secara menyeluruh, bukan per kasus. Tidak ada pertanyaan “apakah input ini sudah di-escape dengan benar” yang harus dijawab setiap kali query ditulis. Selama developer menulis query dengan placeholder, kerentanan ini mustahil muncul di sana — dan itulah bedanya dengan perbaikan yang bergantung pada disiplin di setiap baris kode.
UNION SELECT atau error-based, mana yang dipakai di laporan?
Di laporan ini UNION SELECT dipilih karena yang diuji adalah endpoint read (login) pada server yang menampilkan pesan error mentah. UNION SELECT mengembalikan data secara langsung dan terlihat jelas di respons — bukti yang mudah diverifikasi pembaca. Error-based hanya berguna saat pesan error tidak pernah sampai ke klien, karena data keluar lewat pesan itu sendiri.
Apakah WAF sudah cukup?
WAF berguna sebagai lapisan tambahan — ia menyaring pola umum dan memberi peringatan. Tapi ia tidak menyelesaikan akar masalah: pola yang lolos bisa berbeda, dan setiap endpoint baru Potential ada celah yang belum pernah dilihat aturan WAPNYA. Perbaikan yang bertahan hanyalah di kode: prepared statement, least-privilege, dan error handling yang benar.
Apakah memakai ORM membuat aplikasi 100% aman dari SQLi?
ORM membuat penulisan query parameterized menjadi jalur default, sehingga risiko salah konfigurasi turun drastis. Tapi ORM juga menyediakan jalur mentah — raw query, fragment, dan fungsi-fungsi khusus — yang kembali ke konkattenasi string bila dipakai. Di LabKeu, jalur /login justru memakai parameterized query, sementara /login-noportal tidak; ORM tidak ada di sana, dan konsistensi yang tidak terjaga itulah masalahnya.
Dibuat oleh:
