Injection SQL et comment l'empêcher?

Haut de persistance

Je viens d'annoncer le nouveau cours Learn Spring , axé sur les principes de base de Spring 5 et Spring Boot 2:

>> VOIR LE COURS

1. Introduction

Bien qu'il s'agisse de l'une des vulnérabilités les plus connues, SQL Injection continue de se classer en tête de la tristement célèbre liste des 10 meilleurs OWASP - qui fait désormais partie de la classe d' injection plus générale .

Dans ce didacticiel, nous explorerons les erreurs de codage courantes en Java qui conduisent à une application vulnérable et comment les éviter à l' aide des API disponibles dans la bibliothèque d'exécution standard de la JVM. Nous couvrirons également les protections que nous pouvons obtenir des ORM comme JPA, Hibernate et autres et les angles morts dont nous devrons encore nous soucier.

2. Comment les applications deviennent-elles vulnérables à l'injection SQL?

Les attaques par injection fonctionnent car, pour de nombreuses applications, la seule façon d'exécuter un calcul donné est de générer dynamiquement du code qui est à son tour exécuté par un autre système ou composant . Si, dans le processus de génération de ce code, nous utilisons des données non fiables sans un nettoyage approprié, nous laissons une porte ouverte aux pirates à exploiter.

Cette déclaration peut sembler un peu abstraite, alors voyons comment cela se produit dans la pratique avec un exemple de manuel:

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); // ... }

Le problème avec ce code est évident: nous avons mis la valeur de customerId dans la requête sans aucune validation . Rien de grave ne se passera si nous sommes sûrs que cette valeur ne proviendra que de sources fiables, mais le pouvons-nous?

Imaginons que cette fonction soit utilisée dans une implémentation d'API REST pour une ressource de compte . Exploiter ce code est trivial: il suffit d'envoyer une valeur qui, concaténée avec la partie fixe de la requête, modifie son comportement prévu:

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

En supposant que la valeur du paramètre customerId n'est pas cochée jusqu'à ce qu'elle atteigne notre fonction, voici ce que nous recevrions:

abc' or '1' = '1

Lorsque nous joignons cette valeur à la partie fixe, nous obtenons l'instruction SQL finale qui sera exécutée:

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

Probablement pas ce que nous voulions…

Un développeur intelligent (ne sommes-nous pas tous?) Penserait maintenant: «C'est idiot! Je n'utiliserais jamais la concaténation de chaînes pour créer une requête comme celle-ci ».

Pas si vite ... Cet exemple canonique est certes idiot mais il y a des situations où nous pourrions encore avoir besoin de le faire :

  • Requêtes complexes avec des critères de recherche dynamiques: ajout de clauses UNION en fonction des critères fournis par l'utilisateur
  • Regroupement ou classement dynamique: API REST utilisées comme backend d'une table de données GUI

2.1. J'utilise JPA. Je suis en sécurité, non?

C'est une idée fausse courante . JPA et d'autres ORM nous évitent de créer des instructions SQL codées à la main, mais ils ne nous empêcheront pas d'écrire du code vulnérable .

Voyons à quoi ressemble la version JPA de l'exemple précédent:

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()); } 

Le même problème que nous avons signalé précédemment est également présent ici: nous utilisons une entrée non validée pour créer une requête JPA , nous sommes donc exposés au même type d'exploit ici.

3. Techniques de prévention

Maintenant que nous savons ce qu'est une injection SQL, voyons comment nous pouvons protéger notre code contre ce type d'attaque. Ici, nous nous concentrons sur quelques techniques très efficaces disponibles en Java et dans d'autres langages JVM, mais des concepts similaires sont disponibles pour d'autres environnements, tels que PHP, .Net, Ruby, etc.

Pour ceux qui recherchent une liste complète des techniques disponibles, y compris celles spécifiques aux bases de données, le projet OWASP maintient une feuille de triche de prévention des injections SQL, qui est un bon endroit pour en savoir plus sur le sujet.

3.1. Requêtes paramétrées

Cette technique consiste à utiliser des instructions préparées avec l'espace réservé de point d'interrogation («?») Dans nos requêtes chaque fois que nous devons insérer une valeur fournie par l'utilisateur. C'est très efficace et, à moins qu'il y ait un bogue dans l'implémentation du pilote JDBC, à l'abri des exploits.

Réécrivons notre exemple de fonction pour utiliser cette technique:

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 }

Ici, nous avons utilisé la méthode prepareStatement () disponible dans l' instance Connection pour obtenir un PreparedStatement . Cette interface étend l'interface standard de Statement avec plusieurs méthodes qui nous permettent d'insérer en toute sécurité des valeurs fournies par l'utilisateur dans une requête avant de l'exécuter.

Pour JPA, nous avons une fonctionnalité similaire:

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

Lors de l'exécution de ce code sous Spring Boot, nous pouvons définir la propriété logging.level.sql sur DEBUG et voir quelle requête est réellement construite pour exécuter cette opération:

// 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=?

Comme prévu, la couche ORM crée une instruction préparée à l'aide d'un espace réservé pour le paramètre customerId . C'est la même chose que nous avons fait dans le cas JDBC simple - mais avec quelques instructions en moins, ce qui est bien.

En prime, cette approche se traduit généralement par une requête plus performante, car la plupart des bases de données peuvent mettre en cache le plan de requête associé à une instruction préparée.

Veuillez noter que cette approche ne fonctionne que pour les espaces réservés utilisés comme valeurs . Par exemple, nous ne pouvons pas utiliser d'espaces réservés pour modifier dynamiquement le nom d'une table:

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

Ici, JPA n'aidera pas non plus:

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

Dans les deux cas, nous obtiendrons une erreur d'exécution.

La principale raison derrière cela est la nature même d'une instruction préparée: les serveurs de base de données les utilisent pour mettre en cache le plan de requête requis pour extraire le jeu de résultats, qui est généralement le même pour toute valeur possible. Ce n'est pas vrai pour les noms de table et autres constructions disponibles dans le langage SQL, telles que les colonnes utilisées dans une clause order by .

3.2. API JPA Criteria

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. Utilisez des WAF ou des solutions de détection d'intrusion similaires: ce sont les exemples typiques de liste noire - généralement, ils sont livrés avec une base de données importante de signatures d'attaques connues et déclenchent une action programmable lors de la détection. Certains incluent également des agents in-JVM qui peuvent détecter les intrusions en appliquant une certaine instrumentation - le principal avantage de cette approche est qu'une éventuelle vulnérabilité devient beaucoup plus facile à corriger puisque nous aurons une trace de pile complète disponible.

5. Conclusion

Dans cet article, nous avons couvert les vulnérabilités d'injection SQL dans les applications Java - une menace très sérieuse pour toute organisation qui dépend des données pour son entreprise - et comment les éviter à l'aide de techniques simples.

Comme d'habitude, le code complet de cet article est disponible sur Github.

Fond de persistance

Je viens d'annoncer le nouveau cours Learn Spring , axé sur les principes de base de Spring 5 et Spring Boot 2:

>> VOIR LE COURS