Vai al contenuto
New

DeepSeek V4.1 Flash free for 7 days: 10M tokens a day after a top-up of $10 or more. Until Nov 30.

How it works

SQL injection spiegata con OWASP Juice Shop

Come una SQL injection cambia il significato di una query, perché le query parametrizzate la risolvono e un laboratorio sul login di Juice Shop con una demo in Python e sqlite3.

Intermedio10 min di letturaDi NotHoly

Una SQL injection si verifica quando un'applicazione costruisce una query unendo il proprio testo SQL con dati arrivati da un utente. Il database riceve una sola stringa e non ha modo di sapere quali caratteri ha scritto lo sviluppatore e quali l'utente. Quando il testo dell'utente arriva al parser come SQL, può cambiare il significato della query. La query può restituire altre righe, saltare un controllo, modificare dati o, nei casi peggiori, raggiungere cose che l'applicazione non doveva mai esporre.

Questo articolo spiega il meccanismo, mostra l'unica correzione che agisce sulla causa e ti dà due parti pratiche. La prima è una challenge di login in OWASP Juice Shop, che esegui sulla tua macchina. La seconda è un breve script Python con sqlite3 che riproduce lo stesso bug e la correzione. Se hai letto prima OWASP Top 10 per sviluppatori, la categoria ti è già chiara. Qui prendiamo una voce di quell'elenco e scendiamo fino al testo della query.

Cos'è la SQL injection

La definizione CWE di questa debolezza, CWE-89, la descrive in modo diretto: il software costruisce parte di un comando SQL a partire da un input e non neutralizza la sintassi SQL contenuta in quell'input. L'input viene quindi interpretato come comandi invece che come valori.

In quella definizione contano tre parole: costruisce, input e interpretato. Il bug non sta nel fatto che l'applicazione parli con un database. Sta nel fatto che l'applicazione assembla da sola il testo della query, e alcuni pezzi arrivano da fuori.

Perché conta

Una query di login è l'esempio utile più piccolo. Controlla un nome e una password. Se un attaccante può cambiare la query, può cambiare chi viene autenticato. La stessa falla in una casella di ricerca può restituire righe che l'utente non dovrebbe mai vedere. In una pagina di amministrazione può cambiare prezzi, ruoli o dati salvati. Molti di questi bug iniziano con un singolo apice che arriva al database senza escape, e spesso è proprio quell'apice a dare a chi testa la prima conferma del problema.

L'injection non richiede nemmeno un payload ingegnoso. Gli sviluppatori la trovano spesso con un input che manda in crash l'applicazione o le fa restituire un errore strano. Un errore che cita la sintassi SQL è un forte indizio che l'input è arrivato alla query.

Come la query cambia significato

Parti da un template che uno sviluppatore potrebbe scrivere:

sql
SELECT * FROM users WHERE name = '<name>' AND pw = '<pw>'

Immagina che il campo del nome riceva admin' -- e quello della password riceva wrong. Dopo che l'applicazione ha messo queste stringhe nel template, il database analizza questo:

sql
SELECT * FROM users WHERE name = 'admin' --' AND pw = 'wrong'

Leggila passo per passo. Il primo apice dopo admin chiude il letterale stringa aperto dallo sviluppatore. Poi -- inizia un commento in SQLite e nella maggior parte dei dialetti SQL. Tutto quello che segue viene ignorato, compresa la condizione sulla password. Il database riceve una query valida che chiede la riga con nome admin, e nient'altro.

Succedono quattro cose, in ordine:

  1. L'applicazione copia i caratteri dell'utente nel testo della query.
  2. Il parser del database legge tutta la stringa come SQL. Non può sapere quali caratteri vengono dallo sviluppatore e quali dall'utente.
  3. Un apice dell'utente chiude in anticipo il letterale. Il testo che segue viene interpretato come parole chiave SQL, operatori o commenti.
  4. La condizione scritta dallo sviluppatore non è più quella eseguita dal database.

F-string, concatenazione di stringhe, formattazione con %, .format() e template literal hanno tutti lo stesso difetto. Il linguaggio non conta. Conta il modo in cui si uniscono testo e SQL.

La correzione alla radice: query parametrizzate

La parametrizzazione separa il testo SQL dai valori. Invii la query con dei segnaposto, e i valori viaggiano verso il database attraverso il driver come dati. Il database non li interpreta mai come SQL, quindi un apice dentro un valore non ha alcun significato speciale.

La documentazione di sqlite3 di Python dice di usare i segnaposto invece della formattazione di stringhe per legare valori Python alle istruzioni SQL, proprio per evitare la SQL injection. L'OWASP SQL Injection Prevention Cheat Sheet indica le query parametrizzate come difesa primaria per lo stesso motivo: la struttura della query è definita separatamente dai dati.

La validazione serve ancora, ma è un secondo livello. Il controllo che agisce sulla causa è il binding dei parametri. Se validi un valore e poi lo concateni in una query, il bug è ancora lì.

Pratica: risolvi la challenge Login Admin

Juice Shop è un'applicazione web volutamente vulnerabile che OWASP mantiene per la formazione. Eseguila solo sulla tua macchina e tienila legata all'indirizzo di loopback, così nessun altro computer può raggiungerla.

Avvia il laboratorio su localhost

bash
docker run -d -p 127.0.0.1:3000:3000 bkimminich/juice-shop

Il prefisso 127.0.0.1: pubblica la porta solo sull'interfaccia di loopback. Se la porta 3000 è già occupata, Docker fallisce con un errore come port is already allocated. Sulla macchina di prova usata per questo articolo, un altro container locale occupava già la porta 3000 e il comando è fallito per questo motivo. Scegli una porta host libera, per esempio 127.0.0.1:3001:3000, e usala nel resto dei passi.

Verifica che l'app risponda e controlla la sua versione:

bash
curl -s http://127.0.0.1:3000/rest/admin/application-version

Sulla macchina di prova ha restituito:

text
{"version":"20.3.0"}

Trova il punto di iniezione

La guida ufficiale di Juice Shop, Pwning OWASP Juice Shop, non riporta il payload di questa challenge. Dà dei suggerimenti: la descrizione della challenge indica il form da attaccare, un attacco mirato funziona bene se conosci già l'indirizzo email dell'admin, e un pattern di iniezione standard può riuscire anche senza saperlo. Segui questi suggerimenti. Parti dal form di login e da un singolo apice nel campo email. Una normale password sbagliata dovrebbe dare un errore. Una query rotta spesso dà una risposta diversa.

Sulla macchina di prova, una password sbagliata per l'email dell'admin, senza iniezione, ha restituito:

text
Invalid email or password.

con stato HTTP 401. Questa è la base di confronto. Poi la stessa richiesta con un apice e un commento nel campo email:

bash
curl -s -X POST http://127.0.0.1:3000/rest/user/login \
  -H 'Content-Type: application/json' \
  -d '{"email":"[email protected]'\''--","password":"wrong"}'

La risposta contiene un oggetto authentication con un campo token. La password era sbagliata, eppure il server ha emesso un token di sessione. Decodificando la parte centrale del JSON Web Token, codificata in base64url, si vede "role":"admin". Sulla macchina di prova la risposta è stata HTTP 200.

Controlla lo stato dello scoreboard tramite l'API del laboratorio:

bash
curl -s "http://127.0.0.1:3000/api/Challenges/?name=Login%20Admin"

Sulla macchina di prova la challenge è risultata risolta, con difficoltà 2. Lo scoreboard è la fonte di riferimento per la challenge. Con lo stesso controllo dovresti ottenere lo stesso risultato.

La guida ufficiale e il codice sorgente di Juice Shop sono i posti dove confermare la query esatta dietro questo form. Leggili dopo aver ottenuto il risultato, così capisci il meccanismo invece di limitarti a copiare un payload.

Ferma il laboratorio

bash
docker ps
docker stop <container-name>

Usa il nome o l'ID del container mostrato da docker ps. Chiudere la scheda del browser non ferma il container.

Riproduci il bug e la correzione in Python

Salva questo file come sqli_demo.py. Serve solo la libreria standard. Lo script crea una piccola tabella users in memoria ed espone due funzioni di login. Una costruisce la query con una f-string. L'altra usa un segnaposto.

python
import sqlite3

con = sqlite3.connect(":memory:")
cur = con.cursor()
cur.execute("CREATE TABLE users (name TEXT, pw TEXT)")
cur.execute("INSERT INTO users VALUES ('alice', 'correct-horse'), ('admin', 'S3cret!')")

def login_unsafe(user, pw):
    query = f"SELECT * FROM users WHERE name='{user}' AND pw='{pw}'"
    print("  built:", query)
    return cur.execute(query).fetchall()

def login_safe(user, pw):
    return cur.execute('SELECT * FROM users WHERE name=? AND pw=?', (user, pw)).fetchall()

if __name__ == "__main__":
    print("same valid login, unsafe:", login_unsafe("admin", "S3cret!"))
    print("same valid login, safe:  ", login_safe("admin", "S3cret!"))
    print("crafted input, unsafe:")
    print("  result:", login_unsafe("admin' --", "wrong"))
    print("crafted input, safe:")
    print("  result:", login_safe("admin' --", "wrong"))

Eseguilo. Su Windows con Python installato come python, il comando è python sqli_demo.py. Sulla macchina di prova python3 non era nel path, quindi lì usa python. L'output osservato sulla macchina di prova con Python 3.12.10:

text
same valid login, unsafe: [('admin', 'S3cret!')]
same valid login, safe:   [('admin', 'S3cret!')]
crafted input, unsafe:
  built: SELECT * FROM users WHERE name='admin' --' AND pw='wrong'
  result: [('admin', 'S3cret!')]
crafted input, safe:
  result: []

Confronta le due metà. Con un login normale le due funzioni restituiscono la stessa riga, quindi la versione non sicura sembra corretta. Per questo il bug sopravvive alla code review. Con l'input costruito ad arte, la funzione non sicura stampa la query ricevuta dal database. Il commento elimina il controllo della password e la riga dell'admin torna anche se la password era sbagliata. La funzione sicura tratta admin' -- come un nome letterale. Nessun utente si chiama così, quindi restituisce una lista vuota.

Lo script conserva le password in chiaro perché è una demo. I sistemi reali conservano un hash della password con salt e confrontano gli hash.

Dove ORM e helper sbagliano ancora

Un ORM non rende impossibile il bug. Ti dà un comportamento predefinito sicuro per le query normali e una serie di vie di fuga per tutto il resto. Di solito il bug torna in questi punti:

  • Metodi SQL grezzi. Chiamate come raw, text, extra o un semplice execute accettano una stringa. Se costruisci quella stringa con una f-string o una concatenazione, sei di nuovo nello schema non sicuro.
  • Nomi di tabelle e colonne. I segnaposto legano valori. Non possono legare identificatori. Una colonna di ORDER BY o un nome di tabella controllati dall'utente devono passare da un'allow-list che associa la scelta dell'utente a nomi scritti da te. OWASP raccomanda lo stesso approccio per le scelte strutturali fisse, come la direzione di ordinamento.
  • Liste IN dinamiche. Il numero di segnaposto cambia con l'input. Genera dal tuo codice il numero giusto di marcatori ?, poi passa i valori come tupla. Non unire i valori dell'utente al testo.
  • Helper di filtro. Una funzione che accetta una stringa di filtro e la aggiunge a una clausola WHERE è un punto di concatenazione, anche quando il resto dell'app usa bene l'ORM.

Errori comuni

Mettere in blacklist le parole. Un filtro che rifiuta OR, SELECT, gli apici o -- si aggira facilmente con cambi di maiuscole, encoding e stili di commento. L'OWASP cheat sheet dice chiaramente che la validazione non sostituisce il binding dei parametri. Usa prima il binding. La validazione serve per le scelte strutturali fisse viste sopra, non come difesa principale.

Fare l'escape degli apici a mano. Ogni driver di database ha le sue regole, e basta un caso dimenticato. Lascia fare al driver.

Affidarsi solo al privilegio minimo. Un account di database con meno permessi limita ciò che un attaccante può fare dopo l'iniezione. Non blocca l'iniezione. OWASP indica il privilegio minimo come controllo di supporto e dice che gli account applicativi non dovrebbero mai avere diritti DBA o di amministrazione. Fai entrambe le cose: parametrizza e dai all'applicazione il minimo insieme di permessi che le serve.

Parametrizzare i valori e dimenticare il resto. Un team passa la clausola WHERE ai segnaposto, poi concatena la colonna di ordinamento senza pensarci. Rivedi ogni punto in cui entra del testo in una query, non solo quello più evidente.

Testare solo input normali. Il bug compare solo quando l'input contiene sintassi SQL. Aggiungi un test che invia il valore costruito ad arte e si aspetta un risultato vuoto o una richiesta rifiutata.

Per andare oltre

Leggi la sezione sui segnaposto nella documentazione di sqlite3 di Python, poi prova lo stesso schema con il driver usato dal tuo progetto. Leggi per intero l'OWASP SQL Injection Prevention Cheat Sheet, soprattutto le sezioni sulle stored procedure e sugli identificatori dinamici.

Torna al laboratorio di Juice Shop e risolvi con la stessa cura le altre challenge legate a SQL. Per ognuna, annota la query che secondo te esegue il server, poi controlla il codice sorgente.

Aggiungi un test di regressione al tuo codice. Il test dovrebbe inviare admin' -- o un valore simile a ogni funzione di query che accetta input utente e verificare che la funzione non restituisca righe che non dovrebbe restituire.

Cerca nel tuo codice f-string, formattazione con %, .format() e concatenazioni di stringhe vicine a execute, raw, text o extra. Ogni risultato è un punto da rivedere, non per forza un bug.

Gli strumenti di analisi statica possono segnalare alcuni di questi pattern, e un modello linguistico di grandi dimensioni può aiutarti a leggere query builder che non conosci. Nessuno dei due sostituisce il test che dimostra che la tua query si comporta correttamente. L'API di NotHoly è un'opzione per questo tipo di revisione, ed è utile solo quanto il test che ne controlla il risultato.

Il prossimo articolo di questa serie parla di cross-site scripting (XSS), che ha una forma simile: testo non fidato finisce interpretato come qualcosa che non dovrebbe essere.

Fonti

Fonti

  1. OWASP SQL Injection Prevention Cheat Sheet
  2. MITRE CWE-89: Improper Neutralization of Special Elements used in an SQL Command
  3. OWASP Pwning OWASP Juice Shop companion guide
  4. Python sqlite3 DB-API 2.0 interface for SQLite databases

Altro su Sicurezza web e API

Sicurezza web e API

Testare i bug web comuni con l'aiuto dell'IA

Metodo per IDOR, XSS, SSRF e auth rotta con un modello di IA come lettore e secondo parere, più come verificare un finding ed evitare i falsi positivi.

Base3 min di lettura

Sicurezza web e API

Test delle API con l'IA: OpenAPI e GraphQL

Le API si descrivono tramite le spec OpenAPI e l'introspection GraphQL. Usa l'IA per leggere lo schema e pianificare i test di autorizzazione.

Intermedio3 min di lettura