SQL injection e come prevenirlo?

Persistenza in alto

Ho appena annunciato il nuovo corso Learn Spring , incentrato sui fondamenti di Spring 5 e Spring Boot 2:

>> SCOPRI IL CORSO

1. Introduzione

Nonostante sia una delle vulnerabilità più note, SQL Injection continua a classificarsi al primo posto della famigerata lista OWASP Top 10, ora parte della più generale classe Injection .

In questo tutorial, esploreremo gli errori di codifica comuni in Java che portano a un'applicazione vulnerabile e come evitarli utilizzando le API disponibili nella libreria di runtime standard di JVM. Copriremo anche quali protezioni possiamo ottenere da ORM come JPA, Hibernate e altri e di quali punti ciechi dovremo ancora preoccuparci.

2. In che modo le applicazioni diventano vulnerabili all'iniezione di SQL?

Gli attacchi injection funzionano perché, per molte applicazioni, l'unico modo per eseguire un dato calcolo è generare dinamicamente codice che a sua volta viene eseguito da un altro sistema o componente . Se nel processo di generazione di questo codice utilizziamo dati non attendibili senza un'adeguata sanificazione, lasciamo una porta aperta affinché gli hacker possano sfruttare.

Questa affermazione può sembrare un po 'astratta, quindi diamo un'occhiata a come ciò accade in pratica con un esempio da manuale:

public List unsafeFindAccountsByCustomerId(String customerId) throws SQLException { // UNSAFE !!! DON'T DO THIS !!! String sql = "select " + "customer_id,acc_number,branch_id,balance " + "from Accounts where customer_id = '" + customerId + "'"; Connection c = dataSource.getConnection(); ResultSet rs = c.createStatement().executeQuery(sql); // ... }

Il problema con questo codice è ovvio: abbiamo inserito il valore di customerId nella query senza alcuna convalida . Non succederà nulla di male se siamo sicuri che questo valore verrà solo da fonti attendibili, ma possiamo?

Immaginiamo che questa funzione venga utilizzata in un'implementazione dell'API REST per una risorsa account . Sfruttare questo codice è banale: tutto quello che dobbiamo fare è inviare un valore che, quando concatenato alla parte fissa della query, ne modifichi il comportamento previsto:

curl -X GET \ '//localhost:8080/accounts?customerId=abc%27%20or%20%271%27=%271' \

Supponendo che il valore del parametro customerId non sia selezionato fino a quando non raggiunge la nostra funzione, ecco cosa riceveremmo:

abc' or '1' = '1

Quando uniamo questo valore con la parte fissa, otteniamo l'istruzione SQL finale che verrà eseguita:

select customer_id, acc_number,branch_id, balance from Accounts where customerId = 'abc' or '1' = '1'

Probabilmente non quello che volevamo ...

Uno sviluppatore intelligente (non siamo tutti?) Ora penserebbe: "È stupido! Non userei mai la concatenazione di stringhe per creare una query come questa ”.

Non così velocemente ... Questo esempio canonico è davvero sciocco, ma ci sono situazioni in cui potremmo ancora aver bisogno di farlo :

  • Query complesse con criteri di ricerca dinamici: aggiunta di clausole UNION a seconda dei criteri forniti dall'utente
  • Raggruppamento o ordinamento dinamico: API REST utilizzate come back-end per una tabella dati GUI

2.1. Sto usando JPA. Sono al sicuro, giusto?

Questo è un malinteso comune . JPA e altri ORM ci sollevano dalla creazione di istruzioni SQL codificate manualmente, ma non ci impediscono di scrivere codice vulnerabile .

Vediamo come appare la versione JPA dell'esempio precedente:

public List unsafeJpaFindAccountsByCustomerId(String customerId) { String jql = "from Account where customerId = '" + customerId + "'"; TypedQuery q = em.createQuery(jql, Account.class); return q.getResultList() .stream() .map(this::toAccountDTO) .collect(Collectors.toList()); } 

Lo stesso problema che abbiamo segnalato in precedenza è presente anche qui: stiamo utilizzando input non convalidati per creare una query JPA , quindi siamo esposti allo stesso tipo di exploit qui.

3. Tecniche di prevenzione

Ora che sappiamo cos'è una SQL injection, vediamo come possiamo proteggere il nostro codice da questo tipo di attacco. Qui ci stiamo concentrando su un paio di tecniche molto efficaci disponibili in Java e altri linguaggi JVM, ma concetti simili sono disponibili per altri ambienti, come PHP, .Net, Ruby e così via.

Per coloro che cercano un elenco completo delle tecniche disponibili, comprese quelle specifiche del database, il progetto OWASP mantiene un Cheat Sheet di prevenzione dell'iniezione SQL, che è un buon posto per saperne di più sull'argomento.

3.1. Query parametrizzate

Questa tecnica consiste nell'usare istruzioni preparate con il segnaposto del punto interrogativo ("?") Nelle nostre query ogni volta che è necessario inserire un valore fornito dall'utente. Questo è molto efficace e, a meno che non ci sia un bug nell'implementazione del driver JDBC, immune agli exploit.

Riscriviamo la nostra funzione di esempio per utilizzare questa tecnica:

public List safeFindAccountsByCustomerId(String customerId) throws Exception { String sql = "select " + "customer_id, acc_number, branch_id, balance from Accounts" + "where customer_id = ?"; Connection c = dataSource.getConnection(); PreparedStatement p = c.prepareStatement(sql); p.setString(1, customerId); ResultSet rs = p.executeQuery(sql)); // omitted - process rows and return an account list }

Qui abbiamo utilizzato il metodo prepareStatement () disponibile nell'istanza di Connection per ottenere un PreparedStatement . Questa interfaccia estende la normale interfaccia Statement con diversi metodi che ci consentono di inserire in modo sicuro i valori forniti dall'utente in una query prima di eseguirla.

Per JPA, abbiamo una caratteristica simile:

String jql = "from Account where customerId = :customerId"; TypedQuery q = em.createQuery(jql, Account.class) .setParameter("customerId", customerId); // Execute query and return mapped results (omitted)

Quando si esegue questo codice in Spring Boot, possiamo impostare la proprietà logging.level.sql su DEBUG e vedere quale query è effettivamente costruita per eseguire questa operazione:

// Note: Output formatted to fit screen [DEBUG][SQL] select account0_.id as id1_0_, account0_.acc_number as acc_numb2_0_, account0_.balance as balance3_0_, account0_.branch_id as branch_i4_0_, account0_.customer_id as customer5_0_ from accounts account0_ where account0_.customer_id=?

Come previsto, il livello ORM crea un'istruzione preparata utilizzando un segnaposto per il parametro customerId . Questo è lo stesso che abbiamo fatto nel semplice caso JDBC, ma con poche istruzioni in meno, il che è bello.

Inoltre, questo approccio di solito si traduce in una query con prestazioni migliori, poiché la maggior parte dei database può memorizzare nella cache il piano di query associato a un'istruzione preparata.

Tieni presente che questo approccio funziona solo per i segnaposto utilizzati come valori . Ad esempio, non possiamo utilizzare segnaposto per modificare dinamicamente il nome di una tabella:

// This WILL NOT WORK !!! PreparedStatement p = c.prepareStatement("select count(*) from ?"); p.setString(1, tableName);

Qui, JPA non aiuterà neanche:

// This WILL NOT WORK EITHER !!! String jql = "select count(*) from :tableName"; TypedQuery q = em.createQuery(jql,Long.class) .setParameter("tableName", tableName); return q.getSingleResult(); 

In entrambi i casi, avremo un errore di runtime.

Il motivo principale alla base di ciò è la natura stessa di un'istruzione preparata: i server di database li utilizzano per memorizzare nella cache il piano di query richiesto per estrarre il set di risultati, che di solito è lo stesso per qualsiasi valore possibile. Ciò non è vero per i nomi di tabella e altri costrutti disponibili nel linguaggio SQL, come le colonne utilizzate in una clausola order by .

3.2. API dei criteri JPA

Since explicit JQL query building is the main source of SQL Injections, we should favor the use of the JPA's Query API, when possible.

For a quick primer on this API, please refer to the article on Hibernate Criteria queries. Also worth reading is our article about JPA Metamodel, which shows how to generate metamodel classes that will help us to get rid of string constants used for column names – and the runtime bugs that arise when they change.

Let's rewrite our JPA query method to use the Criteria API:

CriteriaBuilder cb = em.getCriteriaBuilder(); CriteriaQuery cq = cb.createQuery(Account.class); Root root = cq.from(Account.class); cq.select(root).where(cb.equal(root.get(Account_.customerId), customerId)); TypedQuery q = em.createQuery(cq); // Execute query and return mapped results (omitted)

Here, we've used more code lines to get the same result, but the upside is that now we don't have to worry about JQL syntax.

Another important point: despite its verbosity, the Criteria API makes creating complex query services more straightforward and safer. For a complete example that shows how to do it in practice, please take a look at the approach used by JHipster-generated applications.

3.3. User Data Sanitization

Data Sanitization is a technique of applying a filter to user supplied-data so it can be safely used by other parts of our application. A filter's implementation may vary a lot, but we can generally classify them in two types: whitelists and blacklists.

Blacklists, which consist of filters that try to identify an invalid pattern, are usually of little value in the context of SQL Injection prevention – but not for the detection! More on this later.

Whitelists, on the other hand, work particularly well when we can define exactly what is a valid input.

Let's enhance our safeFindAccountsByCustomerId method so now the caller can also specify the column used to sort the result set. Since we know the set of possible columns, we can implement a whitelist using a simple set and use it to sanitize the received parameter:

private static final Set VALID_COLUMNS_FOR_ORDER_BY = Collections.unmodifiableSet(Stream .of("acc_number","branch_id","balance") .collect(Collectors.toCollection(HashSet::new))); public List safeFindAccountsByCustomerId( String customerId, String orderBy) throws Exception { String sql = "select " + "customer_id,acc_number,branch_id,balance from Accounts" + "where customer_id = ? "; if (VALID_COLUMNS_FOR_ORDER_BY.contains(orderBy)) { sql = sql + " order by " + orderBy; } else { throw new IllegalArgumentException("Nice try!"); } Connection c = dataSource.getConnection(); PreparedStatement p = c.prepareStatement(sql); p.setString(1,customerId); // ... result set processing omitted }

Here, we're combining the prepared statement approach and a whitelist used to sanitize the orderBy argument. The final result is a safe string with the final SQL statement. In this simple example, we're using a static set, but we could also have used database metadata functions to create it.

We can use the same approach for JPA, also taking advantage of the Criteria API and Metadata to avoid using String constants in our code:

// Map of valid JPA columns for sorting final Map
    
      VALID_JPA_COLUMNS_FOR_ORDER_BY = Stream.of( new AbstractMap.SimpleEntry(Account_.ACC_NUMBER, Account_.accNumber), new AbstractMap.SimpleEntry(Account_.BRANCH_ID, Account_.branchId), new AbstractMap.SimpleEntry(Account_.BALANCE, Account_.balance)) .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue)); SingularAttribute orderByAttribute = VALID_JPA_COLUMNS_FOR_ORDER_BY.get(orderBy); if (orderByAttribute == null) { throw new IllegalArgumentException("Nice try!"); } CriteriaBuilder cb = em.getCriteriaBuilder(); CriteriaQuery cq = cb.createQuery(Account.class); Root root = cq.from(Account.class); cq.select(root) .where(cb.equal(root.get(Account_.customerId), customerId)) .orderBy(cb.asc(root.get(orderByAttribute))); TypedQuery q = em.createQuery(cq); // Execute query and return mapped results (omitted)
    

This code has the same basic structure as in the plain JDBC. First, we use a whitelist to sanitize the column name, then we proceed to create a CriteriaQuery to fetch the records from the database.

3.4. Are We Safe Now?

Let's assume that we've used parameterized queries and/or whitelists everywhere. Can we now go to our manager and guarantee we're safe?

Well… not so fast. Without even considering Turing's halting problem, there are other aspects we must consider:

  1. Stored Procedures: These are also prone to SQL Injection issues; whenever possible please apply sanitation even to values that will be sent to the database via prepared statements
  2. Triggers: Same issue as with procedure calls, but even more insidious because sometimes we have no idea they're there…
  3. Insecure Direct Object References: Even if our application is SQL-Injection free, there's still a risk that associated with this vulnerability category – the main point here is related to different ways an attacker can trick the application, so it returns records he or she was not supposed to have access to – there's a good cheat sheet on this topic available at OWASP's GitHub repository

In short, our best option here is caution. Many organizations nowadays use a “red team” exactly for this. Let them do their job, which is exactly to find any remaining vulnerabilities.

4. Damage Control Techniques

As a good security practice, we should always implement multiple defense layers – a concept known as defense in depth. The main idea is that even if we're unable to find all possible vulnerabilities in our code – a common scenario when dealing with legacy systems – we should at least try to limit the damage an attack would inflict.

Of course, this would be a topic for a whole article or even a book but let's name a few measures:

  1. Apply the principle of least privilege: Restrict as much as possible the privileges of the account used to access the database
  2. Use database-specific methods available in order to add an additional protection layer; for example, the H2 Database has a session-level option that disables all literal values on SQL Queries
  3. Use short-lived credentials: Make the application rotate database credentials often; a good way to implement this is by using Spring Cloud Vault
  4. Log everything: If the application stores customer data, this is a must; there are many solutions available that integrate directly to the database or work as a proxy, so in case of an attack we can at least assess the damage
  5. Utilizzare WAF o soluzioni di rilevamento delle intrusioni simili: questi sono i tipici esempi di blacklist : di solito, vengono forniti con un database di grandi dimensioni di firme di attacchi noti e attiveranno un'azione programmabile al rilevamento. Alcuni includono anche agenti in-JVM in grado di rilevare le intrusioni applicando una certa strumentazione: il vantaggio principale di questo approccio è che un'eventuale vulnerabilità diventa molto più facile da risolvere poiché avremo a disposizione una traccia completa dello stack.

5. conclusione

In questo articolo, abbiamo coperto le vulnerabilità di SQL Injection nelle applicazioni Java, una minaccia molto seria per qualsiasi organizzazione che dipende dai dati per la loro attività, e come prevenirle utilizzando tecniche semplici.

Come al solito, il codice completo per questo articolo è disponibile su Github.

Fondo di persistenza

Ho appena annunciato il nuovo corso Learn Spring , incentrato sui fondamenti di Spring 5 e Spring Boot 2:

>> SCOPRI IL CORSO