Archives Wild Code School
Spring

SQL Injection - Practice with Spring

Quête 16 sur 16 · 2 images archivées

Maintenant que tu as appris les bases des injections SQL, il est temps de mettre ce savoir en pratique, dans une application Spring Boot !

Cette quête a pour but de te rendre encore plus conscient·e de l'ampleur des menaces que représentent les injections SQL !

Comme tu es en train d'apprendre Spring Boot, tu as peut-être déjà découvert Spring Data JPA et Hibernate : ils fournissent des solutions de persistance de données prêtes à l'emploi, et incluent des mécanismes de prévention des injections SQL.

Ici, tu vas apprendre à prévenir les attaques par injection SQL, sans t'appuyer sur Hibernate. Même s'il est fortement recommandé d'utiliser les solutions disponibles sur étagère pour des applications réelles, suivre une approche de plus bas niveau t'aidera à comprendre comment les choses fonctionnent en coulisses.

Objectifs

  • Plonger dans le code de quelqu'un d'autre
  • Utiliser des requêtes générées dynamiquement dans une app Spring Boot
  • Exploiter les vulnérabilités exposées par l'app
  • Mettre en place de simples contre-mesures

Planter le décor

Ta mission

Fraîchement sorti·e de la Wild Code School, tu es embauché·e par une startup : BankZecure. Ils ont déjà un prototype précoce d'une application web visant leurs futurs clients, leur permettant de gérer leur compte bancaire en ligne.

Le prototype a été déployé sur un serveur temporaire à accès restreint, afin d'être testé. Hélas, la base de données semble avoir été corrompue pendant la nuit : aucun des testeurs ne peut plus accéder à son compte !

L'ancien dev (qui est parti) ne savait visiblement pas ce qu'il faisait... Ta mission est d'enquêter sur les vulnérabilités, et de les corriger si tu le peux !

À ton arrivée dans l'entreprise, on te donne accès au dépôt de l'application web. Il est temps de te salir les mains !

Pour commencer, fork le dépôt, et clone ton fork.

Mettre en place la base de données

Connecte-toi en tant que root à la console MySQL (mysql -uroot -p).

Puis exécute les requêtes SQL qui suivent. Cela crée la base de données, ainsi qu'un compte non-root, auquel sont accordés les droits d'accès à la base :

sql
CREATE DATABASE IF NOT EXISTS springboot_bankzecure CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE USER bankzecure@localhost IDENTIFIED BY 'Ultr4B4nk@L0nd0n';
GRANT ALL PRIVILEGES ON springboot_bankzecure.* TO bankzecure@localhost;

Déconnecte-toi de la console MySQL, puis exécute ces lignes depuis le shell, pour injecter le schéma de la base, et les données initiales :

bash
mysql -uroot -p springboot_bankzecure < src/main/resources/db/schema.sql
mysql -uroot -p springboot_bankzecure < src/main/resources/db/data.sql

Alerte spoiler : cette quête portant sur les attaques par injection SQL, tu es censé·e bousiller ta base de données à un moment donné... On ne fait pas d'omelette sans casser des œufs !

Quand cela arrive, exécute ceci depuis le shell pour vider les tables et réinjecter les données :

bash
mysql -uroot -p springboot_bankzecure < src/main/resources/db/truncate-tables.sql
mysql -uroot -p springboot_bankzecure < src/main/resources/db/data.sql

Explorer le terrain

Fais un tour pour découvrir l'app !

  • Jette un œil aux fichiers SQL mentionnés ci-dessus :

    • schema.sql décrit deux tables, customer et credit_card (reliée à customer via une clé étrangère).

    • data.sql contient de faux clients, et les données de cartes de crédit associées.

  • Jette un œil aux controllers, repositories, entities et templates qui se trouvent sous le dossier src/main/java/bankzecure/webapp.

  • Bien sûr, lance l'app, avec la commande habituelle mvn spring-boot:run.

Il ne devrait rien y avoir de nouveau ici, puisque le code que tu vas trouver est très similaire à celui des quêtes JDBC & Spring (1 à 3).

Un rapide résumé (les liens suivants sont accessibles une fois l'app lancée localement) :

  • la page d'accueil te présente un formulaire de connexion. Les identifiants de chaque client sont constitués d'un identifiant à 6 chiffres et d'un mot de passe. Trouve la liste complète dans le fichier FakeAccountsCredentials.md du dépôt. Essaie l'un d'entre eux !

  • Après une connexion réussie, on te montre ta page de profil. De là, tu peux :

    • te rendre sur ta page de cartes de crédit (par ex. celle du 3ème utilisateur de la liste)

    • mettre à jour la partie de ton profil que tu es autorisé·e à modifier (essaie !). Cela te ramènera à la même page (bien que sous une URL différente).

Avertissement honnête : comme c'est un prototype, il lui manque beaucoup des fonctionnalités habituelles auxquelles tu t'attendrais : par exemple, l'authentification est perdue si tu recharges ta page. Il est aussi mal fichu (pas de chiffrement des mots de passe ni des cartes de crédit, etc.).

Il est aussi vulnérable aux attaques par injection SQL. Tu vas le hacker !

"Super... Mais par où je commence ?", je t'entends dire...

Eh bien d'abord, sache qu'il y a trois endroits dans l'application qui sont vulnérables à l'injection SQL. On t'encourage à essayer de les trouver par toi-même, dès maintenant !

Cela dit, tu auras peut-être besoin d'un peu de guidage si tu es "bloqué·e". Les étapes suivantes te donneront des conseils, et des solutions. Ne fonce pas tête baissée vers ces dernières, par contre !

↗
Le dépôt de l'application web BankZecure
github.com

Procéder méthodiquement

Même si on fait semblant d'être des hackers pour un moment, avoir le code rend la tâche de détecter les vulnérabilités beaucoup plus facile. Tu vas quand même devoir penser comme un développeur !

Tôt ou tard, tu seras confronté·e au défi de devoir comprendre le code d'un autre développeur.

L'aspect le plus difficile est peut-être de comprendre comment les données circulent d'un point de l'application à l'autre.

Comment faire cela, dans le contexte d'une application Spring Boot ? Eh bien, tu peux commencer par te poser ces questions :

  • quelles étapes ont mené à la page que je suis en train de voir ?

  • puisqu'on se concentre sur les injections SQL : où, dans l'app Spring Boot, reçoit-on des données en entrée de la part du client (navigateur) ? Autrement dit, quelles méthodes de controller utilisent @PathVariable et/ou @RequestParam ?

Prenons un exemple.

La page des cartes de crédit

Supposons que tu te sois connecté·e avec le compte de Bradford Swift (5ème paire d'identifiants de la liste, l'identifier étant 489607).

Rends-toi sur la page Tes cartes de crédit : l'URL dans la barre d'adresse de ton navigateur est http://localhost:8080/customers/489607/credit-cards/. D'abord, tu dois comprendre quelle méthode, dans quel controller, est chargée de gérer cette URL.

D'après son nom, CreditCardController a l'air d'un bon candidat. Jette un œil : la méthode getAll est précédée d'une annotation @GetMapping, avec le "/customers/{identifier}/credit-cards". Bingo ! Elle prend aussi un @PathVariable, ce qui en fait une cible potentielle pour les injections SQL !

À l'intérieur de getAll, on appelle la méthode findByCustomerIdentifier d'une instance de CreditCardRepository, en lui fournissant l'identifiant à 6 chiffres du client qu'on a obtenu depuis l'URL, via l'annotation @PathVariable. Descendons plus profond dans ce terrier de lapin !

CreditCardRepository.findByCustomerIdentifier commence par construire une requête SQL, à partir de code SQL écrit en dur qu'on concatène avec l'identifier reçu en argument. Cette requête vise à récupérer les cartes de crédit appartenant à un utilisateur spécifique, en utilisant l'identifier à 6 chiffres dans une clause WHERE, pour obtenir uniquement les données pertinentes.

Elle établit une connexion à la base de données et la stocke dans connection, puis crée une instance de Statement via connection.createStatement(). C'est un petit peu différent des quêtes JDBC & Spring, où l'on créait plutôt une instance de PreparedStatement.

Elle appelle ensuite la méthode executeQuery de cette instance de Statement, pour réellement déclencher la requête SQL.

Ensuite, une ArrayList d'instances de CreditCard est construite, en bouclant sur resultSet. Si tout se passe bien, cette instance d'ArrayList est renvoyée au controller, qui l'injecte dans le template Thymeleaf customer_cards, via l'attribut creditCards.

Pour résumer, la requête a atterri sur le Controller, qui a interrogé le Model, puis a injecté ses données dans la View.

Comment le hacker ?

Tu te souviens de la différence entre une requête dynamique (dynamic query : requête construite en concaténant des morceaux de texte) et une requête paramétrée (parameterized query : requête contenant des emplacements que l'on remplit avec des valeurs), vue dans la quête "Injections SQL : Prévenir" ? Eh bien, les quêtes JDBC & Spring implémentent la seconde, grâce à PreparedStatement. Ce qui est bien ! Tu devrais absolument faire ça quand tu n'utilises pas Hibernate.

On vient de voir que CreditCardRepository.findByCustomerIdentifier utilise un simple Statement. Ce qui signifie qu'au lieu de remplacer les emplacements (placeholders) ? par des valeurs réelles et échappées, query inclut la valeur de l'identifier telle quelle, et ce query est envoyé à MySQL tel quel.

Ce qui est, pour dire les choses simplement, la racine de tous les maux ! Tu te souviens de l'étrange charge utile (payload : la donnée malveillante injectée) ' OR 1=1 -- et de ses variations, vues dans la quête "Injections SQL : Découvrir" ? Et si on l'essayait tout de suite ?

Essaie de remplacer l'identifiant à 6 chiffres dans l'URL par celle-ci, par ex. [http://localhost:8080/customers/' OR 1=1 --/credit-cards/](http://localhost:8080/customers/' OR 1=1 --/credit-cards/). Si tu utilises Chrome, tu verras que l'URL a été convertie en [http://localhost:8080/customers/' OR 1=1 --/credit-cards/](http://localhost:8080/customers/' OR 1=1 --/credit-cards/). Pourquoi ça ? Il se trouve que certains caractères ne sont pas valides dans les URLs. C'est le cas du caractère espace, entre autres. Pour les inclure dans une URL, tu es censé·e les encoder. %20 est le caractère espace encodé pour URL. Chrome le fait pour toi (et affiche les espaces encodées). Firefox le fait aussi, mais affiche les caractères tels que tu les as tapés.

Cette tentative de hacker l'application ne fonctionnera pas telle quelle : le terminal montre que ton app a rencontré une java.sql.SQLSyntaxErrorException. Tu peux déjà voir que cela perturbe le comportement de l'application.

Il te faudra peut-être quelques tentatives pour trouver la bonne combinaison de caractères. Tu dois t'assurer que le query résultant est toujours du SQL valide ! Ce qui serait difficile à savoir si tu tentais cette attaque sur un serveur distant que tu ne possèdes pas.

Heureusement, tu as le contrôle de l'app. Tu peux toujours utiliser un débogueur ou System.out.println pour connaître la valeur de query avant qu'elle ne soit réellement envoyée à MySQL via executeQuery. De plus, tu verras une erreur apparaître dans le terminal où tourne Spring Boot, si ton SQL n'est pas correct.

Si tu ne trouves pas la bonne combinaison, la solution est juste en dessous ! Et elle vaut cher !

Money, money, money !

L'idée derrière l'attaque est de contourner la clause WHERE qui filtre le jeu de données pour le réduire aux cartes de crédit d'un seul utilisateur.

Mais ' OR 1=1 -- seul ne suffit pas.

  • Le ' au début va fermer le premier "terme" de la clause WHERE : WHERE identifier = ''. L'expression identifier = '' sera toujours évaluée à false.

  • OR 1=1 sera combiné avec le terme précédent, de sorte que le tout, identifier = '' OR 1=1, soit toujours true.

  • Un dernier ' est ajouté à la requête, après identifier. Donc la fin de la requête est --'. Le driver MySQL ne se rend pas compte que la requête est terminée, ce qui explique pourquoi son exécution échoue.

Il existe plusieurs variations valides de l'attaque, qui produisent toutes le même résultat. Dans chacune d'elles, la requête est correctement terminée :

  • ' OR 1=1 --' : ajouter le dernier ' aboutit à avoir un nombre pair de ' à la fin. Cette paire de guillemets simples est ignorée, ce qui n'est pas le cas s'il n'y en a qu'un.

  • ' OR 1=1 -- ; : ajouter un ; indique à MySQL que la requête s'est terminée ici. Comme le driver MySQL de JDBC ne prend pas en charge les requêtes multiples par défaut, tout ce qui vient après ; est ignoré.

  • ' OR ''=' : on n'utilise pas 1=1, mais un motif similaire : le dernier ' ajouté à la requête aboutit à avoir ''='', qui s'évalue à vrai.

Encore une fois, vérifie le query résultant dans la fenêtre de terminal de Spring, pour voir comment ces combinaisons de caractères s'insèrent dans la requête.

Dans tous les cas, tu gagnes accès à toutes les cartes de crédit de tous les clients ! C'est plutôt grave ! Avant de voir comment prévenir cette attaque, prépare-toi, parce que les choses vont devenir encore pires.

Trafiquer le processus de connexion

Du formulaire de connexion à la requête résultante

Jette un œil au template signin_form : le <form> poste ses données vers l'endpoint /customers/authenticate de ton serveur. Cette requête est gérée par CustomerController. Ce controller a deux méthodes, qui acceptent les données entrantes via leurs paramètres (annotés avec @RequestParam). Évidemment, login est la méthode pertinente ici.

Jette un œil à la méthode findByIdentifierAndPassword de CustomerRepository : voici un autre cas de query construit en incluant l'entrée utilisateur (les paramètres identifier et password) telle quelle.

En tant qu'apprenti·e hacker, utiliser ça à ton avantage peut sembler plus délicat au premier abord, puisque la clause WHERE combine deux termes avec AND. Pourtant, tu viens de voir que certaines variations d'attaque amènent MySQL à ignorer la fin de la requête. Essaie de réutiliser ce qui a marché pour les numéros de cartes de crédit !

En effet, l'une d'elles fonctionne. Mais tu finis toujours sur le profil du même client. Essaie de réfléchir à comment tu pourrais affiner l'attaque, afin d'accéder au compte de n'importe qui.

Encore une fois, la solution est donnée juste en dessous.

Usurpation d'identité

L'astuce est de remplacer 1=1 (qui est toujours true) par autre chose, qui sera vrai sous une condition spécifique, que toi tu décides.

Insérer ' OR 1=1 -- ; dans la requête conduit à obtenir tous les clients dans resultSet. Comme le code qui vient ensuite ne prend que le premier enregistrement de ce jeu de résultats, tu dois modifier la requête, de sorte que le jeu de résultats n'inclue pas tous les utilisateurs.

Essaie d'insérer ' OR id > 1 -- ; dans le champ identifiant : cela élimine l'utilisateur avec l'id 1 du jeu de résultats, et tu gagnes accès au compte du client suivant (id 2).

Si tu répètes ça avec d'autres valeurs que 1, tu peux accéder à n'importe quel compte. Il serait même faisable de faire tourner un programme qui le fait automatiquement !

Quant à la troisième vulnérabilité SQLi, ce sera à toi de l'exploiter dans le challenge.

Avant cela, découvre comment corriger ces vulnérabilités !

Prévenir les attaques

L'OWASP est la base de connaissances de référence, pour les sujets de sécurité des applications web.

Sous Projects > Cheat Sheets, tu trouveras un lien vers un dépôt de Cheat Sheets. La SQL Injection Prevention Cheat Sheet est exactement ce qu'il te faut !

Elle référence quatre options pour prévenir les attaques par injection SQL.

Option 1 : Prepared Statements (requêtes préparées)

Tu as déjà utilisé ça, par exemple dans la quête Spring & JDBC 1 : Select, sous "Filtrer les résultats".

Cela consiste à :

  • utiliser PreparedStatement au lieu de Statement,

  • utiliser des emplacements (placeholders) ? qui seront remplacés par des valeurs réelles,

  • fournir ces valeurs avec setString, setInt, etc.

C'est la façon privilégiée de traiter l'injection SQL.

On te laisse le soin d'implémenter cette option défensive dans le challenge !

Option 2 : Stored Procedures (procédures stockées)

Les procédures stockées (stored procedures) en SQL sont comparables aux méthodes en Java : elles te permettent d'encapsuler une logique dans une unité de code réutilisable, qui peut accepter des paramètres.

Creuser le sujet en profondeur dépasserait le cadre de cette quête, mais tu peux apprendre à leur sujet, par exemple sur MySQL Tutorial.

Cela dit, voici un exemple de déclaration d'une procédure stockée. Celle-ci vise à récupérer les cartes de crédit d'un utilisateur spécifique. Elle implémente la même logique SQL que la méthode CreditCardRepository.findByCustomer.

sql
DELIMITER $$
CREATE PROCEDURE getCustomerCreditCards(
  IN customerIdentifier VARCHAR(6)
)
BEGIN
  SELECT cc.* FROM credit_card cc JOIN customer c ON cc.customer_id = c.id WHERE c.identifier = customerIdentifier;
END$$
DELIMITER ;

getCustomerCreditCards accepte un paramètre d'entrée (IN), customerIdentifier. Note qu'on précise le type et la taille de ce paramètre. Lors de l'appel de la procédure stockée, on obtiendra une erreur si l'argument donné ne respecte pas le type et la taille spécifiés.

Tu peux copier-coller ceci dans la console MySQL (assure-toi de faire use springboot_bankzecure avant). Autrement, tu peux aussi le trouver dans la branche fix-option2-stored-procedures, sous src/main/resources/db/stored-procedures.sql, et l'injecter dans la base de données, un peu comme tu l'as fait plus tôt avec data.sql.

Ensuite, toujours dans la console MySQL, appelle-la, en lui fournissant un identifiant valide :

sql
CALL getCustomerCreditCards('807646');

Cela ne fonctionnera pas si tu tentes de l'appeler avec le code SQL malveillant qui était injecté dans les requêtes précédentes, par ex. :

sql
CALL getCustomerCreditCards("' OR 1=1 -- ;");

De plus, bien que la Cheat Sheet de l'OWASP affirme que les procédures stockées ne sont pas toujours sûres, les procédures stockées de MySQL le sont bel et bien.

Les procédures stockées peuvent être appelées depuis du code Java en utilisant CallableStatement, qui est encore une autre variante de Statement.

Si tu es curieux·se d'en apprendre plus après avoir terminé cette quête, va voir la branche fix-option2-stored-procedures, pour voir comment on appelle des procédures stockées depuis du code Java (instructions et explications dans le fichier README.md). C'est optionnel car cette quête est déjà assez longue !

Option 3 : Whitelist Input Validation (validation des entrées par liste blanche)

Encore une fois, la validation des entrées (input validation : vérifier que les données reçues respectent des règles) est un vaste sujet. La section pertinente de la cheat sheet est très spécifique, par ex. comment injecter des mots-clés SQL comme ASC et DESC dans une requête, sans laisser une entrée arbitraire entrer dans la requête. Lis ceci pour une explication de ce que signifie une entrée arbitraire.

Ce qu'on pourrait faire ici, c'est vérifier que la valeur fournie pour identifier est toujours constituée de 6 chiffres numériques. Cela peut être facilement réalisé via l'utilisation d'expressions régulières ou regex, encore un autre vaste sujet ! Les expressions régulières te permettent de savoir si une chaîne respecte un "motif" spécifique. L'expression régulière ^\d{6}$ peut être utilisée pour valider les identifiants :

  • \d{6} correspond à une séquence de 6 chiffres numériques dans la chaîne testée.

  • ^ et $ garantissent que la chaîne ne contient rien d'autre.

Voici un exemple. Pour utiliser les regex, tu dois d'abord faire import java.util.regex.Pattern.

Ensuite, ajoute ce membre privé à tout repository où tu veux mettre en place la validation des entrées :

java
private static final Pattern identifierPattern = Pattern.compile("^\\d{6}$");

Enfin, ajoute ce code en haut d'une méthode de repository qui prend un identifier en paramètre.

java
if (!identifierPattern.matcher(identifier).matches())  {
  throw new Exception( "Improper identifier format." );
}

Ce code empêchera l'exécution de code SQL malveillant, en déclenchant une erreur avant même qu'on ne construise la chaîne query.

Attention ! Cette solution n'est pas infaillible !

L'exemple ci-dessus est "simple" : il est facile de vérifier une valeur avec un format bien défini comme les 6 chiffres numériques.

Quant à des valeurs plus complexes : tu pourrais détecter si elles contiennent ' et ", et les rejeter... Mais cela peut ne pas toujours être pratique : tu peux avoir besoin d'autoriser ces caractères dans les mots de passe, ou dans d'autres valeurs textuelles (comme des articles de blog stockés dans une base de données).

Dans ces cas où tu ne peux pas poser de règles strictes sur les entrées acceptées, tu dois quand même recourir à l'échappement (escaping : neutraliser les caractères spéciaux).

Option 4 : Escaping All User-Supplied Input (échapper toutes les entrées fournies par l'utilisateur)

Bien qu'elle n'arrive qu'en 4ème position, c'est vraiment intéressant de savoir comment fonctionne l'échappement !

Échapper signifie simplement s'assurer que les caractères spéciaux tels que ', ", etc. ne seront pas interprétés comme du code SQL, mais comme des valeurs. Cela consiste surtout à préfixer avec \ les caractères spéciaux de MySQL.

Prenons la requête construite dans CustomerRepository.findByIdentifierAndPassword comme exemple.

Quand on insère le code SQL malveillant décrit dans l'étape "Usurpation d'identité" (' OR 1=1 -- ;), on se retrouve avec la requête SQL suivante :

Le ' au début du code malveillant, et le -- ; à la fin, sont insérés directement, et perturbent le fonctionnement normal de la requête.

On doit préfixer tous les caractères spéciaux (dans ce cas ', =, - et ;) avec \, la requête devient :

Maintenant MySQL va réellement comparer identifier avec la valeur ' OR 1=1 -- ;, au lieu d'interpréter cette valeur comme du code.

Maintenant, comment faire ça ? Cette réponse semble correspondre à notre besoin. Mais attention : la question comme les réponses ont un faible nombre de votes positifs. En résumé : même si c'est une mine d'or, ne te fie pas aveuglément à StackOverflow !

La façon recommandée, dans la Cheat Sheet de l'OWASP, est d'utiliser la propre bibliothèque ESAPI de l'OWASP (doc). Elle est disponible sur le Maven Repository, donc facile à inclure dans un projet Spring Boot.

La classe MySQLCodec fournie par ESAPI se chargera de l'échappement. Voici un exemple :

java
import org.owasp.esapi.codecs.MySQLCodec;

public class App {
  public static void main(String[] args) {
    MySQLCodec codec = new MySQLCodec(MySQLCodec.Mode.STANDARD);
    // Value to escape
    String rawValue = "' OR 1=1 -- ;";
    // Quoting the doc: "array of chars to NOT encode. Use with caution."
    char[] immune = {};
    // Carry out escaping
    String escapedValue = codec.encode(immune, rawValue);
    System.out.println(escapedValue);
  }
}

↗
Documentation Java d'ESAPI
javadoc.io
↗
SQL Injection Prevention Cheat Sheet

4 options défensives contre les injections SQL

github.com

Pour conclure

Comme le démontre le SQLi Hall-of-Shame, les attaques par injection SQL sont encore d'actualité de nos jours !

Des solutions simples et efficaces comme les requêtes préparées ou l'échappement protégeront ton application de leurs conséquences épouvantables.

Dans les applications Spring, tu t'appuieras surtout sur Hibernate pour gérer la communication entre le code Java et la base de données. En coulisses, il met bel et bien en place des mesures défensives contre les SQLi.

Pourtant, comme beaucoup de bibliothèques similaires, dans n'importe quel langage de programmation, Hibernate offre des moyens d'émettre des requêtes SQL brutes. Cela peut s'avérer utile dans des cas spécifiques. Si tu as un jour besoin de faire ça : garde à l'esprit qu'émettre négligemment des requêtes créées dynamiquement rendra quand même ton app vulnérable. Tu devras utiliser la façon d'Hibernate d'envoyer des requêtes préparées.

Plus d'infos sur ce sujet dans la ressource ci-dessous.

↗
SQLi Hall-of-Shame
codecurmudgeon.com
↗
Hibernate

Un aperçu d'Hibernate, et comment éviter de tomber la proie de données malveillantes (tainted : corrompues/contaminées).

owasp.org

Challenge

Bouche les trous !

Ce challenge se compose de deux tâches distinctes :

La tâche obligatoire est de corriger les 3 vulnérabilités d'injection SQL affectant :

  1. la liste des cartes de crédit : /customers/<identifier>/credit-cards (GET)
  2. le processus de connexion : /customers/authenticate (POST)
  3. le processus de mise à jour du profil : /customers/update (POST)

La tâche optionnelle : trouver comment exploiter la vulnérabilité dans la mise à jour SQL du profil.

Pour la tâche obligatoire, tu dois remplacer les Statements et les requêtes dynamiques (construites en concaténant des éléments de chaîne) par des PreparedStatements et des requêtes paramétrées (où la requête contient des emplacements ?, dans lesquels les valeurs sont injectées via setString, etc.).

Cela doit être fait dans toutes les méthodes de CustomerRepository et CreditCardRepository.

Trouve de l'inspiration et des exemples dans les quêtes JDBC (surtout 2 : Insert et 3 : Update).

Pour la tâche optionnelle : tu dois regarder de près le formulaire de mise à jour du profil, et la requête de mise à jour (query) dans CustomerRepository.update. Voici quelques indices :

  • Il y a plus dans le formulaire de mise à jour qu'il n'y paraît... Ce qui signifie que le champ de formulaire que tu dois trafiquer n'est pas nécessairement celui que tu vois. Utilise l'inspecteur !
  • Tu trouveras peut-être utile d'essayer les chaînes SQL malveillantes qui ont été utilisées pour exploiter les autres vulnérabilités.
  • Quand l'issue de ton attaque est apparemment réussie (Profile updated !), vérifie ce qui s'est passé dans la table customer.

Si tu trouves la 3ème vulnérabilité, décris-la dans le fichier README.md de ton fork.

Pousse ton application corrigée sur GitHub et partage le lien de ton fork.

Critères de validation

  • Obligatoire : Statement n'est plus utilisé dans CustomerRepository et CreditCardRepository.
  • Obligatoire : des requêtes paramétrées remplacent les requêtes dynamiques.
  • Optionnel : le fichier README.md contient une description de comment tu as exploité la vulnérabilité, et quel en a été le résultat. Pas besoin d'écrire un roman cependant : 2 lignes devraient suffire !

Now that you've learnt the basics of SQL injections, it's time to put this knowledge in practice, in a Spring Boot application!

This quest intends to make you even more aware of the extent of threats posed by SQL injections!

Since you're in the process of learning Spring Boot, by now you may have learnt about Spring Data JPA and Hibernate: they provide off-the-shelf data persistence solutions, and include SQL injection-prevention mechanisms.

Here, you'll learn how to prevent SQL injection attacks, without relying on Hibernate. Though it is strongly encouraged to use readily available solutions for real-life applications, following a lower-level approach will help you understand how things work under the hood.

Goals

  • Dive into someone else's code
  • Use dynamically-generated queries in a Spring Boot app
  • Exploit vulnerabilities exposed by the app
  • Implement simple counter-measures

Setting the stage

Your mission

Fresh out of the Wild Code School, you get hired by a startup: BankZecure. They already have an early prototype of a web app targeting their future customers, allowing them to manage their banking account online.

The prototype has been deployed to a temporary server with restricted access, in order to be tested. Alas, the database seems to have been corrupted overnight: none of the testers can access their account anymore!

The former dev (who left) obviously didn't know what he was doing... Your mission is to investigate the vulnerabilities, and fix them if you can!

Upon arrival in the company, you're given access to the web app's repository. Time to get your hands dirty!

To begin with, fork the repository, and clone your fork.

Setting up the database

Login as root to the MySQL console (mysql -uroot -p).

Then run the SQL queries that follow. This creates the database, along with a non-root account, which is granted the rights to access the database:

sql
CREATE DATABASE IF NOT EXISTS springboot_bankzecure CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE USER bankzecure@localhost IDENTIFIED BY 'Ultr4B4nk@L0nd0n';
GRANT ALL PRIVILEGES ON springboot_bankzecure.* TO bankzecure@localhost;

Log out from the MySQL console, then run these lines from the shell, to inject the database schema, and the initial data:

bash
mysql -uroot -p springboot_bankzecure < src/main/resources/db/schema.sql
mysql -uroot -p springboot_bankzecure < src/main/resources/db/data.sql

Spoiler alert: this quest being about SQL injection attacks, you're supposed to mess up your database at some point... You can't make an omelette without breaking eggs!

When this happens, run this from the shell to empty the tables and reinject the data:

bash
mysql -uroot -p springboot_bankzecure < src/main/resources/db/truncate-tables.sql
mysql -uroot -p springboot_bankzecure < src/main/resources/db/data.sql

Walking the grounds

Take a tour to discover the app!

  • Check out the aforementioned SQL files:

    • schema.sql describes two tables, customer and credit_card (related to customer via a foreign key).

    • data.sql containa fake customers, and associated credit cards data.

  • Check out the controllers, repositories, entities and templates that lie underneath the src/main/java/bankzecure/webapp folder.

  • Of course, run the app, with the usual mvn spring-boot:run command.

There shouldn't be anything new here, since the code you'll find is very similar to that of the JDBC & Spring quests (1 to 3).

A quick summary (the following links are accessible once the app is running locally):

  • the home page presents you with a login form. Each customer's credentials are made of a 6-digit identifier and a password. Find the whole list in the repo's FakeAccountsCredentials.md file. Try one of them!

  • Upon successful sign-in, you'll be shown your profile page. From here, you may:

    • head to your credit cards page (e.g. that of the 3rd user on the list)

    • update the part of your profile that you're allowed to (try it!). This will lead you back to the same page (though under a different URL).

Fair warning: Since it's a prototype, it lacks many of the usual features you'd expect: for example, authentication is lost if you reload your page. It's also flawed (no encryption of passwords nor credit cards, etc.).

It's also vulnerable to SQL injection attacks. You're going to hack it!

"Great... But where do I begin?", I hear you say...

Well first, know that there are three places in the application that are vulnerable to SQL injection. You're encouraged to try and figure them out by yourself, right now!

That being said, you may need a little guidance if you're "stuck". The next steps will provide advice, and solutions. Don't rush headlong to the latter though!

↗
BankZecure webapp's repository
github.com

Proceeding methodically

Though we're pretending to be hackers for a moment, having the code makes the task of detecting vulnerabilities much easier. You're still gonna have to think like a developer!

Sooner or later, you'll be confronted with the challenge of having to understand another developer's code.

Perhaps the most challenging aspect of it is to figure out how the data flows from one point of the application to the other.

How can you do this, in the context of a Spring Boot application? Well, you can begin by asking yourself those questions:

  • which steps led to the page that I'm currently seeing?

  • since our focus is on SQL injections: where in the Spring Boot app do we receive input from the client (browser)? In other words, which controller methods make use of @PathVariable and/or @RequestParam?

Let's take an example.

The credit cards page

Let's assume you've signed in with Bradford Swift's account (5th pair of credentials in the list, the identifier being 489607).

Head to Your credit cards page: the URL in your browser's address bar is http://localhost:8080/customers/489607/credit-cards/. First, you need to figure out which method, in which controller, is in charge of handling this URL.

From its name, CreditCardController looks like a good candidate. Have a look at it: the getAll method is preceded by a @GetMapping annotation, with the "/customers/{identifier}/credit-cards". Bingo! It also takes in a @PathVariable, which makes it a potential target for SQL injections!

Inside getAll, we call the findByCustomerIdentifier method from a CreditCardRepository instance, providing it with the customer's 6-digit identifier that we obtained from the URL, via the @PathVariable annotation. Let's go deeper down that rabbit hole!

CreditCardRepository.findByCustomerIdentifier begins by building an SQL query, from hardcoded SQL code that we concatenate with the identifier received as an argument. This query aims to get the credit cards belonging to a specific user, using the 6-digit identifier in a WHERE clause, to obtain only the relevant data.

It establishes a connection to the database and stores it in connection, then creates a Statement instance via connection.createStatement(). This is a tad bit different from the JDBC&Spring quests, where we created a PreparedStatement instance instead.

It then calls the executeQuery method of this Statement instance, to actually fire the SQL query.

Then, an ArrayList of CreditCard instances is built, by looping through resultSet. If everything goes well, this ArrayList instance is returned to the controller, which injects it into the customer_cards Thymeleaf template, via the creditCards attribute.

To summarize, the request landed on the Controller, which queried the Model, then injected its data into the View.

How to hack it?

Remember the difference from a dynamic and a parameterized queries, from the "SQL Injections: Prevent" quest? Well, the JDBC & Spring quests implement the latter, thanks to PreparedStatement. Which is fine! You should definitely do that when you're not using Hibernate.

We just saw that CreditCardRepository.findByCustomerIdentifier uses a simple Statement. Which means that instead of replacing ? placeholders with actual, escaped values, query includes identifier's value as is, and this query is sent to MySQL as is.

Which is, put it simply, the root of all evil! Remember the weird ' OR 1=1 -- payload and its variations, from the "SQL Injections: Discover" quest? How about trying it right away?

Try and replace the 6-digit identifier in the URL with it, e.g. [http://localhost:8080/customers/' OR 1=1 --/credit-cards/](http://localhost:8080/customers/' OR 1=1 --/credit-cards/). If you are using Chrome, you will see that the URL has been converted to [http://localhost:8080/customers/' OR 1=1 --/credit-cards/](http://localhost:8080/customers/' OR 1=1 --/credit-cards/). Why is that? It so happens that some characters are not valid in URLs. That's the case of the space character, among others. To include them in an URL, you're supposed to encode them. %20 is the URL-encoded space character. Chrome does it for you (and shows the encoded spaces). Firefox does it too, but shows the characters as you typed them.

This attempt to hack the application won't work as is: the terminal shows that your app encountered an java.sql.SQLSyntaxErrorException. You can already see that this disturbs the application's behaviour.

It may take you a few attempts to find the right combination of characters. You have to ensure that the resulting query is still valid SQL! Which would be hard to know if you were attempting this attack on a distant server which you don't own.

Fortunately, you're in control of the app. You can always use a debugger or System.out.println to figure out query's value before it is actually sent to MySQL via executeQuery. Furthermore, you'll see an error popping up in the terminal where Sprint Boot is running, if your SQL is not correct.

If you don't find the right combination, the solution is just below! And it's worth a lot!

Money, money, money!

The idea behind the attack is to bypass the WHERE clause that filters the dataset down to a single user's credit cards.

But ' OR 1=1 -- alone is not sufficient.

  • The ' at its beginning will close the first "term" of the WHERE clause: WHERE identifier = ''. The identifier = '' expression will always evaluate to false.

  • OR 1=1 will be combined with the previous term, so that the whole, identifier = '' OR 1=1, is always true.

  • A last ' is appended to the query, after identifier. So the end of the query is --'. The MySQL driver doesn't realize the query is concluded, which is why its fails execution fails.

There are several valid variations of the attack, which all produce the same result. In each of them, the query is properly terminated:

  • ' OR 1=1 --': appending the last ' results in having an even number of ' at the end. This pair of single quotes is ignored, which is not the case if there's only one.

  • ' OR 1=1 -- ;: appending a ; tells MySQL that the query finished here. As JDBC's MySQL driver doesn't support multiple queries by default, everything that comes after ; is ignored.

  • ' OR ''=': we don't use 1=1, but a similar pattern: the last ' appended to the query results in having ''='', evaluating to true.

Again, check out the resulting query in Spring's terminal window, to see how these character combinations fit in the query.

In every case, you gain access to all customers' credit cards! That's pretty bad! Before we see how to prevent this attack, brace yourself, because things are going to get even worse.

Tampering with the sign-in process

From the sign-in form to the resulting query

Check out the signin_form template: the <form> posts its data to the /customers/authenticate endpoint of your server. This request is handled by CustomerController. This controller has two methods, which accept incoming data via their parameters (annotated with @RequestParam). Obviously, login is the relevant one here.

Check out CustomerRepository's findByIdentifierAndPassword method: here's another case of a query built by including user input (the identifier and password parameters) as is.

As a would-be hacker, using this to your advantage may seem trickier at first glance, since the WHERE clause combines two terms with AND. Still, you just saw that some attack variations lead MySQL to ignore the end of the query. Try to reuse what worked for the credit card numbers!

Indeed, one of them works. But you always end up on the same customer's profile. Try and think how you could refine the attack, in order to gain access to anyone's account.

Again, the solution is given is just below.

Identity theft

The trick is to replace 1=1 (which is always true) with something else, which will be true on a specific condition, that you decide.

Inserting ' OR 1=1 -- ; in the query leads to getting all the customers in resultSet. Since the code that comes after only takes the first record of this result set, you have to alter the query, so that the result set doesn't include all the users.

Try inserting ' OR id > 1 -- ; in the identifier field: this eliminates the user with id 1 from the result set, and you gain access to the next customer's account (id 2).

If you repeat this with other values than 1, you can get access to any account. It would even be feasible to run a program that does it automatically!

As for the third SQLi vulnerability, it will be up to you to exploit it in the challenge.

Before that, discover how to fix these vulnerabilities!

Preventing the attacks

The OWASP is the reference knowledge base, for web application security topics.

Under Projects > Cheat Sheets, you'll find a link to a repository of Cheat Sheets. The SQL Injection Prevention Cheat Sheet is just what you need!

It references four options to prevent SQL injection attacks.

Option 1: Prepared Statements

You've already used this, for example in the Spring & JDBC 1 : Select quest, under "Filtering the results".

It consists of:

  • using PreparedStatement instead of Statement,

  • using ? placeholders that will be replaced with actual values,

  • providing these values with setString, setInt, etc.

This is the preferred way of addressing SQL injection.

We'll leave it up to you to implement this defensive option in the challenge!

Option 2: Stored Procedures

Stored Procedures in SQL are comparable to methods in Java: they allow you to encapsulate some logic in a reusable unit of code, which can accept parameters.

Digging in-depth into topic would go beyond the scope of this quest, but you can learn about them, for example on MySQL Tutorial.

That being said, here's an example of a stored procedure declaration. This one aims to get a specific user's credit cards. It implements the same SQL logic as the CreditCardRepository.findByCustomer method.

sql
DELIMITER $$
CREATE PROCEDURE getCustomerCreditCards(
  IN customerIdentifier VARCHAR(6)
)
BEGIN
  SELECT cc.* FROM credit_card cc JOIN customer c ON cc.customer_id = c.id WHERE c.identifier = customerIdentifier;
END$$
DELIMITER ;

getCustomerCreditCards accepts one input (IN) parameter, customerIdentifier. Note that we specify this parameter's type and size. Upon calling the stored procedure, we'll get an error if the given argument doesn't comply with the specified type and size.

You may copy-paste this to MySQL console (make sure you use springboot_bankzecure before). Alternatively, you can also find it in the fix-option2-stored-procedures branch, under src/main/resources/db/stored-procedures.sql, and inject it into the database, much like you did earlier with data.sql.

Then, still in the MySQL console, call it, providing it with a valid identifier:

sql
CALL getCustomerCreditCards('807646');

It won't work if you attempt to call it with the malicious SQL code that was injected in the queries before, e.g.:

sql
CALL getCustomerCreditCards("' OR 1=1 -- ;");

Additionnally, though the OWASP Cheat Sheet states that stored procedures are not always safe, MySQL's stored procedures actually are.

Stored Procedures can be called from Java code using CallableStatement, which is another variation of Statement.

If you're curious to learn more after completing this quest, check out the fix-option2-stored-procedures, to see how to we call stored procedures from Java code (instructions and explanations in the README.md file). This is optional since this quest is already quite long!

Option 3: Whitelist Input Validation

Again, input validation is a broad topic. The relevant section of the cheat sheet is very specific, e.g. how to inject SQL keywords like ASC and DESC in a query, without letting arbitrary input into the query. Read this for an explanation of what arbitrary input means.

What we could do here is verify that the provided identifier value always is made of 6 numeric digits. This can be easily achieved via the use of Regular expressions or regex - yet another broad topic! Regular expressions allow you to find out if a string complies to a specific "pattern". The ^\d{6}$ regular expression can be used to validate identifiers:

  • \d{6} matches a sequence of 6 numeric digits in the string being tested.

  • ^ and $ ensure that the string contains nothing else.

Here's an example. In order to use regex, you first need to import java.util.regex.Pattern.

Then add this private member to any repository where you want to implement input validation:

java
private static final Pattern identifierPattern = Pattern.compile("^\\d{6}$");

Last, add this code at the top of a repository method which takes an identifier as a parameter.

java
if (!identifierPattern.matcher(identifier).matches())  {
  throw new Exception( "Improper identifier format." );
}

This code will prevent malicious SQL code to be executed, by triggering an error before we even build the query string.

Warning! This solution is not bullet-proof!

The example above is "simple": it's easy to check if a value with a well-defined format like the 6 numeric digits.

As for mor more complex values: you could detect if they contain ' and ", and reject them... But that may not always be pratical: you may need to allow these characters in passwords, or in other text values (like blog articles stored in a database).

In these cases where you can't put strict rules on the accepted inputs, you still have to resort to escaping.

Option 4: Escaping All User-Supplied Input

Though it comes only as the 4th option, it is really interesting to know how escaping works!

Escaping simply means making sure that special characters such as ', ", etc. won't be interpreted as SQL code, but as values. It mostly consists of prepending \ to MySQL's special characters.

Let's take the query built in CustomerRepository.findByIdentifierAndPassword as an example.

When we insert the malicious SQL code described in the "Identity theft" step (' OR 1=1 -- ;), we end up with the following SQL query:

The ' at the start of the malicious code, and the -- ; at the end, are inserted directly, and tamper with the normal operation of the query.

We have to prepend all the special characters (in this case ', =, - and ;) with \, the query becomes:

Now MySQL will actually compare identifier with the value ' OR 1=1 -- ;, instead of interpreting that value as code.

Now, how to do this? This answer seems to correspond to our need. But beware: both the question and answers have a low count of upvotes. Bottom line: though it's a gold mine, don't rely blindly on StackOverflow!

The recommended way, in the OWASP Cheat Sheet, is to use OWASP's own ESAPI library (doc). It's available on Maven Repository, thus easy to include in a Spring Boot project.

The MySQLCodec class provided by ESAPI will take care of escaping. Here's an example:

java
import org.owasp.esapi.codecs.MySQLCodec;

public class App {
  public static void main(String[] args) {
    MySQLCodec codec = new MySQLCodec(MySQLCodec.Mode.STANDARD);
    // Value to escape
    String rawValue = "' OR 1=1 -- ;";
    // Quoting the doc: "array of chars to NOT encode. Use with caution."
    char[] immune = {};
    // Carry out escaping
    String escapedValue = codec.encode(immune, rawValue);
    System.out.println(escapedValue);
  }
}

↗
ESAPI Java documentation
javadoc.io
↗
SQL Injection Prevention Cheat Sheet

4 defensive options against SQL injections

github.com

Final thoughts

As the SQLi Hall-of-Shame demonstrates, SQL injection attacks are still a thing nowadays!

Simple and effective solutions such as prepared statements or escaping will protect your application from their ghastly consequences.

In Spring applications, you'll mostly rely on Hibernate to handle the communication between Java code and the database. Under the hood, it does implement defensive measures against SQLi.

Still, like many similar libraries, in any programming language, Hibernate provides ways to emit raw SQL queries. This can prove useful in specific cases. If you ever need to do this: keep in mind that carelessly emitting dynamically created queries will still make your app vulnerable. You'll have to use the Hibernate way of sending prepared statements.

More on this topic in the resource below.

↗
SQLi Hall-of-Shame
codecurmudgeon.com
↗
Hibernate

An overview of Hibernate, and how to avoid falling prey to malicious (tainted) data.

owasp.org

Challenge

Fill the holes!

This challenge consists of two distinct tasks:

The required task is fix the 3 SQL injection vulnerabilities affecting:

  1. the credit cards list: /customers/<identifier>/credit-cards (GET)
  2. the sign-in process: /customers/authenticate (POST)
  3. the profile update process: /customers/update (POST)

The optional task: find out how to exploit the vulnerability in the SQL profile update.

As for the required task, you have to replace the Statements and the dynamic queries (built by concatenating string elements) with PreparedStatements and parameterized queries (where the query contains ? placeholders, in which values are injected via setString, etc.).

This has to be made in all the methods of CustomerRepository and CreditCardRepository.

Find inspiration and examples in the JDBC quests (especially 2: Insert and 3: Update).

As for the optional task: you have to look closely at the profile update form, and the update query in CustomerRepository.update. Here are a few hints:

  • There's more to the update form than meets the eye... Which means that the form field you have to tamper with is not necessarily the one you see. Use the inspector!
  • You may find it useful to try the malicious SQL strings that were used to exploit the other vulnerabilities.
  • When the outcome of your attack is apparently successful (Profile updated!), check what happened in the customer table.

If you figure out the 3rd vulnerability, describe it in your fork's README.md file.

Push your fixed application to GitHub and share the link of your fork.

Validation criterias

  • Mandatory: Statement is not used anymore in CustomerRepository and CreditCardRepository.
  • Mandatory: parameterized queries replace dynamic queries.
  • Optional: the README.md file contains a description of how you exploited the vulnerability, and what was its outcome. No need to write a novel though: 2 lines should do!