Aplikasi yang merakit SQL dengan menggabungkan string input pengguna sedang mencampurkan data dengan kode. Penyerang yang paham struktur query bisa mengetikkan input yang mengubah makna query itu sendiri — inilah serangan SQL injection, salah satu kelas celah keamanan paling tua dan paling sering muncul di aplikasi web. Chapter ini membedah serangannya pada contoh login sederhana, lalu menutup celahnya dengan satu perubahan struktural: query berparameter.

Anatomi Celah: Query yang Dirakit String #

Kita mulai dengan tabel pengguna pada toko.db:

sql
CREATE TABLE pengguna (
  id INTEGER PRIMARY KEY,
  nama TEXT NOT NULL,
  kata_sandi TEXT NOT NULL
);

INSERT INTO pengguna VALUES
  (1, 'admin', 'sandi-admin-kuat'),
  (2, 'budi', 'kopi-2025'),
  (3, 'siti', 'sate-2025');

Aplikasi login yang rawan biasanya membangun query seperti ini (ilustrasi Python):

python
sql = ("SELECT * FROM pengguna WHERE nama = '" + nama_input +
       "' AND kata_sandi = '" + kata_sandi_input + "'")

Dengan input yang wajar, query tersebut bekerja seperti diharapkan:

bash
sqlite3 toko.db "SELECT * FROM pengguna WHERE nama = 'budi' AND kata_sandi = 'kopi-2025';"

Output yang Dihasilkan:

text
2|budi|kopi-2025

Kata sandi yang salah menghasilkan baris kosong. Masalahnya: input pengguna tidak pernah diperiksa, jadi karakter kutip dan tanda komentar ikut tergabung menjadi bagian dari SQL.

Payload Penyerang di Dunia Nyata #

Bayangkan seorang penyerang mengetikkan nilai berikut pada form login di aplikasi toko fiktif kita:

Kolom isianNilai yang diketik penyerang
nama' OR '1'='1' --
kata sandiapa-saja

Konkatenasi pada kode aplikasi menghasilkan query rakitan berikut:

sql
SELECT * FROM pengguna WHERE nama = '' OR '1'='1'; --' AND kata_sandi = 'apa-saja'

Tanda titik koma sengaja diletakkan sebelum komentar --: bagian sesudah dua garis itu adalah sisa query asli yang dinonaktifkan penyerang. Query ini bisa kita jalankan langsung untuk melihat dampaknya:

bash
sqlite3 toko.db "SELECT * FROM pengguna WHERE nama = '' OR '1'='1' --' AND kata_sandi = 'apa-saja'"

Output yang Dihasilkan:

text
1|admin|sandi-admin-kuat
2|budi|kopi-2025
3|siti|sate-2025

Pemeriksaan kata sandi berhasil dilewati dan baris admin ikut tampil. Mari kita bedah bagian demi bagian payload tersebut:

  1. Tanda kutip pembuka ('): karakter ini menutup string nama = '...' yang dibuka oleh kode aplikasi, sehingga sisa ketikan penyerang tidak lagi berada di dalam string.
  2. OR '1'='1': kondisi ini selalu bernilai benar, sehingga klausa WHERE lolos untuk setiap baris.
  3. --: dua tanda hubung mengubah sisa baris menjadi komentar SQL — pemeriksaan kata_sandi tidak pernah dievaluasi oleh engine SQLite.

Varian lain menargetkan akun tertentu. Payload admin' -- pada kolom nama menghasilkan nama = 'admin' --' AND kata_sandi = '...', sehingga login selalu berhasil sebagai admin:

bash
sqlite3 toko.db "SELECT * FROM pengguna WHERE nama = 'admin' --' AND kata_sandi = 'apa-saja'"

Output yang Dihasilkan:

text
1|admin|sandi-admin-kuat

Celah yang sama berlaku pada konteks angka. Kode yang merakit "WHERE id = " + id_input tanpa tanda kutip bisa dibajaki dengan input 1 OR 1:

bash
sqlite3 toko.db "SELECT id, nama FROM pengguna WHERE id = 1 OR 1;"

Output yang Dihasilkan:

text
1|admin
2|budi
3|siti

Solusi: Query Berparameter #

Query berparameter memisahkan kode dari data secara struktural. Teks SQL dikirim ke engine SQLite sebagai templat dengan tanda tempat — tanda tanya ? atau nama seperti :nama — lalu nilai terikat terpisah sebagai binding. Engine SQLite tidak pernah mengurai ulang nilai terikat sebagai SQL, sehingga kutip, komentar, atau OR 1=1 pada input hanya menjadi teks biasa.

Di sqlite3 CLI, binding dikelola oleh perintah .parameter:

text
sqlite> .parameter init
sqlite> .parameter set :nama "' OR '1'='1' --"
sqlite> .parameter set :kata_sandi apa-saja
sqlite> .parameter list
:kata_sandi 'apa-saja'
:nama       ''' OR ''1''=''1'' --'
sqlite> SELECT COUNT(*) AS cocok FROM pengguna WHERE nama = :nama AND kata_sandi = :kata_sandi;
0

Bagaimana payload yang sama berperilaku di kedua dunia? Program berikut membandingkan keduanya:

python
import sqlite3

koneksi = sqlite3.connect("toko.db")
kursor = koneksi.cursor()

nama_input = "' OR '1'='1' --"
kata_sandi_input = "apa-saja"

# (1) rawan: SQL dirakit dengan konkatenasi string
sql_rawan = ("SELECT id, nama FROM pengguna WHERE nama = '"
             + nama_input + "' AND kata_sandi = '" + kata_sandi_input + "'")
print("SQL rakitan :", sql_rawan)
kursor.execute(sql_rawan)
print("baris lolos :", kursor.fetchall())

# (2) aman: nilai terikat sebagai parameter, bukan digabung ke teks SQL
kursor.execute(
    "SELECT id, nama FROM pengguna WHERE nama = ? AND kata_sandi = ?",
    (nama_input, kata_sandi_input),
)
print("baris lolos :", kursor.fetchall())

koneksi.close()

Output yang Dihasilkan:

text
SQL rakitan : SELECT id, nama FROM pengguna WHERE nama = '' OR '1'='1' --' AND kata_sandi = 'apa-saja'
baris lolos : [(1, 'admin'), (2, 'budi'), (3, 'siti')]
baris lolos : []

Tiga bagian penting pada program di atas:

  1. Baris sql_rawan = ...: operator + menggabungkan input langsung ke teks SQL — payload penyerang menjadi kode yang dieksekusi engine SQLite.
  2. Baris kursor.execute(... ? ...): tanda tanya pada teks SQL adalah templat; tuple (nama_input, kata_sandi_input) berisi nilai yang diikat per posisi.
  3. Baris print(...) terakhir: dengan parameter, query hanya mencocokkan teks literal ' OR '1'='1' -- dengan kolom nama — tidak ada baris yang cocok, sehingga hasilnya kosong.

Pola execute dengan tuple ini dibahas tuntas pada Chapter Query Berparameter di section SQLite dengan Python.

Mengapa Sekadar Meng-escape Bukan Solusi #

Menggandakan tanda kutip (' menjadi '') memang menetralkan contoh di atas. Pendekatan ini tetap buruk sebagai kebijakan:

  • Satu titik lupa, semua jebol: escape harus diterapkan di setiap call site query; satu saja terlewat, celah terbuka kembali.
  • Tidak menyentuh wildcard LIKE: karakter % dan _ pada input lolos dari escape dan tetap bisa memperluas hasil pencarian.
  • Aturan berbeda antar database: MySQL mengenal escape backslash, SQLite tidak — kode yang dipindahkan antar database berubah tingkat keamanannya.
  • Konteks angka tidak bisa dikutip: kolom numerik dirakit tanpa tanda kutip, seperti contoh id = 1 OR 1 di atas.

Parameterisasi adalah solusi struktural: input tidak pernah menyentuh teks SQL, jadi tidak ada aturan escape yang perlu diingat.

Wildcard LIKE sebagai Celah Kecil #

Operator LIKE punya bahasa kecil sendiri: % cocok dengan deretan karakter apa pun, _ cocok dengan satu karakter. Input % pada kolom pencarian bisa mengembalikan seluruh tabel meski query sudah berparameter. Kita buktikan dengan tabel produk:

sql
CREATE TABLE produk (
  id INTEGER PRIMARY KEY,
  kode TEXT NOT NULL
);

INSERT INTO produk VALUES
  (1, 'DISKON-50%'),
  (2, 'DISKON-50A'),
  (3, 'GROSIR-10%');

Pencarian literal 50% tanpa penanganan khusus ikut menyeret baris lain:

bash
sqlite3 toko.db "SELECT kode FROM produk WHERE kode LIKE '%50%%' ORDER BY id;"

Output yang Dihasilkan:

text
DISKON-50%
DISKON-50A

Solusinya: klausa ESCAPE menetapkan karakter pelarian, lalu aplikasi meng-escape %, _, dan karakter pelarian itu sendiri pada input pengguna:

bash
sqlite3 toko.db "SELECT kode FROM produk WHERE kode LIKE '%50\%%' ESCAPE '\' ORDER BY id;"

Output yang Dihasilkan:

text
DISKON-50%

Karakter \% dibaca sebagai tanda persen literal, sehingga hanya DISKON-50% yang cocok. Pola pencarian dari input pengguna sebaiknya dikirim sebagai parameter — LIKE :pola — dan escape-nya dilakukan di sisi aplikasi sebelum nilai diikat.