Tiga chapter sebelumnya mengamankan jalur: pg_hba.conf menyaring siapa yang boleh terhubung, scram-sha-256 membuktikan identitas, TLS mengenkripsi lalu lintas. Semua lapisan itu menjadi sia-sia bila aplikasi sendiri yang menyerahkan isi databasenya — karena SQL injection tidak menyadap jalur, melainkan menumpang naik lewat query yang sah. Serangannya lahir dari satu kebiasaan tampak-sepele: merangkai teks SQL dengan menempelkan input pengguna langsung ke string. Pengguna yang baik mengetik Budi; pengguna yang lain mengetik potongan SQL, dan query berubah bentuk tanpa sepengetahuan aplikasi. Chapter ini membedah anatomi serangan itu secara nyata — kebocoran data lewat OR 1=1 sampai tabel yang benar-benar dijatuhkan — lalu memasang tiga lapis pertahanan yang urutannya tidak bisa ditawar: parameter, escaping, dan least privilege.
Anatomi: Ketika Input Menjadi Kode #
Fungsi pencarian pelanggan di aplikasi sering membangun query begini: ambil string SELECT ... WHERE nama = ', tempelkan isi kolom pencarian, tutup dengan '. Bila pengguna mengetik Budi, hasilnya query yang dimaksudkan. Bila pengguna mengetik Budi'; DROP TABLE pelanggan;--, tiga potongan input itu bekerja sebagai kode:
-- query yang dimaksudkan aplikasi:
SELECT id FROM pelanggan WHERE nama = 'Budi';
-- setelah input pengguna ditempelkan mentah:
SELECT id FROM pelanggan WHERE nama = 'Budi'; DROP TABLE pelanggan;--';Kutip satu menutup literal nama lebih awal, sehingga DROP TABLE pelanggan menjadi pernyataan SQL kedua yang sah, dan tanda -- mengomentari sisa string agar error kutip yang tersisa tidak pernah terbaca. Input pengguna telah berpindah kelas: dari data yang dibandingkan, menjadi kode yang dieksekusi. Inilah seluruh inti SQL injection — sisanya variasi payload.
Bahan Latihan: Tabel Pelanggan #
Seperti chapter lain di section ini, latihan berjalan di atas data Toko Maju Jaya yang tetap. Tabel pelanggan menampung tiga pelanggan fiktif; file pembuka juga menyiapkan pembersihan role app_baca_um yang dipakai pada demo pertahanan ketiga:
-- Bahan latihan SQL injection: tabel pelanggan Toko Maju Jaya.
DROP ROLE IF EXISTS app_baca_um;
CREATE TABLE pelanggan (
id integer PRIMARY KEY,
nama text NOT NULL,
email text NOT NULL UNIQUE
);
INSERT INTO pelanggan (id, nama, email) VALUES
(1, 'Budi', '[email protected]'),
(2, 'Siti', '[email protected]'),
(3, 'Ani', '[email protected]');Output yang Dihasilkan:
DROP ROLE
CREATE TABLE
INSERT 0 3Bocoran Data: Payload OR 1=1 #
Kerusakan paling umum bukan menjatuhkan tabel, melainkan membocorkan isinya. File ini membuat fungsi pencarian versi rawan — query dinamis dirakit dengan operator penyambungan || sebagaimana dipraktikkan di section Stored Programs
— lalu mengujinya dengan input biasa dan payload injeksi:
-- Versi RAWAN: query dirakit dengan menempelkan input pengguna
-- langsung ke string SQL lewat operator ||.
CREATE FUNCTION cari_pelanggan_rawan(cari text)
RETURNS SETOF pelanggan
LANGUAGE plpgsql
AS $$
BEGIN
RETURN QUERY EXECUTE
'SELECT id, nama, email FROM pelanggan WHERE nama = ''' || cari || '''';
END;
$$;
-- Panggilan normal: satu baris, hasil yang diharapkan.
SELECT * FROM cari_pelanggan_rawan('Budi') ORDER BY id;
-- Panggilan dengan payload injeksi: input pengguna mengubah
-- struktur WHERE sehingga semua baris lolos.
SELECT * FROM cari_pelanggan_rawan('x'' OR ''1''=''1') ORDER BY id;Output yang Dihasilkan:
CREATE FUNCTION
id | nama | email
----+------+------------------
1 | Budi | [email protected]
(1 row)
id | nama | email
----+------+------------------
1 | Budi | [email protected]
2 | Siti | [email protected]
3 | Ani | [email protected]
(3 rows)Mari kita bedah hasil kedua panggilan itu:
cari_pelanggan_rawan('Budi')— string yang dirakit menjadiWHERE nama = 'Budi'; satu baris kembali, fungsi bekerja sesuai rancangan.cari_pelanggan_rawan('x'' OR ''1''=''1')— string yang sama kini berbunyiWHERE nama = 'x' OR '1'='1'. Perbandingan'1'='1'selalu benar, sehingga operatorORmeloloskan seluruh baris: tiga pelanggan tumpah semua, termasuk yang seharusnya tidak pernah muncul di layar pencarian satu nama.- Pola serangan yang sama dengan menambah payload lain —
' UNION SELECT ...— memungkinkan penyerang membaca tabel lain; tidak ada yang berubah dari sisi mekanismenya, hanya isi string yang dirakit.
Serangan Klasik yang Menjatuhkan Tabel #
Legenda internet menyebut input Robert'; DROP TABLE Students;--. Versi kita membuktikan bahwa legenda itu nyata: fungsi penghapus catatan versi rawan menerima payload lengkap, dan tabelnya hilang sungguhan:
-- Serangan klasik yang menjatuhkan tabel: payload menutup kutip,
-- menyisipkan pernyataan kedua, lalu mengomentari sisanya.
CREATE TABLE catatan_kunjungan (
id integer PRIMARY KEY,
nama text NOT NULL
);
INSERT INTO catatan_kunjungan (id, nama) VALUES
(1, 'Budi'),
(2, 'Siti');
CREATE FUNCTION hapus_catatan_rawan(nama text) RETURNS void
LANGUAGE plpgsql
AS $$
BEGIN
EXECUTE 'DELETE FROM catatan_kunjungan WHERE nama = ''' || nama || '''';
END;
$$;
-- Panggilan fungsi ini sendiri sukses tanpa error --
-- dan tabelnya lenyap.
SELECT hapus_catatan_rawan('Budi''; DROP TABLE catatan_kunjungan;--');
-- to_regclass mengembalikan NULL untuk objek yang sudah tidak ada,
-- jadi bukti kehancuran ini bisa diperiksa tanpa error.
SELECT to_regclass('catatan_kunjungan') AS objek_tersisa;Output yang Dihasilkan:
CREATE TABLE
INSERT 0 2
CREATE FUNCTION
hapus_catatan_rawan
---------------------
(1 row)
objek_tersisa
---------------
(1 row)Dua baris terakhir output adalah bukti kerusakan: hapus_catatan_rawan sukses dikembalikan tanpa keluhan, dan objek_tersisa bernilai NULL — fungsi to_regclass() memang mengembalikan NULL untuk objek yang tidak ada, alih-alih error, sehingga kehancuran tabel bisa diperiksa dengan tenang. DELETE menjalankan bagian yang sah (WHERE nama = 'Budi'), lalu string yang tersisa mengeksekusi DROP TABLE. Bukan kebetulan yang menyelamatkan data di sini — tidak ada satupun.
Pertahanan Utama: Parameter #
Obatnya sudah berdiri di depan pintu sejak lama: pisahkan struktur query dari datanya. Parameter ($1) menempati lubang di dalam query yang sudah selesai di-parse; isi lubang itu tidak pernah di-parse sebagai SQL, apa pun isinya. File ini membangun ulang kedua fungsi dengan pola EXECUTE ... USING (untuk PL/pgSQL) dan PREPARE/EXECUTE (untuk SQL murni), lalu melemparkan payload yang sama persis:
-- Pertahanan utama: parameter terpisah dari teks SQL.
CREATE TABLE catatan_kunjungan (
id integer PRIMARY KEY,
nama text NOT NULL
);
INSERT INTO catatan_kunjungan (id, nama) VALUES
(1, 'Budi'),
(2, 'Siti');
-- Versi AMAN dari fungsi hapus: nilai masuk lewat USING $1,
-- bukan lewat penyambungan string.
CREATE FUNCTION hapus_catatan_aman(nama text) RETURNS void
LANGUAGE plpgsql
AS $$
BEGIN
EXECUTE 'DELETE FROM catatan_kunjungan WHERE nama = $1' USING nama;
END;
$$;
-- Payload injeksi yang tadi menjatuhkan tabel kini diperlakukan
-- sebagai nilai biasa: tidak ada baris yang cocok, tabel tetap utuh.
SELECT hapus_catatan_aman('Siti''; DROP TABLE catatan_kunjungan;--');
SELECT to_regclass('catatan_kunjungan') AS objek_tersisa;
SELECT count(*) AS baris_tersisa FROM catatan_kunjungan;
-- Versi AMAN dari fungsi cari: struktur query terkunci sejak awal.
CREATE FUNCTION cari_pelanggan_aman(cari text)
RETURNS SETOF pelanggan
LANGUAGE plpgsql
AS $$
BEGIN
RETURN QUERY EXECUTE
'SELECT id, nama, email FROM pelanggan WHERE nama = $1' USING cari;
END;
$$;
SELECT * FROM cari_pelanggan_aman('Budi') ORDER BY id;
SELECT * FROM cari_pelanggan_aman('x'' OR ''1''=''1') ORDER BY id;
-- Pola yang sama di SQL murni: PREPARE memisahkan struktur dan data.
PREPARE cari_am(text) AS
SELECT id, nama, email FROM pelanggan WHERE nama = $1;
EXECUTE cari_am('Budi');
EXECUTE cari_am('x'' OR ''1''=''1');
DEALLOCATE cari_am;Output yang Dihasilkan:
CREATE TABLE
INSERT 0 2
CREATE FUNCTION
hapus_catatan_aman
--------------------
(1 row)
objek_tersisa
-------------------
catatan_kunjungan
(1 row)
baris_tersisa
---------------
2
(1 row)
CREATE FUNCTION
id | nama | email
----+------+------------------
1 | Budi | [email protected]
(1 row)
id | nama | email
----+------+-------
(0 rows)
PREPARE
id | nama | email
----+------+------------------
1 | Budi | [email protected]
(1 row)
id | nama | email
----+------+-------
(0 rows)
DEALLOCATEHasil eksekusinya adalah kebalikan sempurna dari kedua serangan sebelumnya:
hapus_catatan_aman('Siti''; DROP TABLE ...')— payload diserahkan sebagai nilai parameter$1. Server mencocokkan literal itu dengan kolomnama: tidak ada pelanggan bernamaSiti'; DROP TABLE ..., jadi nol baris terhapus.objek_tersisatetapcatatan_kunjungandanbaris_tersisatetap2— tabel utuh.cari_pelanggan_amandancari_amdengan payloadOR 1=1— keduanya mengembalikan nol baris: string itu dibandingkan sebagai nama, dan tidak ada yang cocok. Payload tidak pernah sampai ke parser sebagai SQL.DEALLOCATEdi akhir — membersihkan prepared statement dari sesi; detail lengkap siklus hidupnya, termasuk daftar statement yang masih hidup, dibahas di chapter Prepared Statement .
Pertahanan Kedua: Escaping dengan format() #
Sekelompok kasus kecil memang tidak bisa berparameter: nama tabel, nama kolom, dan kata kunci sortir tidak boleh ditaruh di $1 karena posisinya bukan lubang nilai, melainkan bagian struktur. Untuk celah inilah PostgreSQL menyediakan escaping — fungsi format() dengan penentu %L (literal) dan %I (identifier), atau fungsi lama quote_literal() dan quote_ident():
-- Pertahanan kedua: escaping. format() dengan %L mengutip nilai
-- harfiah, %I mengutip identifier -- hasilnya query yang aman
-- dari perubahan struktur.
SELECT format('SELECT id FROM pelanggan WHERE nama = %L', 'Budi')
AS sql_dibangun;
SELECT format('SELECT id FROM pelanggan WHERE nama = %L',
'x'' OR ''1''=''1') AS sql_dibangun;
SELECT format('SELECT id FROM %I WHERE nama = %L',
'pelanggan', 'Budi') AS sql_dibangun;
-- Fungsi manual yang lebih tua: quote_literal dan quote_ident.
SELECT quote_literal('x'' OR ''1''=''1') AS literal_terkutip;
SELECT quote_ident('nama pelanggan" ON DELETE CASCADE--')
AS identifier_terkutip;Output yang Dihasilkan:
sql_dibangun
----------------------------------------------
SELECT id FROM pelanggan WHERE nama = 'Budi'
(1 row)
sql_dibangun
----------------------------------------------------------
SELECT id FROM pelanggan WHERE nama = 'x'' OR ''1''=''1'
(1 row)
sql_dibangun
----------------------------------------------
SELECT id FROM pelanggan WHERE nama = 'Budi'
(1 row)
literal_terkutip
--------------------
'x'' OR ''1''=''1'
(1 row)
identifier_terkutip
----------------------------------------
"nama pelanggan"" ON DELETE CASCADE--"
(1 row)Baris kedua memperlihatkan kerjanya: payload x' OR '1'='1 keluar sebagai 'x'' OR ''1''=''1' — setiap kutip satu di dalam nilai digandakan, sehingga string itu kembali menjadi satu literal utuh, bukan tiga potongan SQL. Baris terakhir menunjukkan quote_ident() menghasilkan identifier terkutip ganda dengan kutip yang tersimpan di dalamnya ikut dijumlah. Dua catatan praktis: gunakan %L/%I pada format() — jangan %s yang hanya menempelkan mentah — dan perlakukan escaping sebagai jalan terakhir.
Pertahanan Ketiga: Least Privilege sebagai Rem Terakhir #
Bila lapis pertama dan kedua gagal — bug lolos review, framework lama, query dinamis yang tidak terhindarkan — radius ledakan ditentukan oleh hak role yang menjalankan query. File ini membuat role aplikasi yang hanya boleh membaca, lalu mencoba serangan DROP TABLE dari balik role tersebut:
-- Pertahanan ketiga: least privilege membatasi radius ledakan.
-- Role aplikasi hanya mendapat SELECT -- bukti bahwa serangan
-- yang lolos pun tidak bisa menjatuhkan tabel.
CREATE ROLE app_baca_um LOGIN;
GRANT SELECT ON pelanggan TO app_baca_um;
SET ROLE app_baca_um;
SELECT count(*) AS pelanggan_terbaca FROM pelanggan;
-- Penyerang yang berhasil menyuntikkan DROP TABLE tetap
-- dihentikan oleh kepemilikan tabel.
DROP TABLE pelanggan;Output yang Dihasilkan:
psql:blast-radius.sql:14: ERROR: must be owner of table pelangganPanggilan SELECT count(*) sukses — role membaca tiga pelanggan sebagaimana haknya. Tetapi DROP TABLE pelanggan berhenti dengan ERROR: must be owner of table pelanggan: menjatuhkan tabel adalah hak pemilik, bukan hak privilege biasa, dan role aplikasi bukan pemiliknya. Serangan yang berhasil disuntikkan sekalipun terhenti di lapis ini. Prinsip ini dirancang penuh di chapter Least Privilege
; pesannya bagi pertahanan injeksi sederhana: jangan pernah menghubungkan aplikasi sebagai superuser atau pemilik skema.
Injeksi Orde Kedua #
Varian yang lebih senyap menunda ledakannya: input jahat disimpan dulu sebagai data yang sah — misalnya nama pengguna Budi'; ... yang lolos validasi karena aplikasi memakai parameter — lalu aplikasi lain membaca nilai itu dan merangkai query dinamis dengannya tanpa escaping. Parameterisasi tetap menjadi jawaban, tetapi harus berlaku di setiap titik di mana nilai keluar dari database menuju query, bukan hanya di titik masuk dari pengguna. Data yang hari ini aman tidak menjamin query esok hari tidak merakitnya menjadi kode.
Pola di Empat Bahasa Aplikasi #
Mekanisme $1 terbawa ke seluruh driver utama, hanya penulisannya yang berbeda. Tabel berikut merangkum pola rawan dan pola aman di empat bahasa yang dibahas lengkap di section Integrasi Aplikasi
:
| Bahasa | Pola rawan | Pola aman |
|---|---|---|
| PHP (PDO) | $pdo->query("... WHERE nama = '$nama'") | $stmt = $pdo->prepare('... WHERE nama = $1'); $stmt->execute([$nama]); |
| Python (psycopg) | cur.execute(f"... WHERE nama = '{nama}'") | cur.execute('... WHERE nama = %s', (nama,)) |
| Go (pgx) | fmt.Sprintf("... WHERE nama = '%s'", nama) | conn.Query(ctx, "... WHERE nama = $1", nama) |
| Node.js (pg) | client.query(`... WHERE nama = '${nama}'`) | client.query('... WHERE nama = $1', [nama]) |
Perhatikan pola psycopg: penempat %s adalah penanda di sisi klien yang driver terjemahkan menjadi parameter bernomor $n di protokol — mekanismenya tetap pemisahan struktur dan data yang sama.
File penutup membersihkan role latihan:
-- Pembersihan role latihan chapter SQL injection.
DROP OWNED BY app_baca_um;
DROP ROLE app_baca_um;
SELECT count(*) AS sisa_role_latihan
FROM pg_roles
WHERE rolname = 'app_baca_um';Output yang Dihasilkan:
DROP OWNED
DROP ROLE
sisa_role_latihan
-------------------
0
(1 row)Tiga lapis pertahanan kini teruji: parameter menutup celahnya, escaping menyegel sisa celah struktural, dan least privilege mempersempit kerusakan bila keduanya jebol. Mekanisme $1 yang berulang di seluruh chapter ini bukan trik sesaat — itu fitur bernama prepared statement, yang selain aman juga menghemat kerja parser. Chapter berikutnya membedahnya secara penuh: siklus PREPARE–EXECUTE–DEALLOCATE, alat bacanya, sampai perilaku cache rencana eksekusinya.
Referensi #
Berkas Sumber
Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.
bersihkan-injeksi.sql
Unduh berkas-- Pembersihan role latihan chapter SQL injection.
DROP OWNED BY app_baca_um;
DROP ROLE app_baca_um;
SELECT count(*) AS sisa_role_latihan
FROM pg_roles
WHERE rolname = 'app_baca_um';
blast-radius.sql
Unduh berkas-- Pertahanan ketiga: least privilege membatasi radius ledakan.
-- Role aplikasi hanya mendapat SELECT -- bukti bahwa serangan
-- yang lolos pun tidak bisa menjatuhkan tabel.
CREATE ROLE app_baca_um LOGIN;
GRANT SELECT ON pelanggan TO app_baca_um;
SET ROLE app_baca_um;
SELECT count(*) AS pelanggan_terbaca FROM pelanggan;
-- Penyerang yang berhasil menyuntikkan DROP TABLE tetap
-- dihentikan oleh kepemilikan tabel.
DROP TABLE pelanggan;
escape-literal.sql
Unduh berkas-- Pertahanan kedua: escaping. format() dengan %L mengutip nilai
-- harfiah, %I mengutip identifier -- hasilnya query yang aman
-- dari perubahan struktur.
SELECT format('SELECT id FROM pelanggan WHERE nama = %L', 'Budi')
AS sql_dibangun;
SELECT format('SELECT id FROM pelanggan WHERE nama = %L',
'x'' OR ''1''=''1') AS sql_dibangun;
SELECT format('SELECT id FROM %I WHERE nama = %L',
'pelanggan', 'Budi') AS sql_dibangun;
-- Fungsi manual yang lebih tua: quote_literal dan quote_ident.
SELECT quote_literal('x'' OR ''1''=''1') AS literal_terkutip;
SELECT quote_ident('nama pelanggan" ON DELETE CASCADE--')
AS identifier_terkutip;
pertahanan-parameter.sql
Unduh berkas-- Pertahanan utama: parameter terpisah dari teks SQL.
CREATE TABLE catatan_kunjungan (
id integer PRIMARY KEY,
nama text NOT NULL
);
INSERT INTO catatan_kunjungan (id, nama) VALUES
(1, 'Budi'),
(2, 'Siti');
-- Versi AMAN dari fungsi hapus: nilai masuk lewat USING $1,
-- bukan lewat penyambungan string.
CREATE FUNCTION hapus_catatan_aman(nama text) RETURNS void
LANGUAGE plpgsql
AS $$
BEGIN
EXECUTE 'DELETE FROM catatan_kunjungan WHERE nama = $1' USING nama;
END;
$$;
-- Payload injeksi yang tadi menjatuhkan tabel kini diperlakukan
-- sebagai nilai biasa: tidak ada baris yang cocok, tabel tetap utuh.
SELECT hapus_catatan_aman('Siti''; DROP TABLE catatan_kunjungan;--');
SELECT to_regclass('catatan_kunjungan') AS objek_tersisa;
SELECT count(*) AS baris_tersisa FROM catatan_kunjungan;
-- Versi AMAN dari fungsi cari: struktur query terkunci sejak awal.
CREATE FUNCTION cari_pelanggan_aman(cari text)
RETURNS SETOF pelanggan
LANGUAGE plpgsql
AS $$
BEGIN
RETURN QUERY EXECUTE
'SELECT id, nama, email FROM pelanggan WHERE nama = $1' USING cari;
END;
$$;
SELECT * FROM cari_pelanggan_aman('Budi') ORDER BY id;
SELECT * FROM cari_pelanggan_aman('x'' OR ''1''=''1') ORDER BY id;
-- Pola yang sama di SQL murni: PREPARE memisahkan struktur dan data.
PREPARE cari_am(text) AS
SELECT id, nama, email FROM pelanggan WHERE nama = $1;
EXECUTE cari_am('Budi');
EXECUTE cari_am('x'' OR ''1''=''1');
DEALLOCATE cari_am;
query-rawan.sql
Unduh berkas-- Versi RAWAN: query dirakit dengan menempelkan input pengguna
-- langsung ke string SQL lewat operator ||.
CREATE FUNCTION cari_pelanggan_rawan(cari text)
RETURNS SETOF pelanggan
LANGUAGE plpgsql
AS $$
BEGIN
RETURN QUERY EXECUTE
'SELECT id, nama, email FROM pelanggan WHERE nama = ''' || cari || '''';
END;
$$;
-- Panggilan normal: satu baris, hasil yang diharapkan.
SELECT * FROM cari_pelanggan_rawan('Budi') ORDER BY id;
-- Panggilan dengan payload injeksi: input pengguna mengubah
-- struktur WHERE sehingga semua baris lolos.
SELECT * FROM cari_pelanggan_rawan('x'' OR ''1''=''1') ORDER BY id;
serangan-drop.sql
Unduh berkas-- Serangan klasik yang menjatuhkan tabel: payload menutup kutip,
-- menyisipkan pernyataan kedua, lalu mengomentari sisanya.
CREATE TABLE catatan_kunjungan (
id integer PRIMARY KEY,
nama text NOT NULL
);
INSERT INTO catatan_kunjungan (id, nama) VALUES
(1, 'Budi'),
(2, 'Siti');
CREATE FUNCTION hapus_catatan_rawan(nama text) RETURNS void
LANGUAGE plpgsql
AS $$
BEGIN
EXECUTE 'DELETE FROM catatan_kunjungan WHERE nama = ''' || nama || '''';
END;
$$;
-- Panggilan fungsi ini sendiri sukses tanpa error --
-- dan tabelnya lenyap.
SELECT hapus_catatan_rawan('Budi''; DROP TABLE catatan_kunjungan;--');
-- to_regclass mengembalikan NULL untuk objek yang sudah tidak ada,
-- jadi bukti kehancuran ini bisa diperiksa tanpa error.
SELECT to_regclass('catatan_kunjungan') AS objek_tersisa;
siapkan-pelanggan.sql
Unduh berkas-- Bahan latihan SQL injection: tabel pelanggan Toko Maju Jaya.
DROP ROLE IF EXISTS app_baca_um;
CREATE TABLE pelanggan (
id integer PRIMARY KEY,
nama text NOT NULL,
email text NOT NULL UNIQUE
);
INSERT INTO pelanggan (id, nama, email) VALUES
(1, 'Budi', '[email protected]'),
(2, 'Siti', '[email protected]'),
(3, 'Ani', '[email protected]');