Setiap nilai yang masuk ke aplikasi — formulir login, query string, cookie — adalah data yang tidak boleh dipercaya begitu saja. SQL injection adalah serangan yang menyusupkan potongan SQL palsu melalui data tersebut, dan konsekuensinya fatal: seluruh tabel bisa tersedot, diubah, bahkan dihapus oleh pihak yang tidak berhak. Kabar baiknya, PHP memiliki senjata yang hampir menuntaskan masalah ini: prepared statements. Pada chapter ini kita akan membongkar cara kerja serangan lewat simulasi yang aman di SQLite :memory:, lalu membangun pertahanan berlapis: parameter binding untuk nilai, allowlist untuk nama kolom, dan escaping untuk wildcard LIKE. Kajian keamanan web yang lebih luas menanti di Section Web Security .

Anatomi Serangan SQL Injection #

Serangan SQL injection berawal dari satu kebiasaan berbahaya: menempelkan string dari pengguna langsung ke dalam string SQL dengan operator concatenation (.). Untuk melihat akibatnya, mari kita bangun tabel pengguna berisi tiga akun, lalu simulasikan formulir login yang menerima alamat email dari pengguna:

php
<?php
$pdo = new PDO("sqlite::memory:");
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$pdo->exec("CREATE TABLE pengguna (id INTEGER PRIMARY KEY, email TEXT, nama TEXT, peran TEXT)");
$pdo->exec("INSERT INTO pengguna (email, nama, peran) VALUES
    ('[email protected]', 'Budi', 'admin'),
    ('[email protected]', 'Siti', 'editor'),
    ('[email protected]', 'Rini', 'pelanggan')");

$emailJahat = "' OR '1'='1";

// KODE RENTAN - hanya untuk pembelajaran, jangan ditiru!
$sql = "SELECT id, email, nama, peran FROM pengguna WHERE email = '$emailJahat'";
echo "SQL yang tereksekusi:\n$sql\n\n";

foreach ($pdo->query($sql, PDO::FETCH_ASSOC) as $row) {
    echo "{$row['id']} | {$row['email']} | {$row['nama']} | {$row['peran']}\n";
}

Output yang Dihasilkan:

text
SQL yang tereksekusi:
SELECT id, email, nama, peran FROM pengguna WHERE email = '' OR '1'='1'

1 | [email protected] | Budi | admin
2 | [email protected] | Siti | editor
3 | [email protected] | Rini | pelanggan

Mari kita bedah bagaimana serangan ini bekerja:

  1. $emailJahat = "' OR '1'='1" adalah payload klasik. Penyerang tidak mengetik alamat email yang sah, melainkan potongan SQL.
  2. Baris $sql = "... email = '$emailJahat'" menempelkan payload itu ke dalam query, menghasilkan WHERE email = '' OR '1'='1'. Tanda kutip pembuka dari payload menutup string '...' milik query kita, sehingga sisanya dibaca sebagai kode SQL.
  3. Kondisi email = '' memang tidak cocok dengan satu baris pun, tetapi OR '1'='1' selalu bernilai benar — sehingga klausa WHERE lolos untuk semua baris.
  4. $pdo->query($sql) mengeksekusi query hasil tempelan itu dan mengembalikan ketiga akun, termasuk akun admin. Jika kode login memakai baris pertama hasil query, penyerang masuk sebagai Budi si admin tanpa perlu tahu kata sandi siapa pun.

Payload lain bisa sama parahnya: "'; DROP TABLE pengguna; --" mencoba menghapus tabel, sedangkan "' OR email LIKE '%admin%'; --" mencoba memilih korbannya sendiri. Semuanya mungkin hanya karena data dan kode SQL bercampur dalam satu string.

Prepared Statements sebagai Benteng Utama #

Prepared statement memisahkan struktur SQL dari data. PDO mengirim template query yang memuat penanda ? ke database, lalu mengirim nilai parameter secara terpisah; database mengeksekusi template dengan nilai tersebut tanpa pernah menafsirkan nilai sebagai kode SQL. Payload yang sama kini menjadi string biasa yang hanya cocok dengan baris yang emailnya benar-benar berbunyi ' OR '1'='1:

php
<?php
$pdo = new PDO("sqlite::memory:");
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$pdo->exec("CREATE TABLE pengguna (id INTEGER PRIMARY KEY, email TEXT, nama TEXT, peran TEXT)");
$pdo->exec("INSERT INTO pengguna (email, nama, peran) VALUES
    ('[email protected]', 'Budi', 'admin'),
    ('[email protected]', 'Siti', 'editor'),
    ('[email protected]', 'Rini', 'pelanggan')");

$emailJahat = "' OR '1'='1";

$stmt = $pdo->prepare("SELECT id, email, nama, peran FROM pengguna WHERE email = ?");
$stmt->execute([$emailJahat]);

echo "baris yang cocok: " . count($stmt->fetchAll(PDO::FETCH_ASSOC)) . "\n";

$stmt->execute(["[email protected]"]);
$user = $stmt->fetch(PDO::FETCH_ASSOC);
echo "login sah: {$user['nama']} ({$user['peran']})\n";

Output yang Dihasilkan:

text
baris yang cocok: 0
login sah: Budi (admin)

Perhatikan hasilnya: payload jahat mengembalikan nol baris, sedangkan email yang sah tetap berfungsi normal. Kutip satu dalam payload kini hanya data — database mencocokkannya secara harfiah dengan kolom email, bukan menutup string query. Cara kerja prepare()-execute() beserta tiga gaya binding telah kita bahas tuntas di chapter Parameter Binding ; pada chapter ini kita cukup menjadikannya kebiasaan baku untuk semua nilai yang berasal dari luar.

Nama Kolom Tidak Bisa Diikat Parameter #

Prepared statements melindungi nilai, tetapi tidak melindungi identifier — nama kolom, nama tabel, dan arah sort tidak boleh diisi penanda ? karena parser membutuhkan nama kolom yang nyata saat kompilasi query. ORDER BY ? yang diisi string "harga" hanya mengurutkan berdasarkan konstanta string, bukan kolom harga. Karena itu fitur sortir dinamis sering memaksa developer kembali ke concatenation — dan di situlah lubang kedua muncul. Solusinya adalah allowlist: daftar kolom yang memang diizinkan, dan input pengguna hanya diterima jika tercantum di dalamnya:

php
<?php
$pdo = new PDO("sqlite::memory:");
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$pdo->exec("CREATE TABLE produk (id INTEGER PRIMARY KEY, nama TEXT, kategori TEXT, harga INTEGER)");
$pdo->exec("INSERT INTO produk (nama, kategori, harga) VALUES
    ('Kopi Hitam', 'minuman', 18000),
    ('Teh Melati', 'minuman', 12000),
    ('Roti Bakar', 'makanan', 25000)");

// Simulasi $_GET['kolom'] dan $_GET['arah'] dari query string
$kolomInput = "harga; DROP TABLE produk";
$arahInput = "DESC";

echo "hasil quote(): " . $pdo->quote($kolomInput) . "\n";

$kolomValid = ['id', 'nama', 'kategori', 'harga'];
$kolom = in_array($kolomInput, $kolomValid, true) ? $kolomInput : 'id';

$arah = strtoupper($arahInput) === 'DESC' ? 'DESC' : 'ASC';

$stmt = $pdo->query("SELECT nama, harga FROM produk ORDER BY $kolom $arah");
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
    echo "{$row['nama']} - Rp{$row['harga']}\n";
}

Output yang Dihasilkan:

text
hasil quote(): 'harga; DROP TABLE produk'
Roti Bakar - Rp25000
Teh Melati - Rp12000
Kopi Hitam - Rp18000

Mari kita uraikan pertahanan pada contoh di atas:

  1. $pdo->quote($kolomInput) menunjukkan hasil dari alat yang salah: quote menghasilkan 'harga; DROP TABLE produk' — string ber-kutip. Jika tempelan ini dipakai di posisi ORDER BY, database menerimanya sebagai konstanta string, bukan nama kolom, sehingga sortir diam-diam tidak bekerja. quote() dibuat untuk nilai, bukan identifier.
  2. in_array($kolomInput, $kolomValid, true) memeriksa input terhadap daftar kolom sah dengan mode ketat (true). Input "harga; DROP TABLE produk" tidak ada dalam daftar, sehingga digantikan nilai fallback 'id' — query tetap valid dan berbahaya input diabaikan.
  3. strtoupper($arahInput) === 'DESC' menerapkan pola allowlist yang sama untuk arah sort: hasilnya hanya bisa DESC atau ASC, tidak ada string lain yang lolos.
  4. ORDER BY $kolom $arah kini aman ditempel karena kedua variabel sudah dipastikan hanya berisi nilai dari himpunan tertutup yang kita definisikan sendiri.

Menjinakkan Wildcard pada LIKE #

Pada klausa LIKE, karakter % cocok dengan deretan karakter apa pun dan _ cocok dengan satu karakter apa pun. Ketika pengguna mencari teks "50%" (maksudnya literal persen), karakter % milik pengguna ikut ditafsirkan sebagai wildcard — hasil pencarian melebar ke baris yang tidak diminta. Solusinya: escape karakter wildcard milik pengguna, lalu nyatakan karakter escape-nya lewat klausa ESCAPE:

php
<?php
$pdo = new PDO("sqlite::memory:");
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$pdo->exec("CREATE TABLE promo (id INTEGER PRIMARY KEY, judul TEXT)");
$pdo->exec("INSERT INTO promo (judul) VALUES
    ('Diskon 50% Hari Ini'),
    ('Diskon 150 Ribu Super'),
    ('Promo_Gajian Hemat'),
    ('Promo Akhir Bulan')");

function escapeLike(string $nilai, string $escape = '\\'): string
{
    return str_replace(
        [$escape, '%', '_'],
        [$escape . $escape, $escape . '%', $escape . '_'],
        $nilai
    );
}

$cari = '50%';

$stmt = $pdo->prepare("SELECT judul FROM promo WHERE judul LIKE ?");
$stmt->execute(['%' . $cari . '%']);
echo "tanpa escape  : " . implode(' | ', $stmt->fetchAll(PDO::FETCH_COLUMN)) . "\n";

$stmt = $pdo->prepare("SELECT judul FROM promo WHERE judul LIKE ? ESCAPE '\\'");
$stmt->execute(['%' . escapeLike($cari) . '%']);
echo "dengan escape : " . implode(' | ', $stmt->fetchAll(PDO::FETCH_COLUMN)) . "\n";

$stmt = $pdo->prepare("SELECT judul FROM promo WHERE judul LIKE ?");
$stmt->execute(['%Promo_%']);
echo "underscore bebas: " . implode(' | ', $stmt->fetchAll(PDO::FETCH_COLUMN)) . "\n";

$stmt = $pdo->prepare("SELECT judul FROM promo WHERE judul LIKE ? ESCAPE '\\'");
$stmt->execute(['%' . escapeLike('Promo_') . '%']);
echo "underscore aman : " . implode(' | ', $stmt->fetchAll(PDO::FETCH_COLUMN)) . "\n";

Output yang Dihasilkan:

text
tanpa escape  : Diskon 50% Hari Ini | Diskon 150 Ribu Super
dengan escape : Diskon 50% Hari Ini
underscore bebas: Promo_Gajian Hemat | Promo Akhir Bulan
underscore aman : Promo_Gajian Hemat

Fungsi escapeLike() mengganti tiga karakter berbahaya sekaligus: karakter escape itu sendiri (di-escape lebih dulu agar tidak dobel), %, dan _. Pola pencarian tetap dibungkus %...% milik kita sendiri — wildcard pembungkus sengaja kita tambahkan setelah input dijinakkan. Perhatikan dua baris terakhir: tanpa escaping, pola %Promo_% menangkap dua judul karena _ cocok dengan karakter apa pun; setelah di-escape, hanya judul yang memuat garis bawah literal yang tampil. Perlu dicatat, masalah wildcard adalah soal ketepatan hasil, bukan celah injeksi SQL — nilainya tetap dikirim lewat parameter binding. MySQL menambah satu karakter khusus lagi (\), sehingga koneksi tanpa PDO::ATTR_EMULATE_PREPARES kadang perlu penanganan ekstra yang kita bahas di chapter MySQL.

Daftar Periksa Pertahanan #

Pertahanan terbaik tersusun berlapis, dan setiap jenis input punya alatnya masing-masing. Tabel berikut merangkum keputusan yang harus Anda ambil setiap kali query menyentuh data dari luar:

Jenis inputPertahanan yang tepat
Nilai pada WHERE, INSERT, UPDATEPrepared statement + parameter binding
Nama kolom atau tabel dinamisAllowlist array + fallback nilai aman
Arah sortir ASC/DESCPerbandingan ketat terhadap dua nilai sah
Nomor halaman, limit, offsetCast (int) sebelum dipakai
Teks pencarian pada LIKEescapeLike() + klausa ESCAPE
Nama tabel dari konfigurasiDibangun dari kode, bukan dari request

Dua kebiasaan pelengkap memperkecil dampak bila lubang tetap lolos: gunakan akun database dengan hak serendah mungkin (aplikasi toko tidak butuh hak DROP), dan jangan pernah memperlihatkan pesan error SQL mentah ke pengguna — cara menanganinya sudah kita bahas di chapter Database Errors .

Ringkasan #

SQL injection menyusup ketika data pengguna menempel pada string SQL; prepared statements memutus campuran itu dengan mengirim nilai terpisah dari struktur query. Identifier seperti nama kolom dilindungi allowlist karena penanda ? tidak berlaku untuk posisi tersebut, wildcard LIKE dijinakkan lewat escaping, dan parameter numerik cukup di-cast. Dengan empat kebiasaan ini, hampir seluruh permukaan serangan tertutup. Seluruh contoh kita sejauh ini berjalan di SQLite :memory: — saatnya mengenal database kecil ini lebih dekat: kekuatannya, konfigurasi pragmanya, dan kapan SQLite pantas dipakai di produksi.

Referensi #