Protection contre SQL injection

mieux protéger une application internet

Ce site ne sera plus alimenté de contenu après août 2014. Tous les nouveaux articles seront redigés pour www.waitingforcode.com
Dans le troisième article consacré au SQL injection, on va se concentrer sur les méthodes de défense contre cette attaque. D'abord on verra comment PHP protège les applications web avec ses extensions. Ensuite on analysera la sécurité de l'ORM bien connu par le monde PHP, Doctrine2.

Les exemples vont se baser sur la même structure de la base que celle utilisée dans l'article avec des exemples de SQL injection :


CREATE TABLE authors (
id_author INT(11) NOT NULL AUTO_INCREMENT,
login_author VARCHAR(10) NOT NULL,
password_author VARCHAR(255) NOT NULL,
type_author ENUM('administrator', 'moderator', 'writer') NOT NULL,
PRIMARY KEY(id_author)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
INSERT INTO authors (login_author, password_author, type_author)
VALUES
('test', SHA1('test'), 'writer'),
('moderator', SHA1('moderator'), 'moderator'),
('administrator', SHA1('administrator'), 'administrator');
CREATE TABLE comments (
id_author INT(11) NOT NULL,
comment text NOT NULL,
ip_author VARCHAR(255) NOT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8;


Prepared statements
L'article précédent a bien démontré qu'un caractère peut souvent mettre en péril tout l'effort de sécurisation d'une application web. Comment PHP et MySQL se sont protégés contre ce danger ? Ils devaient pourtant prendre en compte la démocratisation des services internet, souvent conçus par des particuliers sur les bases des projets Open Source (plugins) ? Avec la sortie du PHP 5.1, une nouvelle interface de connexion à la base de données, PDO, a été proposée. Grâce à elle les développeurs peuvent plus facilement profiter de prepared statements. Ces instructions paramétrées permettent, entre autres, de séparer la logique de la requête et les paramètres utilisés dans les conditions. Comme le garantissent les développeurs du langage PHP, il s'agit ici d'une solution quasiment infaillible :


If an application exclusively uses prepared statements, the developer can be sure that no SQL injection will occur (however, if other portions of the query are being built up with unescaped input, SQL injection is still possible).

http://php.net/manual/en/pdo.prepared-statements.php

Qu'est-ce qu'il fait ce prepared statement pour améliorer la tranquillité des développeurs ? Prenons l'exemple de notre précédent article :

SELECT * FROM authors WHERE login_author = ''; SHOW TABLES WHERE 1 = '1'

Le paramètre passé est "'; SHOW TABLES WHERE 1 = '1". A cause d'un mauvais escaping des caractères, l'attaquant a réussi à lancer deux requêtes. Grâce aux instructions paramétrées, MySQL va générer une seule requête (qui ne retournera aucun résultat) :

SELECT * FROM authors WHERE login_author = `'; SHOW TABLES WHERE 1 = '1`

L'instruction paramétrée rajoute des quotes spécifiques qui traitent une chaîne de caractères comme un paramètre, malgré les tentatives d'infraction.

Comment construire un prepared statement en PHP ? Rien de très compliqué. La requête ne diffère guère d'une requête écrite manuellement. La seule diffèrence consite en présence des symboles "?" qui seront remplacés par les valeurs des paramètres :

$dbClass = new PDO('odbc:sample', 'db2inst1', 'ibmdb2');
$statement = $dbClass->prepare("SELECT * FROM authors WHERE login_author = ?");
$dbClass->execute(array("'; SHOW TABLES WHERE 1 = '1"));
$author = $dbClass->fetchAll();


Quels autres avantages on peut tirer des instructions paramétrées ? Avant tout, grâce à la séparation des requêtes et paramètres on gagne de la clarté de code. Un deuxième avantage est le fait que la requête est compilée une seule fois. Ceci représente un gain de rapidité. Malgré cela, prepared statements ne sont pas une solution miracle qui à elle seule réglera tous les problèmes de sécurité liés à la base de données.

Cette technique ne s'applique pas aux tableaux (arrays). Elle ne pourra pas donc être utilisée dans les requêtes avec la clause "WHERE champ IN ()". La solution à cette problématique est l'utilisation de la fonction quote() de la clase PDO :

// we use $dbClass from previous example
$names = array("O'Neil", "McNamara", "N'Diaye");
$inValues = array_map(array($db,'quote'),$names);
$sqlQuery = 'SELECT * FROM authors WHERE login_author IN ('.join(',',$values).')';
$result = $dbClass->query($sqlQuery);
while($row = $result->fetch(PDO::FETCH_ASSOC)) {
echo "Author found"; var_dump($row);
echo "
}


Les instructions paramatrées mal utilisées peuvent ne pas avoir aucun effet sur la sécurisation d'une application web. L'utilisation correcte consite à séparer la partie requête et paramètres. L'exemple ci-dessous sera donc invalable :

$whereClause = "'' OR 1 = '1'";
$statement = $dbClass->prepare("SELECT * FROM authors WHERE login_author = " . $whereClause);
// Query after parse SELECT * FROM authors WHERE login_author = '' OR 1 = '1'


Une autre limitation des prepared statements est l'impossibilité de les utiliser ailleurs que dans la partie WHERE. On ne pourra pas donc paramétrer les noms de colonnes ou les éléments de tri (ORDER BY). Cependant, ce sont des choses qu'on peut facilement gérer, par exemple, avec le whitelisting des colonnes acceptées par la requête.

Filtrage des données
Comme dans le cas de la protection contre XSS, un meilleur contrôle sur les données est synonyme du nombre d'attaques réussies décroissant.

Tout d'abord on devrait commencer par convertir les variables en types correspondants. Par exemple un identifiant numérique ne pourra pas être passé dans la requête en tant qu'une chaîne de caractères. Il devrait être converti en un integer. Pareil pour le prix d'un produit qui, en occurence, deviendra un float.

Pour bien escaper les données entrantes il faut éviter la méthode addslashes(). Si elle fonctionne partiellement dans la protection contre les attaques SQL injection, c'est un peu par hasard. En fait, le caractère d'échappement n'est pas le même pour tous les SGBD. Par exemple pour MySQL c'est bien '\', mais déjà pour PostgreSQL c'est un apostrophe "'". Voyons cela sur cet exemple :

echo pg_escape_string("O'Neil"); // displays O''Neil
echo mysql_real_escape_string("O'Neil", $connection); // displays O\'Neil
echo addslashes("O'Neil"); // displays O\'Neil

La deuxième différence entre addslashes() et une fonction d'échappement propre aux systèmes des bases de données est la méthode de fonctionnement. Addslashes() échappe bêtement les apostrophes, ne regardant pas l'encodage de la base. Tandis que mysql_real_escape_string() d'abord se connecte à la base pour vérifier quel encodage appliquer au caractère échappé.

En plus, il y a 5 ans addslashes() utilisé avec certains encodages (gbk, big5, sjis, cp932) provoquait une faille de sécurité. L'exploit a été décrit par href="http://shiflett.org/blog/2006/jan/addslashes-versus-mysql-real-escape-string" target="_blank" rel="nofollow">Chris Shiflett et Ilia Alshanetsky.

Protection contre SQL injection dans Doctrine2
Avant de passer de bons moments sur mes projets développés avec Doctrine2, j'ai décidé de regarder si aucune intervention supplémentaire en matière de sécurisation ne sera pas nécessaire. On analysera donc la façon de construire et d'exécuter les requêtes par cet ORM.

L'exécution des requêtes SQL dans Doctrine2 se base sur les répositoires qui héritent de la classe Doctrine\ORM\EntityRepository. Ces répositoires (repositories en anglais) regroupent plusieurs méthodes qui permettent de manipuler les données qui nous intéressent. Ils sont en relations directes avec les entités (entity) qui décrivent ses composants. Pour créer une requête on a donc besoin de l'interaction entre ces deux éléments :

// Example based on Symfony2 implementation of Doctrine2
// create repository : in our case, we get only list of users ids
namespace Others\UsersBundle\Repository;
class UsersRepository extends EntityRepository
{
public function getUserIds()
{
$query = $this->getEntityManager()
->createQuery("SELECT userId
FROM OthersUsersBundle:Users u");
$rows = $query->getResult();
}
}

// make users entity : var $userId must exis
namespace Others\UsersBundle\Entity;

/**
* @ORM\Table(name="users")
* @ORM\Entity(repositoryClass="Others\UsersBundle\Repository\UsersRepository")
*/
class Users
{
/**
* @ORM\Id
* @ORM\Column(name="user_id", type="integer")
* @ORM\GeneratedValue(strategy="AUTO")
*/
protected $userId;

public function getUserId()
{
return $userId;
}
}

Regardons ce que fait le système en analysant la requête à exécuter :

  1. $this->getEntityManager() : la récupération du gestionnaire des entités (instance de la classe Doctrine\ORM\EntityManager). C'est lui qui gère, entre autres, les transactions, l'exécution de la méthode find()

  2. $this->createQuery($query) : le gestionnaire des entités instancie la classe Doctrine\ORM\Query. La requête DQL est alors passée à la fonction setDQL(). Les paramètres sont envoyés à la fonction setParameter() de la classe Doctrine\ORM\AbstractQuery.

  3. $this->setParameter($alias, $value) : cette méthode vérifie au début de quel type de données s'agit-il (integer, array, DateTime...). Ensuite elle rajoute le paramètre au tableau contenant tous les paramètres de la requête en question.

  4. Query\ParameterTypeInferer::inferType($value) : c'est la fonction qui détermine le type du paramètre collé à la requête. Elle gère uniquement 3 types : integer, array (avec la distinction en numérique et alphabétique) et DateTime.

  5. $this->getResult() : la requête est alors créée. La méthode getResult() se charge de l'exécuter et de récupérer les informations recherchées. La requête SQL est écrite à l'intérieur, avec l'appel de la fonction _doExecute() de la classe Doctrine\ORM\Query.

  6. $this->_doExecute() : dans un premier temps elle appelle sa méthode _parse() qui instancie la classe Doctrine\ORM\Query\Parser, chargée de préparer la requête. On y récupère également l'instance de l'objet Doctrine\ORM\Query\Exec\SingleSelectExecutor (un exécuteur).
    Ensuite, via Doctrine\ORM\Query\ParserResult on retrouve les paramètres de la requête qui, comparés au nombre des paramètres définis, autorisent ou pas à passer à la tâche suivante (préparation des paramètres SQL).

    A la fin du processus, l'exécuteur appelle la méthode execute()

  7. $this->execute() : elle appelle de son côté la méthode executeQuery() de la classe Doctrine\DBAL\Connection.

  8. $this->executeQuery() : elle conçoit la requête en utilisant l'instance du Doctrine\DBAL\Driver\PDOStatement

  9. retour à $this->execute() : vers la fin, grâce à Doctrine\ORM\Internal\Hydration\ObjectHydrator on appele la méthode fetch() de la classe \PDOStatement et retourne le résultat de la requête. Les paramètres et les valeurs sont collés avec bindParam() et bindValue(), toutes les deux issues

On peut donc constater que dans les requêtes simples, le développeur peut être tranquille. Et comment cela s'applique aux requêtes plus complexes, comme par exemple celles avec la condition IN() ?

// we create query with IN clause with QueryBuilder
$qb = $this->getEntityManager()->createQueryBuilder();
$qb->add('select', 't.tagId, t.tagName')
->add('from', 'PagesNewsBundle:Tags t');
$qb->add('where', $qb->expr()->in('t.tagName', array("shop", "shop\'ing", "shopp'ing", 'shopp"ing')));
$query = $qb->getQuery();
return $query->getResult();

Voici comment procède Doctrine2 pour exécuter cette requête :

  1. $this->getEntityManager()->createQueyrBuilder() : la récupération de l'instance de la classe Doctrine\ORM\QueryBuilder qui va servir à construire la requête

  2. $this->add() : la méthode qui ajoute des éléments de la requête

  3. $qb->expr()->in() : l'utilisation de l'instance du Doctrine\ORM\Query\Expr qui construit la chaîne des caractères à inclure dans la condition IN(). Comment sont échappés les caractères ?

  4. $this->_quoteLiteral : c'est la fonction utilisé par Doctrine2 pour échapper les caractères dans les requêtes avec la condition IN(). La condition finale est donc "t.tagName IN('shop', 'shop\''ing', 'shopp''ing', 'shopp"ing')"

L'échappement est donc automatique et correct grâce à l'extension PDO. Cela s'applique à d'autres types de conditions (LIKE). Est-ce que la sécurisation concerne également les parties qui composent la requête (les champs à récupérer, les informations sur le tri) ?

La réponse est oui. Doctrine2 analyse les champs listés après SELECT et vérifie leur présence dans les entités. Il est obligé de le faire afin de "traduire" les aliases en nom des champs de la base qui sont définis dans les annotations des entités. Quant à l'ajout du texte invalable après GROUP BY, ORDER ou HAVING, Doctrine2 n'exécute pas non plus la requête. A la place il retourne une erreur de syntaxe.

On peut donc constater l'évolution positive de la sécurisation des bases de données sous PHP. Le développement du Doctrine2 qui se base sur l'extension PDO en est la preuve. Cependant il faut toujours rester vigilant en utilisant les types de variables appropriés, ne pas accepter certains caractères dans les patterns définis (adresse e-mail, numéro de téléphone...), blacklister certaines expressions, et surtout, tracer les comportements non attendus par l'application.

L'article écrit en rythme de:
Slaï - Autour de toi
Bartosz KONIECZNY 13-11-2011 12:27 sécurité des applications web
Un conseil Symfony1

Comment récupérer un paramètre de la requête dans le template ?

Il suffit d'utiliser la méthode get() de l'objet $sf_params, par exemple: $sf_params->get('nom_du_parametre').

Comme c'est déjà le cas dans les actions, on peut également préciser la valeur par défaut, au cas où le paramètre n'existe pas. Exemple de l'utilisation: $sf_params->get('nom_du_parametre', 'valeur par défaut');