SQL Injection

Mengapa konkatenasi query berakibat fatal dan bukti prepared statement.

Daftar isi

SQL injection adalah serangan yang menyusupkan perintah SQL melalui masukan sehingga query berjalan di luar maksud penulisnya. Topik ini membuktikan bagaimana konkatenasi string mengubah makna query pada basis data SQLite di memori, lalu menunjukkan bahwa prepared statement mencegah serangan tersebut secara mekanis. Prepared statement adalah query yang strukturnya disiapkan lebih dahulu sebelum data diikat. Setelah membacanya, larangan konkatenasi query dapat dijelaskan dengan bukti keluaran nyata dan bukan sekadar anjuran.

Bagaimana konkatenasi mengubah makna query#

Query yang dirakit dengan konkatenasi adalah satu string sebelum dikirim ke server basis data, sehingga server tidak dapat membedakan bagian perintah dari bagian data. Aplikasi pencarian catatan berikut menerima nama pemilik dari luar lalu menempelkannya langsung ke dalam kondisi WHERE. Tabelnya memuat empat catatan milik tiga orang, dan pencarian yang jujur atas nama budi hanya boleh mengembalikan dua di antaranya.

php injeksi.php
<?php
$pdo = new PDO('sqlite::memory:');
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$pdo->exec('CREATE TABLE catatan (id INTEGER PRIMARY KEY, pemilik TEXT, judul TEXT)');
$pdo->exec("INSERT INTO catatan (pemilik, judul) VALUES
    ('budi', 'Rencana piket kelas'),
    ('budi', 'Daftar peminjaman buku'),
    ('citra', 'Jadwal latihan basket'),
    ('dewi', 'Catatan kas kelas')");

function cari(PDO $pdo, string $masukan): void
{
    $sql = "SELECT id, judul FROM catatan WHERE pemilik = '" . $masukan . "'";
    $baris = $pdo->query($sql)->fetchAll(PDO::FETCH_ASSOC);
    echo 'Masukan: ' . $masukan . PHP_EOL;
    echo 'Query terbentuk: ' . $sql . PHP_EOL;
    echo 'Jumlah baris: ' . count($baris) . PHP_EOL;
    foreach ($baris as $b) {
        echo '- ' . $b['judul'] . PHP_EOL;
    }
    echo PHP_EOL;
}

cari($pdo, 'budi');
cari($pdo, "x' OR '1'='1");
text Keluaran
Masukan: budi
Query terbentuk: SELECT id, judul FROM catatan WHERE pemilik = 'budi'
Jumlah baris: 2
- Rencana piket kelas
- Daftar peminjaman buku

Masukan: x' OR '1'='1
Query terbentuk: SELECT id, judul FROM catatan WHERE pemilik = 'x' OR '1'='1'
Jumlah baris: 4
- Rencana piket kelas
- Daftar peminjaman buku
- Jadwal latihan basket
- Catatan kas kelas

Keluaran atas masukan pertama sesuai harapan, yaitu dua catatan milik budi. Masukan kedua berisi x' OR '1'='1, dan konkatenasi menempelkannya apa adanya sehingga kondisi WHERE berubah menjadi pemilik = 'x' OR '1'='1'. Bagian '1'='1' selalu bernilai benar, sehingga seluruh empat baris terbaca, termasuk catatan milik citra dan dewi yang seharusnya tidak terlihat.

Payload semacam ini tidak memerlukan keahlian khusus. Tanda kutip tunggal menutup nilai string lebih awal, sisanya menjadi perintah baru, dan versi yang lebih merusak dapat mengubah atau menghapus data. Basis data menjalankan semuanya karena menerima string yang secara sintaksis sah. Diagram berikut merangkum perbedaan antara konkatenasi dan parameter terikat.

Konkatenasi ke string queryMasukan dari luarx' OR '1'='1Ditempel ke dalam queryMakna query berubahsemua baris terbacaParameter terikatMasukan dari luarx' OR '1'='1Diikat sebagai nilaiData tetap datastruktur query utuh
Dua cara menyusun query beserta akibatnya

Prepared statement sebagai pembatas#

Prepared statement mengirim struktur query lebih dahulu, kemudian data diikat ke tempat khusus yang disebut placeholder. Server basis data menyusun rencana eksekusi untuk strukturnya, sedangkan nilai yang diikat diperlakukan murni sebagai data dan tidak pernah diurai sebagai perintah. Masukan x' OR '1'='1 yang sama kini hanya mencari baris yang benar-benar berisi teks tersebut.

php aman.php
<?php
$pdo = new PDO('sqlite::memory:');
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$pdo->exec('CREATE TABLE catatan (id INTEGER PRIMARY KEY, pemilik TEXT, judul TEXT)');
$pdo->exec("INSERT INTO catatan (pemilik, judul) VALUES
    ('budi', 'Rencana piket kelas'),
    ('budi', 'Daftar peminjaman buku'),
    ('citra', 'Jadwal latihan basket'),
    ('dewi', 'Catatan kas kelas')");

$stmt = $pdo->prepare('SELECT id, judul FROM catatan WHERE pemilik = :pemilik');
$stmt->execute(['pemilik' => "x' OR '1'='1"]);
$baris = $stmt->fetchAll(PDO::FETCH_ASSOC);
echo 'Masukan berbahaya, jumlah baris: ' . count($baris) . PHP_EOL;

$stmt->execute(['pemilik' => 'budi']);
$baris = $stmt->fetchAll(PDO::FETCH_ASSOC);
echo 'Masukan jujur, jumlah baris: ' . count($baris) . PHP_EOL;
text Keluaran
Masukan berbahaya, jumlah baris: 0
Masukan jujur, jumlah baris: 2

Keluaran menegaskan dua hal. Masukan berbahaya mengembalikan nol baris karena tidak ada pemilik yang benar-benar bernama x' OR '1'='1, sedangkan masukan jujur tetap mengembalikan dua baris milik budi. Nilai yang diikat melalui execute tidak pernah diurai ulang sebagai SQL, sehingga tanda kutip di dalamnya kehilangan daya mengubah struktur query.

PDO menyediakan dua bentuk placeholder, yaitu :nama yang dipakai pada contoh dan tanda tanya berposisi. Keduanya setara dari sisi keamanan, sehingga pemilihannya adalah urusan keterbacaan kode. Prepared statement juga memisahkan persiapan dari eksekusi, sehingga query yang sama dapat dijalankan ulang dengan nilai berbeda tanpa menyusun ulang stringnya.

Batasan parameter terikat dan whitelist#

Parameter terikat hanya menerima nilai, bukan bagian struktur query. Pengenal seperti nama kolom pada ORDER BY tidak dapat diikat sebagai parameter, sehingga nilai itu harus berasal dari daftar yang ditetapkan aplikasi. Pendekatan whitelist, yaitu membandingkan masukan dengan daftar nilai yang diizinkan, menjadi satu-satunya jalur sah untuk bagian struktur.

php urutan.php
<?php
$pdo = new PDO('sqlite::memory:');
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$pdo->exec('CREATE TABLE catatan (id INTEGER PRIMARY KEY, pemilik TEXT, judul TEXT)');
$pdo->exec("INSERT INTO catatan (pemilik, judul) VALUES
    ('budi', 'Rencana piket kelas'),
    ('citra', 'Jadwal latihan basket')");

$urutan = 'judul; DROP TABLE catatan';
$kolomSah = ['id', 'pemilik', 'judul'];
if (!in_array($urutan, $kolomSah, true)) {
    $urutan = 'id';
}
$stmt = $pdo->prepare('SELECT judul FROM catatan ORDER BY ' . $urutan);
$stmt->execute();
echo 'Kolom urutan yang dipakai: ' . $urutan . PHP_EOL;
echo 'Baris pertama: ' . $stmt->fetchColumn() . PHP_EOL;
text Keluaran
Kolom urutan yang dipakai: id
Baris pertama: Rencana piket kelas

Masukan judul; DROP TABLE catatan tidak terdapat pada whitelist, sehingga kode beralih ke nilai bawaan id dan query tetap tersusun dari nama kolom yang sah. Pola yang sama berlaku untuk arah pengurutan, jumlah baris pada LIMIT, dan nama tabel, yaitu seluruh bagian yang tidak dapat diikat sebagai parameter. Satu catatan tambahan menyasar pencarian LIKE. Parameter terikat melindungi struktur query, tetapi karakter % dan _ di dalam masukan tetap memperluas pola pencarian. Karakter tersebut perlu diloloskan bila polanya perlu dibatasi.

Kesalahan umum#

Empat kesalahan berikut paling sering muncul saat melindungi query.

  • Masukan dibungkus fungsi pengganti karakter sebelum konkatenasi, misalnya addslashes. Penggantian seadanya gagal pada pengodean tertentu dan data tetap menjadi bagian string query. Perbaikannya adalah prepared statement untuk setiap query yang membawa data dari luar.
  • Query dirakit dengan sprintf atau pemformatan serupa. Format tidak mengubah sifat konkatenasi, sehingga data tetap menempel ke perintah. Perbaikannya adalah placeholder terikat pada semua nilai.
  • Nama kolom pada ORDER BY diikat sebagai parameter. Nilai terikat dibaca sebagai data, sehingga pengurutan tidak terjadi atau query gagal. Perbaikannya adalah whitelist nama kolom yang dibandingkan sebelum disusun ke dalam query.
  • Pesan error basis data ditampilkan mentah ke halaman. Pesan itu memuat potongan query yang membantu penyerang memetakan struktur tabel. Perbaikannya adalah mencatat error pada log server dan menampilkan pesan umum kepada pengunjung.

Ringkasan#

  • Konkatenasi menyatukan masukan ke dalam string query, sehingga tanda kutip dari luar dapat mengubah makna perintah.
  • Payload x' OR '1'='1 pada contoh menaikkan hasil dari dua baris menjadi empat baris karena kondisi WHERE menjadi selalu benar.
  • Prepared statement mengirim struktur query lebih dahulu dan mengikat data sebagai nilai murni melalui placeholder, sehingga masukan kehilangan daya mengubah perintah.
  • Bagian struktur yang tidak dapat diikat, seperti nama kolom pada ORDER BY, ditentukan melalui whitelist nilai yang diizinkan aplikasi.

Referensi#