SQL Injection: Anatomia, Rilevazione e Difesa — Guida Pratica per Professionisti della Sicurezza
Capitolo 7 di 10 · aggiornato 03 lug 2026
Difesa in profondità: pattern sicuri e i casi limite rimanenti
Defense in Depth: Pattern Sicuri e i Casi Limite Rimanenti
A questo punto della guida, abbiamo visto come un attaccante irrompe in ShopBox, come appaiono i loro payload e come la forensics ha quasi mancato completamente la violazione. Ora arriva la parte che apprezzo davvero: costruire cose che non crollano. Faccio code review difensive da quindici anni, e trovo ancora bug di injection in codebase ritenute "sicure". Non perché gli sviluppatori siano negligenti—perché l'SQL injection ha la brutta abitudine di nascondersi in posti che sembrano perfettamente sicuri.
Fatemi mostrare cosa intendo, usando il nostro laboratorio ShopBox. Induriremo il login PHP su 192.0.2.10, l'interfaccia di ricerca Python e il livello database dietro entrambi. Lungo il percorso, condividerò gli errori che ho commesso implementando questi pattern—perché è lì che avviene l'apprendimento reale.
Query Parametrizzate: Il Fondamento Che Non è Infallibile
La prima cosa che tutti imparano sulla difesa contro SQL injection è "usa prepared statements." E hanno ragione, più o meno. Ma "prepared statement" significa cose diverse in contesti diversi, e il modello di minaccia cambia di conseguenza.
Ecco il vulnerabile login ShopBox dalle pagine precedenti—la classica concatenazione di stringhe che ci ha compromessi:
// Vulnerable: login.php on 192.0.2.10 (ShopBox PHP variant)
$username = $_POST['username'];
$password = $_POST['password'];
$query = "SELECT * FROM users WHERE username = '" . $username . "' AND password = '" . md5($password) . "'";
$result = mysqli_query($conn, $query);
La correzione sembra ovvia. Sostituire con un prepared statement:
// Hardened: login.php
$stmt = $conn->prepare("SELECT user_id, username, role FROM users WHERE username = ? AND password_hash = ?");
$stmt->bind_param("ss", $_POST['username'], md5($_POST['password']));
$stmt->execute();
$result = $stmt->get_result();
I placeholder ? vengono inviati al server MySQL su 192.0.2.20 come parte del template della query. I valori effettivi viaggiano separatamente. Il server analizza il template una volta, compila il piano di esecuzione, e rifiuta di reinterpretare i valori come sintassi SQL. Questo è il meccanismo: separazione di codice e dati a livello di protocollo.
Ma ecco dove ho sbagliato una volta, e vedo costantemente nelle code review. Avevo un dev junior venuto da me fiero della sua query "parametrizzata" che appariva così:
// STILL VULNERABLE — common mistake
$table = $_GET['table']; // "validated" with a regex, supposedly
$id = $_GET['id'];
$stmt = $conn->prepare("SELECT * FROM $table WHERE id = ?");
$stmt->bind_param("i", $id);
Il ? è sicuro. Il $table non lo è. I prepared statements parametrizzano valori, non identificatori. Nomi di tabelle, nomi di colonne, clausole ORDER BY, offset LIMIT—queste sono parti strutturali della query che non possono essere legate come parametri in SQL standard. Il protocollo di prepared statement di MySQL semplicemente non supporta la sostituzione di placeholder per identificatori.
Perché questo è importante: La maggior parte dei tutorial su SQL injection si ferma a "usa prepared statements" senza spiegare questo confine. Ho visto sistemi in produzione dove sviluppatori, frustrati dal fatto che il loro ORM non ordinasse dinamicamente, concatenavano colonne
ORDER BYdirettamente nella stringa della query. L'applicazione "usava prepared statements dappertutto" e veniva comunque colpita.
Le stored procedures si collocano in un punto diverso del modello di minaccia. Quando chiami una stored procedure con parametri, stai ancora usando esecuzione parametrizzata—CALL sp_login(?, ?) lega esattamente come un prepared statement. Il beneficio aggiuntivo è l'incapsulamento del piano di query: l'applicazione non vede mai l'SQL interno. Ma le stored procedures possono essere vulnerabili se costruiscono SQL dinamico internamente usando EXEC() o sp_executesql con concatenazione. Ho auditato ambienti SQL Server dove la stored procedure era "sicura" ma all'interno faceva EXEC('SELECT * FROM ' + @table). L'injection si è semplicemente spostata di indirizzo.
Per ShopBox, abbiamo standardizzato su prepared statements per i parametri valore e allow-list rigide (trattate qui sotto) per qualsiasi identificatore dinamico. Le stored procedures erano riservate per operazioni complesse multi-statement dove volevamo imporre la business logic a livello database.
Sicurezza ORM: Quando l'Astrazione Perde
Gli ORM (Object-Relational Mappers, strumenti che ti permettono di interagire con i database usando codice orientato agli oggetti invece di SQL grezzo) promettono di eliminare l'injection per costruzione. E quando usati correttamente, per lo più lo fanno. Ma "correttamente" sta facendo molto lavoro qui.
Nella nostra variante Python di ShopBox su 192.0.2.10 (backend Django, PostgreSQL su 192.0.2.30), la ricerca prodotti iniziava abbastanza innocua:
# Safe: Django ORM query
products = Product.objects.filter(name__icontains=user_input)
L'ORM di Django costruisce query parametrizzate sotto il cofano. Il user_input diventa un parametro legato in WHERE name ILIKE %s. Ma poi i requisiti sono cambiati. Il marketing voleva una "ricerca avanzata" con SQL grezzo per performance. Uno sviluppatore ha raggiunto per raw():
# VULNERABLE: Django raw() with string formatting
user_input = request.GET.get('q')
products = Product.objects.raw( f"SELECT * FROM shopbox_product WHERE name ILIKE '%{user_input}%'"
)
Il raw() di Django restituisce istanze di modello come un QuerySet normale, ma accetta una stringa SQL grezza. L'interpolazione f-string distrugge ogni sicurezza. Il pattern corretto usa la parametrizzazione all'interno di raw():
# Hardened: Django raw() with proper parameter binding
products = Product.objects.raw( "SELECT * FROM shopbox_product WHERE name ILIKE %s", [f"%{request.GET.get('q')}%"]
)
Notate la lista di parametri [%s]—Django la passa attraverso all'esecuzione parametrizzata di PostgreSQL. I wildcard % fanno parte del valore, non della struttura della query.
Ma il caso limite che mi ha quasi bruciato: espressioni RawSQL e Func() per annotazioni complesse. Dovevo classificare i risultati di ricerca per similarità e ho provato:
# DANGEROUS: RawSQL with untrusted column reference
from django.db.models.expressions import RawSQL
products = Product.objects.annotate( rank=RawSQL("similarity(%s, %s)", [user_column, user_query])
)
I parametri sono sicuri, ma ho quasi usato RawSQL(f"similarity({user_column}, %s)", [user_query]) perché il nome della colonna "doveva essere dinamico." Stesso problema di identificatore. Sono finito con un allow-list esplicito:
ALLOWED_COLUMNS = {'name', 'description', 'sku'}
if user_column not in ALLOWED_COLUMNS: raise ValueError(f"Invalid sort column: {user_column}")
# Then proceed with parameterized RawSQL
Ora, Hibernate. La nostra variante Java di ShopBox non esegue Hibernate, ma ho gestito CVE-2026-0603 in altri ambienti, ed è istruttivo. Le versioni Hibernate dalla 5.2.8 alla 5.6.15 sono vulnerabili—nessuna patch ufficiale esiste poiché la 5.6.x è end-of-life. La vulnerabilità è in InlineIdsOrClauseBuilder durante operazioni bulk DELETE o UPDATE. Chiavi primarie stringa fornite dall'utente contenenti SQL malevolo vengono inserite direttamente nelle clausole WHERE.
⚠️ Solo uso autorizzato e difensivo. Quello che segue descrive un pattern di vulnerabilità per comprensione difensiva. Verificare solo in ambienti lab isolati.
Il flusso di attacco: un attaccante si registra con un username come admin' OR '1'='1—ma più specificamente, una stringa elaborata che diventa un identificatore di entità. Questo valore viene memorizzato nel database. Più tardi, quando un amministratore esegue un'operazione bulk (elimina utenti inattivi, aggiorna stati), Hibernate costruisce una clausola IN o un predicato concatenato con OR usando quegli ID memorizzati. Il payload viene eseguito.
Questa è injection di secondo ordine (trattata qui sotto), ma illustra una lezione critica sugli ORM: gli ORM ti proteggono solo quando rimani dentro la loro astrazione. Il Session.createQuery() di Hibernate con parametri nominati è sicuro. Il suo Session.createSQLQuery() con concatenazione di stringhe non lo è. E le operazioni bulk che ottimizzano costruendo dinamicamente liste di ID—quelle sono dove la generazione interna di SQL dell'ORM può tradirti.
Per la mitigazione, la fonte menziona InlineIdsOrClauseBulkIdStrategy come approccio di configurazione in application.yml, sebbene non possa confermarne l'efficacia dai dati disponibili. In pratica, ho migrato le applicazioni affette a Hibernate 6.x o sostituito le operazioni bulk con eliminazioni iterative parametrizzate.
Validazione Input: Il Backup, Non Il Piano
Voglio essere diretto qui perché vedo questo errore costantemente. La validazione input non è la tua difesa primaria contro SQL injection. Le query parametrizzate lo sono. La validazione è la tua rete di sicurezza—la cosa che cattura le stranezze quando la difesa primaria ha una falla, o quando sei forzato in un percorso di codice che non può essere parametrizzato.
Ci sono due filosofie: deny-list e allow-list. Le deny-list dicono "rifiuta se vedi parole chiave SQL come SELECT, UNION, --". Le allow-list dicono "accetta solo pattern noti come buoni."
Le deny-list falliscono. Ogni volta. Ho bypassato filtri che rifiutavano UNION usando UNI/**/ON, o SELECT usando subquery annidate che non colpivano mai la parola chiave. L'attaccante ha creatività infinita; la tua regex ha pazienza finita.
Le allow-list richiedono più riflessione ma funzionano effettivamente. Per la ricerca prodotti di ShopBox, abbiamo definito:
| Campo | Regola Allow-list | Esempio Rifiutato |
|---|---|---|
| ID Prodotto | ^[0-9]+$ | 105 OR 1=1 |
| Colonna ordinamento | ^(name\|price\|created_at)$ | name; DROP TABLE |
| Filtro categoria | ^[a-z_]{1,30}$ | '; DELETE FROM |
| Intervallo prezzo | Intero, 0-1000000 | -1 UNION SELECT |
La colonna di ordinamento è la classica superficie di injection di identificatori. Non potevamo parametrizzare ORDER BY ?—MySQL lo rifiuta. Quindi l'allow-list garantisce che solo colonne note come sicure raggiungano la stringa della query.
In termini semplici: Non provare a pensare a ogni cosa cattiva. Definisci esattamente come appare il buono, e rifiuta tutto il resto.
Indurimento Database: Assumi Che l'Applicazione Fallisca
Questa è la parte che mi ha salvato in un incidente reale. L'applicazione aveva uno zero-day che non conoscevo. Ma l'account database non poteva fare molto, quindi il raggio di esplosione è stato contenuto.
Per ShopBox, abbiamo ristrutturato i privilegi su entrambi i backend. Ecco la nostra matrice prima/dopo per l'account applicazione MySQL su 192.0.2.20:
| Capability | Before (default) | After (hardened) |
|---|---|---|
SELECT | All tables | shopbox.* only |
INSERT/UPDATE/DELETE | All tables | shopbox.* only |
CREATE/DROP/ALTER | Granted | Revoked |
FILE (read/write filesystem) | Granted | Revoked |
SUPER | Granted | Revoked |
INFORMATION_SCHEMA | Full access | TABLES, COLUMNS restricted to shopbox |
mysql.user access | Read | None |
La restrizione INFORMATION_SCHEMA conta per la difesa. Nelle pagine precedenti, abbiamo mostrato come gli attaccanti usano information_schema.tables per mappare il database. Abbiamo creato una vista dedicata che espone solo le tabelle ShopBox, e revocato l'accesso diretto a INFORMATION_SCHEMA. L'applicazione funziona ancora; l'attaccante perde la capacità di ricognizione.
Per PostgreSQL su 192.0.2.30, abbiamo aggiunto una replica read-only per le query di reporting. L'account applicazione che si connette alla replica ha solo SELECT, nessun INSERT/UPDATE/DELETE, e si connette attraverso un segmento di rete separato. Se l'interfaccia di reporting ha una falla di injection, l'attaccante legge i dati prodotti—non può modificare ordini, non può pivotare verso il primario scrivibile.
La Superficie di Rischio Residua: Dove l'Injection Si Nasconde Ancora
Anche con tutto quanto sopra, tre casi limite mi tengono sveglio la notte.
Injection ORDER BY. La query necessita di ordinamento dinamico. Il nome della colonna non può essere parametrizzato. La tua allow-list ha un bug—forse usa strpos() invece del matching esatto, quindi name, (SELECT password FROM users) passa perché name viene trovato. Ho visto questo nel mondo reale. La correzione: matching esatto rigoroso contro un array hardcodato, mai controlli di sottostringa.
Injection LIMIT. Il LIMIT di MySQL accetta due interi separati da virgola, o LIMIT offset, count. Alcuni sviluppatori fanno LIMIT $offset, $count con validazione intera. Ma cosa succede se la validazione è is_numeric() in PHP, che accetta 1,1 PROCEDURE ANALYSE()? (Quel vettore particolare è patchato in MySQL moderno, ma il pattern persiste.) Cast esplicito a intero: (int)$offset, (int)$count.
SQL injection di secondo ordine. Questa è la più insidiosa perché il tuo prepared statement è perfetto al punto di memorizzazione. Il payload sta inoffensivamente nel database. Mesi dopo, un percorso di codice diverso lo recupera e lo usa in una nuova query—senza parametrizzazione.
Ecco come fluisce in ShopBox:
[Attacker] → Modulo di registrazione: username = "admin' UNION SELECT ..." [Prepared statement memorizza in sicurezza nel DB] [Passa del tempo] [Admin] → Job batch "Elimina utenti inattivi" [L'applicazione recupera gli username, costruisce SQL dinamico] → "DELETE FROM users WHERE username = 'admin' UNION SELECT ...'" [L'injection viene eseguita]
L'interazione di primo ordine era sicura. L'interazione di secondo ordine non lo era. Testare questo richiede scenari con dati memorizzati: registrarsi, attendere, triggerare operazioni batch. L'analisi statica spesso lo manca perché il codice vulnerabile e il codice di memorizzazione sono in moduli diversi.
⚠️ Solo uso autorizzato e difensivo. Testare i pattern di secondo ordine solo in ambienti lab isolati come il nostro setup ShopBox.
La Checklist di Samuel: Prima di Chiamarlo Fatto
Uso questo in ogni code review. Non tutti gli elementi si applicano a ogni applicazione, ma la disciplina del controllo conta.
- [ ] Tutti i parametri valore usano prepared statements o equivalente ORM
- [ ] Tutti gli identificatori (tabelle, colonne, ORDER BY) usano allow-list rigide
- [ ] Nessuna costruzione di SQL dinamico nelle stored procedures
- [ ] Usi di ORM
raw()/createSQLQuery()auditati per il binding dei parametri - [ ] L'account DB applicazione ha privilegi minimi (no DDL, no FILE, no SUPER)
- [ ] Accesso a INFORMATION_SCHEMA ristretto o filtrato
- [ ] Repliche read-only usate per interfacce di reporting/analytics
- [ ] La validazione input esiste come defense-in-depth, non come difesa primaria
- [ ] Scenari di secondo ordine testati: dati memorizzati usati poi in query
- [ ] Clausole ORDER BY e LIMIT validate esplicitamente, non solo "sembrano sicure"
L'ultimo elemento di quella lista—"sembrano sicure"—è dove conta l'esperienza. Ho imparato a diffidare della mia stessa fiducia. Se non posso spiegare esattamente perché un percorso di codice è sicuro, con il meccanismo, probabilmente non lo è.
Nelle pagine successive, confrontareremo i comportamenti specifici per database in profondità, e più tardi copriremo il testare le tue difese senza rompere la produzione. Per ora, metti questi pattern nel tuo lab ShopBox, rompili deliberatamente, e osserva cosa mostrano i log. È così che la conoscenza si fissa.