Injection SQL

Catégorie OWASP : MASVS-CODE : qualité du code

Présentation

L'injection SQL exploite des applications vulnérables en insérant du code dans les instructions SQL pour accéder aux bases de données sous-jacentes au-delà de leurs interfaces volontairement exposées. L'attaque peut exposer des données privées, corrompre le contenu d'une base de données et même compromettre l'infrastructure du backend.

SQL peut être vulnérable à l'injection via des requêtes créées dynamiquement en concaténant l'entrée utilisateur avant l'exécution. L'injection SQL, ciblant le Web, les mobiles et les applications de base de données SQL, figure généralement dans le top 10 de l'OWASP des failles Web. Des pirates informatiques ont utilisé cette technique pour de nombreux piratages très connus.

Dans cet exemple de base, une entrée utilisateur non échappée dans une zone de numéro de commande peut être insérée dans la chaîne SQL et interprétée comme étant la requête suivante :

SELECT * FROM users WHERE email = 'example@example.com' AND order_number = '251542'' LIMIT 1

Ce code génère une erreur de syntaxe de la base de données dans une console Web, ce qui montre que l'application peut être vulnérable à l'injection SQL. Remplacer le numéro de commande par 'OR 1=1– signifie que l'authentification peut être effectuée, car la base de données évalue l'instruction comme True, car un est toujours égal à un.

De la même façon, cette requête renvoie toutes les lignes d'une table :

SELECT * FROM purchases WHERE email='admin@app.com' OR 1=1;

Fournisseurs de contenu

Les fournisseurs de contenu proposent un mécanisme de stockage structuré qui peut être limité à une application ou exporté pour être partagé avec d'autres applications. Les autorisations doivent être définies selon le principe du moindre privilège : un ContentProvider exporté ne peut avoir qu'une seule autorisation spécifiée pour la lecture et l'écriture.

Il convient de noter que toutes les injections SQL ne mènent pas à une exploitation. Certains fournisseurs de contenu accordent déjà aux lecteurs un accès complet à la base de données SQLite. La possibilité d'exécuter des requêtes arbitraires n'offre que peu d'avantages. Des problèmes de sécurité sont possibles avec les configurations suivantes :

  • Plusieurs fournisseurs de contenu partagent un fichier de base de données SQLite.
    • Dans ce cas, chaque table peut être destinée à un fournisseur de contenu unique. Une injection SQL réussie chez un fournisseur de contenu accorderait l'accès à toutes les autres tables.
  • Un fournisseur de contenu dispose de plusieurs autorisations pour du contenu au sein de la même base de données.
    • L'injection SQL dans un seul fournisseur de contenu accordant l'accès avec différents niveaux d'autorisation peut entraîner un contournement local des paramètres de sécurité ou de confidentialité.

Impact

L'injection SQL peut exposer des données utilisateur ou d'application sensibles, contourner les restrictions d'authentification et d'autorisation, et rendre les bases de données vulnérables à la corruption ou à la suppression. Les conséquences peuvent s'avérer dangereuses et durables pour les utilisateurs dont les données personnelles ont été exposées. Les fournisseurs d'applications et de services risquent de perdre leur propriété intellectuelle ou la confiance des utilisateurs.

Stratégies d'atténuation

Paramètres remplaçables

L'utilisation de ? en tant que paramètre remplaçable dans les clauses de sélection et d'un tableau distinct d'arguments de sélection associe l'entrée utilisateur directement à la requête plutôt que de l'interpréter dans une instruction SQL.

Kotlin

// Constructs a selection clause with a replaceable parameter.
val selectionClause = "var = ?"

// Sets up an array of arguments.
val selectionArgs: Array<String> = arrayOf("")

// Adds values to the selection arguments array.
selectionArgs[0] = userInput

Java

// Constructs a selection clause with a replaceable parameter.
String selectionClause =  "var = ?";

// Sets up an array of arguments.
String[] selectionArgs = {""};

// Adds values to the selection arguments array.
selectionArgs[0] = userInput;

L'entrée utilisateur est directement liée à la requête au lieu d'être traitée comme une requête SQL, ce qui empêche l'injection de code.

Voici un exemple plus élaboré illustrant la requête d'une application d'achat pour récupérer des détails d'achat avec des paramètres remplaçables :

Kotlin

fun validateOrderDetails(email: String, orderNumber: String): Boolean {
    val cursor = db.rawQuery(
        "select * from purchases where EMAIL = ? and ORDER_NUMBER = ?",
        arrayOf(email, orderNumber)
    )

    val bool = cursor?.moveToFirst() ?: false
    cursor?.close()

    return bool
}

Java

public boolean validateOrderDetails(String email, String orderNumber) {
    boolean bool = false;
    Cursor cursor = db.rawQuery(
      "select * from purchases where EMAIL = ? and ORDER_NUMBER = ?",
      new String[]{email, orderNumber});
    if (cursor != null) {
        if (cursor.moveToFirst()) {
            bool = true;
        }
        cursor.close();
    }
    return bool;
}

Utiliser des objets PreparedStatement

L'interface PreparedStatement précompile les instructions SQL en un objet pouvant ensuite être exécuté efficacement plusieurs fois. PreparedStatement utilise ? comme espace réservé pour les paramètres, ce qui rendrait la tentative d'injection compilée suivante inefficace :

WHERE id=295094 OR 1=1;

Dans ce cas, l'instruction 295094 OR 1=1 est lue comme valeur pour l'ID, probablement sans résultat, tandis qu'une requête brute interpréterait l'instruction OR 1=1 comme une autre partie de la clause WHERE. L'exemple ci-dessous présente une requête paramétrée :

Kotlin

val pstmt: PreparedStatement = con.prepareStatement(
        "UPDATE EMPLOYEES SET ROLE = ? WHERE ID = ?").apply {
    setString(1, "Barista")
    setInt(2, 295094)
}

Java

PreparedStatement pstmt = con.prepareStatement(
                                "UPDATE EMPLOYEES SET ROLE = ? WHERE ID = ?");
pstmt.setString(1, "Barista")
pstmt.setInt(2, 295094)

Utiliser des méthodes de requête

Dans cet exemple plus long, selection et selectionArgs de la méthode query() sont combinés pour créer une clause WHERE. Comme les arguments sont fournis séparément, ils sont échappés avant leur combinaison, ce qui empêche l'injection SQL.

Kotlin

val db: SQLiteDatabase = dbHelper.getReadableDatabase()
// Defines a projection that specifies which columns from the database
// should be selected.
val projection = arrayOf(
    BaseColumns._ID,
    FeedEntry.COLUMN_NAME_TITLE,
    FeedEntry.COLUMN_NAME_SUBTITLE
)

// Filters results WHERE "title" = 'My Title'.
val selection: String = FeedEntry.COLUMN_NAME_TITLE.toString() + " = ?"
val selectionArgs = arrayOf("My Title")

// Specifies how to sort the results in the returned Cursor object.
val sortOrder: String = FeedEntry.COLUMN_NAME_SUBTITLE.toString() + " DESC"

val cursor = db.query(
    FeedEntry.TABLE_NAME,  // The table to query
    projection,            // The array of columns to return
                           //   (pass null to get all)
    selection,             // The columns for the WHERE clause
    selectionArgs,         // The values for the WHERE clause
    null,                  // Don't group the rows
    null,                  // Don't filter by row groups
    sortOrder              // The sort order
).use {
    // Perform operations on the query result here.
    it.moveToFirst()
}

Java

SQLiteDatabase db = dbHelper.getReadableDatabase();
// Defines a projection that specifies which columns from the database
// should be selected.
String[] projection = {
    BaseColumns._ID,
    FeedEntry.COLUMN_NAME_TITLE,
    FeedEntry.COLUMN_NAME_SUBTITLE
};

// Filters results WHERE "title" = 'My Title'.
String selection = FeedEntry.COLUMN_NAME_TITLE + " = ?";
String[] selectionArgs = { "My Title" };

// Specifies how to sort the results in the returned Cursor object.
String sortOrder =
    FeedEntry.COLUMN_NAME_SUBTITLE + " DESC";

Cursor cursor = db.query(
    FeedEntry.TABLE_NAME,   // The table to query
    projection,             // The array of columns to return (pass null to get all)
    selection,              // The columns for the WHERE clause
    selectionArgs,          // The values for the WHERE clause
    null,                   // don't group the rows
    null,                   // don't filter by row groups
    sortOrder               // The sort order
    );

Utiliser SQLiteQueryBuilder correctement configuré

Les développeurs peuvent mieux protéger les applications à l'aide de SQLiteQueryBuilder, une classe qui permet de créer des requêtes à envoyer aux objets SQLiteDatabase. Voici les configurations recommandées :

Utiliser la bibliothèque Room

Le package android.database.sqlite fournit les API nécessaires pour utiliser des bases de données sur Android. Toutefois, cette approche nécessite d'écrire du code peu élaboré et manque de vérification des temps de compilation des requêtes SQL brutes. À mesure que les graphiques de données changent, les requêtes SQL concernées doivent être mises à jour manuellement. Ce processus est chronophage et source d'erreurs.

Une solution de haut niveau consiste à utiliser la bibliothèque Room Persistence en tant que couche d'abstraction pour les bases de données SQLite. Les fonctionnalités de Room incluent les éléments suivants :

  • Une classe de base de données servant de point d'accès principal pour la connexion aux données persistantes de l'application
  • Des entités de données représentant les tables de la base de données
  • Des objets d'accès aux données, qui fournissent des méthodes permettant à l'application d'interroger, de mettre à jour, d'insérer et de supprimer des données

Voici les principaux avantages de Room :

  • La vérification du temps de compilation des requêtes SQL
  • La réduction du code récurrent susceptible de générer des erreurs
  • Une migration simplifiée des bases de données

Bonnes pratiques

L'injection SQL est une attaque puissante contre laquelle il peut être difficile d'être totalement résilient, en particulier avec des applications volumineuses et complexes. Des mesures de sécurité supplémentaires doivent être mises en place pour limiter la gravité des potentielles failles des interfaces de données, comme :

  • Des hachages fiables, à sens unique et salés pour chiffrer les mots de passe :
    • Un chiffrement AES 256 bits pour les applications commerciales.
    • Des tailles de clés publiques de 224 ou 256 bits pour la cryptographie à courbe elliptique.
  • La limitation des autorisations.
  • La structuration précise des formats de données et la vérification de la conformité des données au format attendu.
  • Éviter si possible de stocker des données utilisateur sensibles ou à caractère personnel (par exemple, implémenter la logique d'application par hachage au lieu de transmettre ou de stocker des données).
  • La réduction du nombre d'API et d'applications tierces qui accèdent aux données sensibles.

Ressources