Accueil ¦ Blog ! ¦ Perso ¦ Profil pro ¦ Articles ¦ Liens recommandés ¦ Préférences ¦ À propos

The most dangerous phrase in the language is, “We''ve always done it this way.”Rear Admiral Grace Murray Hopper

Pulp Fiction, © Miramax

Injection SQL

TLDR

  • toute donnée transmise via une interface est dangereuse
  • toute donnée utilisée pour fabriquer une requête doit être échappée
  • choisissez une méthode unique (échappement manuel, prédicats préparés, ORM)
  • développeur ? lisez l'article, bordel, c'est important !

C'est un sujet fort discuté, parfois mal connu ou mal compris, même parmi des développeurs chevronnés. Les injections SQL sont une classe de vulnérabilité qu'on retrouve potentiellement dans toutes les applications manipulant des bases de données qui utilisent SQL.

On pourrait se dire que seules les applications Web sont concernées par ces problèmes. C'est très faux, on peut ainsi trouver des vulnérabilités dans les applications Google Android, en raison d'un usage intensif de SQLite, une base de données sur fichier. La liste est longue comme le bras, alors nous n'allons pas s'embêter à tout cataloguer.

Ainsi je me propose de vous montrer quel est le problème puis comment le mitiger. On finira par montrer ce qu'il ne faut surtout pas faire…

Dans les exemples, ce sera essentiellement du PHP qui sera utilisé. Mais il faut avoir à l'esprit que le problème est totalement orthogonal au choix du language de programmation utilisé.

La base

Pour enregistrer une donnée en base, on commence souvent par créer une table, et on lui ajoute des données en utilisant une requête SQL paramétrée. Pour l'exemple :

<?php

$create = 'create table if not exists vilains (firstname text, surname text)';

$firstname = 'Freddy';
$surname = 'Krueger';
$insert = "insert into vilains (firstname, surname) values ('$firstname', '$surname')";

$con = new mysqli('localhost', null, null, 'test');

$con->query($create)
	or die($con->error);

$con->query($insert)
	or die($con->error);

Il n'y a ici pas d'injection SQL possible. En effet, la requête est essentiellement écrite sous la forme d'une chaîne littérale PHP, qui incorpore deux variables qui sont définies comme des chaînes litérales. On pourra objecter que ces deux variables sont inutiles, mais peut-être que l'auteur a déterminé que les valeurs à insérer changeront et a donc anticipé.

Fonction

Comme anticipé, le développeur à généralisé l'insertion de vilains et a encapsulé la logique dans une fonction.

<?php

$create = 'create table if not exists vilains (firstname text, surname text)';

$con = new mysqli('localhost', null, null, 'test');
$con->query($create)
	or die($con->error);

function insert_vilain($firstname, $surname)
{
	global $con;
	$insert = "insert into vilains (firstname, surname) values ('$firstname', '$surname')";

	$con->query($insert)
		or die($con->error);
}

foreach
	(
		[ (object)
			[ 'firstname' => 'Freddy'
			, 'surname' => 'Krueger'
			]
		, (object)
			[ 'firstname' => 'Jason'
			, 'surname' => 'Todd'
			]
		] as $vilain
	)
	insert_vilain($vilain->firstname, $vilain->surname);

Ici l'injection SQL devient possible. Il n'est en effet pas possible, depuis la fonction insert_vilain, de connaître la qualité du contenu des variables. Il peut y avoir n'importe quoi, et ça là que le danger réside. Le plus l'usage de ces variables intégrées dans le SQL est éloigné de leur définition, le plus probable est qu'elle contienne n'importe quoi. Et un client de la fonction, même soi à venir, finira tôt ou tard à fournir du contenu problématique.

Attaque

Le code a donc vécu, personne ne s'est inquiété des injections, personne n'a jamais vraiment compris ce que c'était. Après tout, on est trop petit pour être une cible des vilains pirates. Ainsi un service « REST » a vu le jour :

<?php

////////////////////////////////////////////////////////////////////////////////
//
// WARNING: be advise not to use this code in production
//			This code is faulty and shouldn't be used but learning purpose.
//
//			It contains:
//
//				* SQL injections
//				* XSS
// 				* Header injection
// 				* is prone to DDOS
// 				* data disclosure
// 				* many business logic errors
// 				* many control flow logic errors
//
////////////////////////////////////////////////////////////////////////////////

ini_set('html_errors', 'Off');
header('Content-type: application/json');
$create = 'create table if not exists vilains (firstname text, surname text)';

$con = new mysqli('db', null, null, 'test');
if(false === $con->query($create))
{
	$result = ['error' => $con->error];
	$result = json_encode($result);
}

$result = [];

if(isset($_POST['firstname']) && isset($_POST['surname']))
{
	extract($_POST);
	$result = insert_vilain($firstname, $surname);
	if(true === $result)
	{
		header('HTTP/1.1 201 Created');
		header('Location: ' . $_SERVER['PHP_SELF'] . "?firstname=$firstname");
		die();
	}
	else
	{
		header('HTTP/1.1 500 Internal Server Error');
		die(json_encode($result));
	}
}
elseif(isset($_GET['firstname']))
{
	extract($_GET);

	if('DELETE' === $_SERVER['REQUEST_METHOD'])
	{
		$result = delete_vilains_by_firstname($firstname);
		if(true === $result)
		{
			header('HTTP/1.1 204 No Content');
			die();
		}
		elseif(empty($result))
		{
			header('HTTP/1.1 404 Not Found');
			die('{"firstname": "'. $firstname . '"}');
		}
		else
		{
			header('HTTP/1.1 500 Internal Server Error');
			die(json_encode($result));
		}
	}
	else
	{
		$result[$firstname] = get_vilains_by_firstname($firstname);
		$result = json_encode($result);
		empty($result[$firstname])
			and header('HTTP/1.1 404 Not Found');
		header('Content-Length: ' . strlen($result));
		die($result);
	}
}
else
{
	$result['all'] = get_all_vilains();
	$result = json_encode($result);
	header('Content-Length: ' . strlen($result));
	die($result);
}

$result = json_encode($result);
if(false === $result)
	$result = '{ "error": "' . json_last_error() . '"}';

header('Content-Length: ' . strlen($result));
die($result);

function get_all_vilains()
{
	global $con;
	$select = 'select * from vilains';
	$result = $con->query($select);

	return false === $result ? $con->error : $result->fetch_all();
}

function get_vilains_by_firstname($firstname)
{
	global $con;
	$select = "select * from vilains where firstname = '$firstname'";
	$result = $con->query($select);

	return false === $result ? $con->error : $result->fetch_assoc();
}

function delete_vilains_by_firstname($firstname)
{
	global $con;
	$delete = "delete from vilains where firstname = '$firstname'";
	$result = $con->query($delete);

	return false === $result ? $con->error : true;
}

function insert_vilain($firstname, $surname)
{
	global $con;
	$insert = "insert into vilains (firstname, surname) values ('$firstname', '$surname')";

	return $con->query($insert) ? true : $con->error;
}

Je dois avouer qu'en expérimentant pour cet article, je me suis rendu compte qu'un certain nombre d'attaques qui me semblaient possibles n'était pas forcément envisageables. En effet, je croyais qu'en utilisant PHP avec MySQL, je pourrais démontrer qu'on peut supprimer massivement de la donnée (delete après un select, instructions drop ou truncate insérées à la suite d'une clause arbitraire). Mais malheureusement, ce n'est pas aussi simple : l'API mysqli ne permet pas les requêtes en série !

Ce code est rempli de points problématiques.

Le voleur

En forgeant une URL, et pour peu que l'utilisateur MySQL utilisé par notre API a accès à l'espace mysql, on peut exposer les utilisateurs et voler le hâchis de mots de passe :

#!/bin/bash

declare -r site="$1"
declare -r payload="' union select concat(user, '.', host), password from mysql.user union select 1, '"

curl -i \
	"http://$site/03-api.php?firstname=$(echo $payload | jq -Rr @uri)"
echo

La vulnérabilité se situe dans la fonction get_vilains_by_first_name, où le contenu du tableau HTTP GET est directement injecté dans la requête. La charge ' union select concat(user, '.', host), password from mysql.user union select 1, ' permet d'obtenir cette requête :

select * from vilains where firstname = '' union select concat(user, '.', host), password from mysql.user union select 1, ''

Le travail du pirate aura été facilité par le fait que nous renvoyons l'erreur SQL au client de l'APIa, ce qui lui permet de deviner combien de colonnes notre requête sélectionne, pour pouvoir ajuster l'union (car on se doit d'avoir le même nombre de colonnes dans chaque result set de l'union).

Cette attaque peut être limitée par une politique de restriction des accès à l'espace mysql par l'utilisateur mysql utilisé par notre API. Mais si une telle politique est souhaitable, n'oubliez pas que votre application a certainement une table d'utilisateurs… avec des mots de passe… en clair peut-être… pas salés… mais je m'égare, ceci sera pour un autre article !

Le barbare

Si nous ne pouvons voler, autant détruire nos ennemis :

#!/bin/bash

declare -r site="$1"
declare -r payload="one' or true or '"

curl -i -X DELETE \
	"http://$site/03-api.php?firstname=$(echo $payload | jq -Rr @uri)"
echo

Ici la vulnérabilité se situe dans delete_vilains_by_firstname. C'est le même problème que précédemment, sauf que ce coup-ci, on permet la suppression pure et simple de toutes les données de la table. En injectant , on obtient la requête :

delete from vilains where firstname = 'one' or true or ''

Imaginez que c'est la table de vos clients !

Protection

On pourrait croire que tout est perdu, que notre application est irrécupérable, mais j'ai un sort pour ce genre de situation ! Certains vous diront qu'il faut qualifier les données avant de les insérer, d'autres vous mettront des filtres pourris qui ne sont qu'autant de malédictions et ceux du fond prononceront des borborismes inaudibles.

En réalité, il n'y a, à mon sens, qu'une seule démarche acceptable :

  • considérer toute donnée comme hostile
  • gérer les traversées de dioptre
  • traduire la donnée au plus proche de la traversée

Moi d'abord j'tappe

On va tout de suite évacuer le premier point : au moment où vous avez une variable contenant une donnée, vous ne pouvez savoir que cette donnée sera sans effet de bord dans le domaine métier où vous allez la transférer. On ne connait pas l'historique de la donnée avec une certitude absolue. Même si la donnée provient de votre base de données, cette donnée peut contenir des données lexicalement significatives dans le domaine cible. En gros, il faut faire attention à ce qu'on écrit, et dans quoi on l'écrit.

Interprétation, traduction et confusion

Les langages de programmation et les formats de données sont structurées selon des règles précises. Certaines combinaisons auront une charge sémantique dans l'un, seront interdites dans l'autre, voire désigneront un concept complètement différent dans le plus étrange d'entre tous.

L'exemple qui vient immédiatement, et donc nous intéresse le plus ici, c'est l'apostrophe '. Dans certaines langues naturelles, comme celle dans laquelle nous discutons, l'apostrophe est utilisée pour éluder des caractères. Elle seront donc légions dans du contenu en anglais ou en français, ce qui posera tôt où tard des problèmes quand on tentera d'insérer, par exemple, « c'est la misère ! » en base. On aura une erreur de syntaxe et la solution qui viendra alors à l'esprit est d'« échapper » les apostrophes avec addslashes. C'est une approche naïve assez commune, que nous avons tous utilisée. Il était même possible, en des temps reculés, d'activer l'échappement automatique des apostrophes dans les variables super-globales de PHP. C'est évidement une catastrophe, car en bout de ligne, il n'y a qu'une partie des problèmes résolus, en introduisant des bogues subtils et abscons. Une malédiction.

La seule alternative est de protéger l'ensemble des données utilisées pour construire notre requête, sans préjuger de la structure du domaine cible.

Échappement

Reprenons le premier exemple.

<?php

$create = 'create table if not exists vilains (firstname text, surname text)';

$firstname = 'Freddy';
$surname = 'Krueger';
$insert_fmt = "insert into vilains (firstname, surname) values ('%s', '%s')";

$con = new mysqli('localhost', null, null, 'test');

$con->query($create)
	or die($con->error);

$insert = sprintf
	( $insert_fmt
	, $con->real_escape_string($firstname)
	, $con->real_escape_string($surname)
	);

$con->query($insert)
	or die($con->error);

J'utilise sprintf pour éviter la concaténation de chaînes. Ce sera pour un autre article, mais en gros c'est pour améliorer la lisibilité. C'est très difficile de lire de la concaténation de chaîne fait à la schlag.

Le point important, c'est mysqli::real_escape_string. Cette fonction va prendre une chaîne en entrée, la soumettre à la base de données, et fournir une chaîne aseptisée, qui peut être utilisée comme valeur dans une chaîne litérale SQL sans plus aucun risque.
On peut alors généraliser et utiliser une fonction de ce type :

function query($fmt, ...$args)
{
	global $con;
	$args = array_map([$con, 'real_escape_string'], $args);
	$query = vsprintf($fmt, $args);
	return $con->query($query);
}

Cette fonction est utilisée dans la version 04 de l' API, avec les scripts d'attaque qui permettent de montrer que les attaques n'ont plus de prise.

Pour aller plus loin

Retrouvez les sources sur ce dépôt.

Notez également que nous n'avons pas fait le tour de la question. Déjà, nous avons ignoré les problèmes d'encodage de la donnée, qui peuvent donner lieu à des attaques bien vicieuses. Mais aussi, nous n'avons pas considéré les requêtes préparées ou les ORM pour interroger la base. Enfin, nous n'avons pas parlé des problèmes liés aux noms de tables, des fonctions, etc. Il y a encore de quoi creuser !

Conclusion

Le concept est finalement assez simple, il faut faire attention et utiliser les données avec une certaine rigueur. C'est ça qui est certainement le plus difficile : ne pas se relâcher et ne pas transiger avec une pratique nécessaire qui, si elle n'est pas assez bien suivie, peut beaucoup vous coûter !

Haut de la page
1.807s