Dari semua cara menyerang database, SQL injection adalah yang paling sering berhasil — bukan karena kecerdasan penyerangnya, melainkan karena kesalahan satu baris kode aplikasi yang terasa tidak bersalah: menyambung input pengguna langsung ke dalam string SQL. Input yang seharusnya menjadi data berubah menjadi perintah; tanda kutip yang diketik pengguna menutup string query sebelum waktunya, dan sisanya dieksekusi server sebagai kode sungguhan. Pada chapter ini kita membedah anatomi serangan ini lapis demi lapis: tautologi yang membuat filter selalu benar, komentar -- yang menetralkan sisa query, dan payload destruktif — semuanya kita buktikan dulu dalam SQL murni yang teruji, lalu kita lihat wujud kode aplikasi rentan versus versi amannya.

Penyiapkan Data Uji #

Kita pakai data produk yang sudah akrab, dengan satu baris yang akan menjadi target pencarian normal:

sql
DROP DATABASE IF EXISTS belajar_aman;
CREATE DATABASE belajar_aman;
USE belajar_aman;

CREATE TABLE produk (
  id INT AUTO_INCREMENT PRIMARY KEY,
  nama VARCHAR(60) NOT NULL,
  kategori VARCHAR(30) NOT NULL,
  harga DECIMAL(10,2) NOT NULL,
  stok INT NOT NULL
);

INSERT INTO produk (nama, kategori, harga, stok) VALUES
  ('Kopi Arabika Gayo', 'Minuman', 85000.00, 40),
  ('Teh Melati Premium', 'Minuman', 45000.00, 120),
  ('Gula Aren Cair', 'Bahan', 32000.00, 75),
  ('Susu Kental Manis', 'Bahan', 18000.00, 200),
  ('Sirup Jeruk', 'Minuman', 27000.00, 0);

Output yang Dihasilkan:

text
Query OK, 0 rows affected, 1 warning (0.00 sec)

Query OK, 1 row affected (0.00 sec)

Database changed
Query OK, 0 rows affected (0.00 sec)

Query OK, 5 rows affected (0.00 sec)
Records: 5  Duplicates: 0  Warnings: 0

Filter Pencarian Normal — dan Filter yang Dibajak #

Query pencarian yang sehat membentuk seperti ini: nilai dicocokkan persis dengan kolom, satu baris cocok, selesai:

sql
SELECT id, nama, harga FROM produk WHERE nama = 'Teh Melati Premium';

Output yang Dihasilkan:

text
+----+--------------------+----------+
| id | nama               | harga    |
+----+--------------------+----------+
|  2 | Teh Melati Premium | 45000.00 |
+----+--------------------+----------+
1 row in set (0.00 sec)

Sekarang bayangkan nilai Teh Melita Premium tadi datang dari form pencarian, dan pengguna mengetik ' OR '1'='1. String SQL yang dirakit aplikasi menjadi satu kalimat baru — dan kita bisa menjalankan hasil rakitannya secara langsung:

sql
SELECT id, nama, harga FROM produk WHERE nama = '' OR '1'='1' ORDER BY id;

Output yang Dihasilkan:

text
+----+--------------------+----------+
| id | nama               | harga    |
+----+--------------------+----------+
|  1 | Kopi Arabika Gayo  | 85000.00 |
|  2 | Teh Melati Premium | 45000.00 |
|  3 | Gula Aren Cair     | 32000.00 |
|  4 | Susu Kental Manis  | 18000.00 |
|  5 | Sirup Jeruk        | 27000.00 |
+----+--------------------+----------+
5 rows in set (0.00 sec)

Lima baris muncul — seluruh isi tabel. Mari kita bedah mengapa filter ini bocor total. String input ' OR '1'='1 mengandung tanda kutip tunggal di depan: kutip itu menutup string nama = ' yang dibuka aplikasi, sama seperti kutip penutup normal. Sisa input menjadi kode SQL sungguhan — operator OR, lalu kondisi '1'='1' yang selalu bernilai benar. Kondisi nama = '' OR TRUE terpenuhi oleh setiap baris yang ada. Pada aplikasi login, versi klasiknya sama persis: WHERE username = '' OR '1'='1' AND password = ... cukup mengembalikan baris pertama, dan kode aplikasi yang hanya memeriksa “ada hasil” membukakan pintu tanpa sandi.

Untuk melihat persis bagaimana string rakitan itu terbentuk, kita bangun dua potongannya dengan CONCAT — fungsi yang melakukan pekerjaan persis seperti operator penyambung string di aplikasi:

sql
SELECT CONCAT('SELECT id, nama FROM produk WHERE nama = ''', ''' OR ''1''=''1', '''') AS query_jadi;

Output yang Dihasilkan:

text
+--------------------------------------------------------+
| query_jadi                                             |
+--------------------------------------------------------+
| SELECT id, nama FROM produk WHERE nama = '' OR '1'='1' |
+--------------------------------------------------------+
1 row in set (0.00 sec)

Kolom query_jadi memperlihatkan hasil akhirnya: SELECT id, nama FROM produk WHERE nama = '' OR '1'='1' — identik dengan query yang barusan kita jalankan. Tidak ada sihir: input pengguna hanya “menempel” di antara dua potong string aplikasi, tetapi tanda kutipnya menulis ulang tata bahasa kalimatnya.

Payload Dekstruktif: Komentar yang Menetralkan Sisa Query #

Tautologi membocorkan data; varian destruktif menghancurkannya. Payload kopi'; DROP TABLE produk; -- bekerja tiga tahap: nilai kopi menutup string dengan benar, titik koma mengakhiri statement pertama, DROP TABLE produk menjadi statement kedua yang dijalankan server, dan -- mengomentari sisanya (termasuk kutip penutup milik aplikasi) supaya tidak menjadi error sintaks. Kita lihat bentuk rakitan akhirnya — dibangun, ditampilkan, tidak dieksekusi:

sql
SELECT CONCAT('SELECT id, nama FROM produk WHERE nama = ''', 'kopi''; DROP TABLE produk; --', '''') AS query_destruktif;

Output yang Dihasilkan:

text
+-------------------------------------------------------------------------+
| query_destruktif                                                        |
+-------------------------------------------------------------------------+
| SELECT id, nama FROM produk WHERE nama = 'kopi'; DROP TABLE produk; --' |
+-------------------------------------------------------------------------+
1 row in set (0.00 sec)

Kolom hasil memperlihatkan dua statement sekaligus dalam satu kiriman: ... nama = 'kopi'; DROP TABLE produk; --'. Menjalankan dua statement dalam satu tembakan memang butuh driver yang mengizinkan multi-statement (mode default banyak driver menolaknya — satu lapis pertahanan yang layak dipertahankan), namun sejarah insiden membuktikan kombinasinya terus ditemukan: data dibocorkan lewat tautologi, struktur dijatuhkan lewat payload kedua, dan akun yang kelebihan hak (chapter least privilege) menjadikan semuanya mungkin.

Wujud di Aplikasi: Rentan versus Aman #

Bentuk rentannya hampir selalu tampak polos begini — contoh PHP pada form login:

php
<?php
// BENTUK RENTAN — jangan ditiru di produksi!
$username = $_POST['username'];   // mis. isinya: ' OR '1'='1
$password = $_POST['password'];

$sql = "SELECT id, nama FROM pengguna
        WHERE username = '" . $username . "'
          AND password = '" . $password . "'";

$result = $mysqli->query($sql);   // query dirakit dari string: CELAH
if ($result->num_rows > 0) {
    // login dianggap sah — penyerang masuk tanpa sandi
}

Perhatikan baris $sql = ...: operator titik menyambung input langsung ke kalimat SQL — replika persis dari CONCAT yang kita uji tadi. Perbaikannya bukan menyaring karakter one per satu, melainkan mengubah arsitektur kalimatnya: struktur SQL ditetapkan lebih dulu dengan placeholder, nilai datang belakangan sebagai parameter:

php
<?php
// BENTUK AMAN — struktur dan data terpisah
$stmt = $mysqli->prepare(
    "SELECT id, nama FROM pengguna WHERE username = ? AND password = ?"
);
$stmt->bind_param("ss", $_POST['username'], $_POST['password']);
$stmt->execute();
$result = $stmt->get_result();
if ($result->num_rows > 0) {
    // login sah berdasar pencocokan nilai, bukan sambungan string
}

Placeholder ? tidak sekadar menempel nilai — placeholder mengirim nilai ke server secara terpisah dari teks SQL, sehingga tanda kutip dalam input tinggal menjadi karakter data biasa. Python memakai pola yang sama dengan penanda %s:

python
username = request.form["username"]   # mis. isinya: ' OR '1'='1
password = request.form["password"]

cursor.execute(
    "SELECT id, nama FROM pengguna WHERE username = %s AND password = %s",
    (username, password),
)   # BENTUK AMAN: nilai dikirim terpisah dari teks SQL, bukan f-string/format
row = cursor.fetchone()

Mitigasi Berlapis #

Prepared statement (kita praktikkan penuh di chapter berikutnya) adalah benteng utama, tetapi pertahanan yang sehat selalu berlapis:

  • Prepared statement untuk semua query berinput — memisahkan struktur dari data, sehingga injeksi kehilangan mekanismenya.
  • Least privilege pada akun aplikasi — bila injeksi lolos sekalipun, akun CRUD murni tidak bisa menjatuhkan tabel (kita buktikan ERROR 1142-nya di chapter sebelumnya).
  • Escape sebagai fallback, bukan andalan — fungsi seperti mysqli_real_escape_string() mengamankan karakter khusus saat placeholder memang tidak tersedia; kelemahannya, satu titik lupa escape di antara ribuan baris cukup untuk kebocoran.
  • Pesan error yang senyap — pesan SQL mentah yang ditampilkan ke pengguna (“Unknown column ‘stok’…”) membantu penyerang memetakan skema; catat detailnya di log server, tampilkan pesan generik di layar.
  • Validasi bentuk input — ID harus angka, tanggal harus format tanggal; menolak bentuk yang salah mempersempit ruang payload sejak awal.

Membersihkan Latihan #

Database uji kita jatuhkan seperti biasa:

sql
DROP DATABASE IF EXISTS belajar_aman;

Output yang Dihasilkan:

text
Query OK, 1 row affected (0.00 sec)

Referensi #

Kita telah melihat bagaimana sambungan string mengubah input menjadi senjata, dan placeholder memutus mekanismenya. Sekarang saatnya membongkar mesin placeholder itu sendiri: PREPARE, EXECUTE, dan DEALLOCATE di level SQL — lengkap dengan bukti bahwa payload ' OR '1'='1 yang dikirim sebagai parameter hanya menghasilkan Empty set.