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:

text
-- 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:

text
DROP ROLE
CREATE TABLE
INSERT 0 3

Bocoran 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:

sql query-rawan.sql
-- 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:

text
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:

  1. cari_pelanggan_rawan('Budi') — string yang dirakit menjadi WHERE nama = 'Budi'; satu baris kembali, fungsi bekerja sesuai rancangan.
  2. cari_pelanggan_rawan('x'' OR ''1''=''1') — string yang sama kini berbunyi WHERE nama = 'x' OR '1'='1'. Perbandingan '1'='1' selalu benar, sehingga operator OR meloloskan seluruh baris: tiga pelanggan tumpah semua, termasuk yang seharusnya tidak pernah muncul di layar pencarian satu nama.
  3. 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:

text
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:

text
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)

DEALLOCATE

Hasil eksekusinya adalah kebalikan sempurna dari kedua serangan sebelumnya:

  1. hapus_catatan_aman('Siti''; DROP TABLE ...') — payload diserahkan sebagai nilai parameter $1. Server mencocokkan literal itu dengan kolom nama: tidak ada pelanggan bernama Siti'; DROP TABLE ..., jadi nol baris terhapus. objek_tersisa tetap catatan_kunjungan dan baris_tersisa tetap 2 — tabel utuh.
  2. cari_pelanggan_aman dan cari_am dengan payload OR 1=1 — keduanya mengembalikan nol baris: string itu dibandingkan sebagai nama, dan tidak ada yang cocok. Payload tidak pernah sampai ke parser sebagai SQL.
  3. DEALLOCATE di 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:

text
                 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:

text
psql:blast-radius.sql:14: ERROR:  must be owner of table pelanggan

Panggilan 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 :

BahasaPola rawanPola 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:

text
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
sql
-- 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
sql
-- 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
sql
-- 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
sql
-- 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
sql
-- 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
sql
-- 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
sql
-- 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]');