Aller au menu - Aller au contenu

Icône Sécurisation des failles CSRF

Avatar
Mise à jour : 02/09/2009
Difficulté : Intermédiaire Intermédiaire Durée d'étude : 1 heure Creative Commons BY-NC-ND
256 visites depuis 7 jours, classé 373/786
Bienvenue à tous !

Aujourd'hui, je vais vous introduire une nouvelle faille de sécurité, et vous expliquer comment vous en protéger au mieux.

Si vous êtes développeur et que la notion de "token" ne vous dit rien, lisez vite ce tuto, on est peut-être déjà en train de vous pirater !

Ce tutoriel concerne les sites PHP, vous devez donc maîtriser ce langage (c.f. : le tuto de M@teo21). Il est surtout utile pour les sites en activité, et assez complets, puisque ceux-ci doivent reposer sur un système de privilèges.


C'est partiiiii ! ;)

CSRF, késako?

Je suis sûr que vous vous demandez :

Mais c'est quoi ce CSRF dont il nous parle depuis tout à l'heure ?

Ah oui, j'avais oublié de vous expliquer ! :diable:

CSRF signifie "Cross-Site Request Forgeries". Je vous donne un exemple :
Paul est newser d'un site, il peut donc ajouter, modifier, et supprimer une news, ce qu'un utilisateur du site lambda ne peut PAS faire.
Jean est un de ces utilisateurs lambda, et il aimerait pirater le site de Paul ! :pirate: Il va donc récupérer l'adresse permettant de supprimer une news, et envoyer un message privé à Paul contenant une image dont l'adresse sera celle de la page de suppression de news.

Et là, le navigateur entre en jeu. En essayant d'afficher l'image, il va aller sur la page web permettant de supprimer la news et donc l'exécuter. Or, Paul était identifié en tant que newser, la news sera donc supprimée car IL a affiché la page.

Sur le même principe, si Paul peut recevoir une requête ajax qui en s'exécutant va poster ou modifier une news !
C'est-à-dire que Paul recevra un code javascript qui "forcera" le navigateur à appeler la page que le pirate veut que Paul exécute.

On va donc apprendre à se protéger de ces failles très dangereuses, sachant qu'il n'existe pas de protection parfaite.

Avant d'entrer dans le vif du sujet, je précise à toutes fins utiles que cette faille ne s'applique pas uniquement au cas que je présente dans ce tuto (c'est à dire le "piratage" d'un espace d'administration), mais peut-être utilisée dans de nombreux domaines où le but est de faire générer une requête par le navigateur de la victime (pour truquer des votes par exemple).

Le jeton de sécurité ou token

Alors, pour commencer, voyons la protection la plus courante. :)
Elle consiste à stocker un jeton unique (clairement une suite de nombres et de lettres) associé à la date d'affichage pour chaque visiteur qui affiche un formulaire dans une session, et ce même jeton dans un champ caché. Ceci permet que la personne qui tente d'exécuter la page est bien passée par le formulaire avant, où on lui a délivré le jeton.

Ce qui donne :
Code : PHP
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
<?php
//On démarre les sessions
session_start();
//On génére un jeton totalement unique (c'est capital :D)
$token = uniqid(rand(), true);
//Et on le stocke
$_SESSION['token'] = $token;
//On enregistre aussi le timestamp correspondant au moment de la création du token
$_SESSION['token_time'] = time();

//Maintenant, on affiche notre page normalement, le champ caché token en plus
?><!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml">
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
<title>Mon formulaire anti CSRF</title>
</head>

<body>
<form id="form" name="form" method="post" action="traitement.php">
  <p>Pseudo : 
    <label>
      <input type="text" name="pseudo" id="pseudo" />
    </label>
  </p>
  <p>E-mail : 
    <label>
      <input type="text" name="email" id="email" />
    </label>
  </p>
  <p>Nom : 
    <label>
      <input type="text" name="nom" id="nom" />
    </label>
    <input type="hidden" name="token" id="token" value="<?php
//Le champ caché a pour valeur le jeton
echo $token;
	?>"/>
  </p>
  <p>
    <label>
      <input type="submit" name="Envoyer" id="Envoyer" value="Envoyer" />
    </label>
  </p>
</form>
</body>
</html>


Bon, jusque-là, c'est bon je pense... Les commentaires sont explicites. :soleil:

Je rappelle que session_start() se place avant tout affichage, c'est souvent une cause d'erreurs inexpliquées. Vous pouvez outrepasser cette limitation grâce aux fonctions ob_, mais c'est moins propre.

Ensuite, on passe à la page du traitement, et vous vous rendrez compte qu'il est très simple de se protéger efficacement.

On va déjà vérifier la présence du token, de sa date dans la session, et du token envoyé par POST. Donc, si la personne qui exécute la page n'est pas passée par le formulaire, ça bloque...

Le token doit aussi être valide, c'est-à-dire identique à celui envoyé par POST, et non-expiré, c'est-à-dire que sa génération ne remonte pas à trop longtemps.

Puis on vérifie aussi si le token de $_POST est le même que celui de $_SESSION. Ce qui donne finalement :
Code : PHP
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
<?php
session_start();
//On va vérifier :
//Si le jeton est présent dans la session et dans le formulaire
if(isset($_SESSION['token']) && isset($_SESSION['token_time']) && isset($_POST['token']))
{
	//Si le jeton de la session correspond à celui du formulaire
	if($_SESSION['token'] == $_POST['token'])
	{
		//On stocke le timestamp qu'il était il y a 15 minutes
		$timestamp_ancien = time() - (15*60);
		//Si le jeton n'est pas expiré
		if($_SESSION['token_time'] >= $timestamp_ancien)
		{
				//ON FAIT TOUS LES TRAITEMENTS ICI
				//...
				//...
		}
	}
}
//SINON, ON RAJOUTE DES ELSE ET DES MESSAGES D'ERREUR
?>

Je vous conseille si vous utilisez cette méthode sur plusieurs scripts de nommer vos sessions pour ne pas confondre les jetons. Par exemple, $_SESSION['token_time'] devient $_SESSION['inscription_token_time'].

Voilà pour la protection de base. Suivez le guide, on va rentrer dans d'autres petites sécurités à mettre en place pour bien gêner le pirate. :diable:

Évidemment, on peut utiliser un token pour des actions qui ne passent pas par un formulaire.
Par exemple : sur une page ou l'administrateur peut supprimer des news, il y a plusieurs liens vers : supprimer_news.php?id=34

Au lieu de transmettre le jeton par POST, on le transmet par GET, comme les autres données (en l'occurence l'ID de la news à supprimer). Le principe reste sinon le même, mais le POST est plus sûr.

Le referer

On va donc poursuivre notre long chemin vers la vérité. ;)

Imaginons que le pirate fasse une injection CSRF et que, au moyen de requêtes AJAX, il fasse afficher à l'admin la page du formulaire puis la page du traitement...

Une requête AJAX, c'est un code Javascript qui s'exécute et qui appelle en arrière plan (c'est à dire que c'est invisible pour l'utilisateur) une page distante. Le javascript étant un langage client, c'est à dire exécuté par l'utilisateur et non par le serveur, le site qui reçoit la requête AJAX est appelé comme si l'utilisateur victime l'avait appelé consciemment. C'est donc un moyen de réaliser une attaquer CSRF. Je vous renvoie vers ce tuto.

Eh bien l'injection serait réussie :waw: , car le token aurait bien été généré par la page 1, et la vérification sera passée avec succès sur la page 2, sauf si vous vérifiez que la page qui a conduit le visiteur à la page de traitement est bien le formulaire, c'est à dire que le visiteur a cliqué sur un lien ou un bouton de la page 1 qui l'a directement conduit à la page 2.

On peut vraiment faire ça ? :honte:

Oui, bien sûr, grâce à $_SERVER['HTTP_REFERER']. C'est une variable disponible n'importe où dans votre script sans rien à faire de particulier (comme toutes ses cousines $_SERVER['...']), et elle contient l'adresse, si elle existe, de la page qui a amené votre visiteur sur le script en cours.

ATTENTION : cette variable renvoie une adresse absolue, par exemple http://lesite.com/dossier1/url_a_rallonge.html, et non pas ../url_a_rallonge.html

Donc, on va rajouter un if qui vérifiera si cette variable est égale à la page formulaire.php, je vous laisse trouver ça tout seul (je sais, je suis cruel :diable: ).

Ramassage des copies, et correction : Secret (cliquez pour afficher)

Code : PHP
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
<?php
session_start();
//On va vérifier :
//Si le jeton est présent dans la session et dans le formulaire
if(isset($_SESSION['token']) && isset($_SESSION['token_time']) && isset($_POST['token']))
{
	//Si le jeton de la session correspond à celui du formulaire
	if($_SESSION['token'] == $_POST['token'])
	{
		//On stocke le timestamp qu'il était il y a 15 minutes
		$timestamp_ancien = time() - (15*60);
		//Si le jeton n'est pas expiré
		if($_SESSION['token_time'] >= $timestamp_ancien)
		{
			//Si le referer est bon
			if($_SERVER['HTTP_REFERER'] == 'http://monserveur.com/leformulaire.php')
			{
				//ON FAIT TOUS LES TRAITEMENTS ICI
				//...
				//...
			}
		}
	}
}
//SINON, ON RAJOUTE DES ELSE ET DES MESSAGES D'ERREUR
?>


Remplacez évidemment l'adresse du formulaire par la bonne. :)

Le referer est envoyé par le navigateur du client, en d'autres termes il est très facile de le modifier ! Ne vous fiez donc pas à lui à 100%, ce n'est qu'une protection complémentaire.

Voilà, vous êtes déjà bien protégés, mais il y a encore quelques petites choses à voir... On continue!

Autres conseils

Je vous rappelle qu'aucune protection n'est parfaite, mais que les pirates sont des feignants et donc s'ils doivent passer 50h avant de vous pirater, il vont chercher une autre victime !

Donc, premièrement, passez le plus de données possibles en POST, ça oblige à monter une attaque avec des requêtes AJAX, puisque la technique de l'image (voir partie I) ne permet pas de passer des données en POST à la page appelée, mais seulement en GET (dans l'URL).
De plus, la taille des URL est limitée à 255 caractères, donc ça fait pas lourd.
Privilégiez le POST, mais avec discernement, pour passer un id dans l'URL, par exemple, profil.php?id_membre=1, pas la peine d'utiliser POST, GET suffira bien.

Ensuite, on a obligé le pirate à nous envoyer du code AJAX qui sera exécuté par l'administrateur. Hé, il me vient une idée, si on l'empêchait de le faire ? Allez, lançons-nous ! :magicien:

On va échapper les caractères HTML nécessaires par exemple le < et les ' " qui sont indispensables pour ce type d'attaque (ne soupirez pas, c'est plus que simplissime). La plupart de vous doit s'en douter, on va utiliser htmlspecialchars().

Vous utilisez peut-être déjà htmlentities(), sachez que c'est htmlspecialchars() + d'autres choses, donc ça convient très bien !

Il suffit donc de passer tout ce qui risque de contenir du code qui sera affiché à travers cette fonction, par exemple (vous pouvez changer le nom des variables :D ):
Code : PHP
1
2
3
<?php
$chaine_securisee = htmlentities($chaine_a_risque);
?>


À la poursuite de la protection parfaite :ange: , vous pouvez aussi sur la page traitement demander une confirmation avant de valider l'action. Donc, à moins que le webmaster clique sur poursuivre, rien ne sera exécuté. Mais réservez cette technique aux points clés du site, parce que c'est très lourd !
Dans le même ton, il est possible d'exiger une double connexion pour obtenir l'accès à la partie d'administration, comme dans certains forums PhPBB. Ainsi, on peut conserver une option de connexion automatique (bien pratique) sans risque, puisque si l'administrateur veut se connecter à l'administration, il devra se reconnecter.

Vous pouvez aussi sur le même principe demander avant une action critique de retaper le mot de passe.

Enfin, je vous conseille de lire ce tuto, qui vous donnera des pistes pour contrer le vol de session, qui ici peut constituer une faille dans le système.

Il existe donc de nombreux moyens de vous protéger, je ne les ai pas tous cités, et je vous invite donc à vous documenter, puisque le Web ne manque pas d'informations à ce sujet, et de jeter un coup d'oeil à la partie bonus ! L'idéal est de choisir une ou un ensemble de protections qui combinent praticité et efficacité, c'est à dire le compromis entre sécurité et ergonomie. Là, vous êtes les seuls juges.

Les bonus

Résumé de nos fonctions


Vous êtes encore là ? Très bien, pour vous féliciter de votre persévérance, je vous ai récapitulé tout le code, évidemment, à vous de l'adapter à vos besoins.

Voici donc 2 petites fonctions qui récapitulent les principales protections, pour les explications référez-vous aux commentaires :
Code : PHP
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
<?php

//Cette fonction génère, sauvegarde et retourne un token
//Vous pouvez lui passer en paramètre optionnel un nom pour différencier les formulaires
function generer_token($nom = '')
{
	session_start();
	$token = uniqid(rand(), true);
	$_SESSION[$nom.'_token'] = $token;
	$_SESSION[$nom.'_token_time'] = time();
	return $token;
}


//**************************************************************************//
//**************************************************************************//
//**************************************************************************//


//Cette fonction vérifie le token
//Vous passez en argument le temps de validité (en secondes)
//Le referer attendu (adresse absolue, rappelez-vous :D)
//Le nom optionnel si vous en avez défini un lors de la création du token
function verifier_token($temps, $referer, $nom = '')
{
session_start();
if(isset($_SESSION[$nom.'_token']) && isset($_SESSION[$nom.'_token_time']) && isset($_POST['token']))
	if($_SESSION[$nom.'_token'] == $_POST['token'])
		if($_SESSION[$nom.'_token_time'] >= (time() - $temps))
			if($_SERVER['HTTP_REFERER'] == $referer)
				return true;
return false;
}
?>


Bon, je vous donne quand même un exemple d'utilisation mes chers Zéros. Voici la page du formulaire :
Code : PHP
1
2
3
4
<?php
$token = generer_token('forum');
//Ensuite, le formulaire normal, pensez au champ caché. ;)
?>


Et voici la page de traitement :
Code : PHP
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
<?php
if(verifier_token(600, 'http://test.fr/formulaire.php', 'forum'))
{
	//TRAITEMENTS
	//..
	//..
}
else
{
	//ERREUR	
}
?>


Mais pourquoi met-on if(verifier_token(...)) ? Ça n'a aucun sens ?

Eh bien si : la fonction renvoie true si le token est valide, et false s'il est invalide, ce qui donne :
Si le token est validé : if(true), ce qui équivaut à "si vrai", donc la condition est vérifiée
Sinon, c'est if(false), si faux, condition non valide, on exécute le else.

Pour approfondir



Si vous voulez en apprendre plus, je vous conseille les sites suivants :
  • Wikipédia
  • XMCOPartners(audit informatique)
  • MTI
  • Il est aussi intéressant de voir comment les grands scripts open-source (PhPBB, Joomla, etc.) se protègent de ce genre d'attaques. Les parades qu'ils déploient sont réfléchies par toute une communauté, ça les rend donc efficaces.Enfin, j'attire votre attention sur le fait que certains frameworks facilitent la gestion de la sécurité, nativement ou avec des modules, comme Ruby on Rails, Django, etc.

Voilà, c'est fini ! :'(

Q.C.M.

Qu'est-ce qu'une faille CSRF?
Qu'est ce qu'un token?
Le referer est-il totalement sûr ?

Statistiques de réponses au QCM

Vous avez appris à vous protéger contre un type de faille assez méconnu chez les débutants. N'hésitez pas à poster un commentaire si vous avez un problème, j'y répondrai, mais pas MP par contre. ;)

Avant de nous quitter, je tiens à remercier les différents Zér0s qui m'ont aidé par leurs commentaires à ajouter, modifier et améliorer certains points du tuto. Je vous encourage aussi à me faire part de vos commentaires, pour pouvoir avancer. ;)

Enfin, je précise, pour éviter les critiques acerbes de certains, que ce tutoriel n'est qu'une introduction aux failles CSRF. Il ne les traite pas exhaustivement, l'intérêt étant surtout d'attirer l'attention des webmestres débutants sur le fait qu'il existe des failles très dangereuses auxquelles on ne pense pas immédiatement, CSRF en étant un bon exemple.

Merci de votre lecture, à bientôt!

Partager

83 commentaires pour "Sécurisation des failles CSRF"
Note moyenne : 3.74 / 4 (23 votes)
Pseudo Commentaire
Hors ligne Argoneth # Posté le 25/05/2011 à 15:20:46
Avatar

Avis : Très bon

@Crimsonblue et yamissous : merci de l'encouragement ;)

@JeromeJ : si j'ai bien compris, tu expliques que les gens qui n'envoient volontairement pas leur referer ne peuvent accéder à mon site... Techniquement c'est vrai, mais ne pas envoyer ton referer, c'est presque de la paranoïa, en dehors du fait que c'est quasiment inutile.
En effet, l'anonymat sur internet, c'est presque une chimère, alors désactiver le referer c'est pas réellement efficace... D'ailleurs, peu de sites en font des stats, et même si c'est le cas, où est le problème?
De toute façon, c'est comme IE6, tu es libre de l'utiliser, mais c'est pas pour autant que l'on doit faire des sites compatibles IE6, au prix d'un dev bien plus long (et chiant).
L'absence de Referer est, je pense, encore moins répandue que IE6, donc se priver de cette petite partie d'utilisateurs en échange d'une sécurité accrue, c'est un compromis que j'ai choisi de faire.

Tu es libre d'être légèrement paranoïaque, je suis libre de faire un site réservé à certains utilisateurs (ici une grande majorité). Cependant, tu fais bien de le signaler, certains lecteurs feront peut-être un choix différent. :)

Sinon, à titre personnel, je te conseille, si tu tiens réellement à être anonyme, de te pencher sur les proxys (certains très efficaces), Tor, etc...
Mais pour un particulier normal, c'est inutile et peu fiable.

Bye
Hors ligne maxima # Posté le 25/08/2011 à 13:22:33
C’est pas faux.
Avatar

Études : Lycée du Parc - Lyon

Je pense qu'il faut que tu mettes la technique de symfony :

Code de symfony :
Code : PHP
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
<?php
public function getCSRFToken($secret = null)
{
    // Maxima : par défaut, self::$CSRFSecret = md5(__FILE__.php_uname()); et $this->localCSRFSecret = null (donc considéré false)
    if (null === $secret)
    {
        $secret = $this->localCSRFSecret ? $this->localCSRFSecret : self::$CSRFSecret;
    }

    return md5($secret.session_id().get_class($this));
}


C'est assez astucieux.
- De cette manière le token est le même pour toute la session, ce qui empêche certain bug avec les onglets (avec ton code si j'ouvre 2 onglets et que je reviens sur le premier, le token ne sera plus valide) ;
- Pas d'encombrement d'une variable $_SESSION['token'].
- Possibilité de "personnalisation" en passant un token en argument.

Cordialement.
Hors ligne lamouche42 # Posté le 11/10/2011 à 09:23:09

Bonjour,
JE suis un peu (beaucoup) novice dans ce domaine c'est pour ca que je m'instruis avec ce tuto bien fait et comprehensible pour les "noob" :)

cependant, si je comprend le systême de token, ne serait il pas possible de remplacer cette generation aléatoire par l'ip de l'utilisateur?
Code : PHP
1
$authentification = $_SERVER['REMOTE_ADR'];

la verification à chaque page de la variable contenant l'ip est une bonne protection car le seul moyen de reussir le piratage serai que l'admin et l'user normal utilise le même pc et que la connection ne soit pas rebootée.

cette solution comporte t elle des desaventages par rapport au systême de token?
Hors ligne remontees # Posté le 04/03/2012 à 21:58:02
Vive Symfony2 !
Avatar

Avis : Mitigé

Et ça protège des images qui vont chercher des pages ? Je trouve le tuto incomplet. Il faudrait expliquer la vérification de la nature de l'image, non ?

Pensez à indiquer les messages qui vous ont aidé et à indiquer vos problèmes résolus ;)
XHTML 1.0/CSS <= 2 : IIIIIIIIII
PHP/MySQL : IIIIIIIIII
Javascript : IIIIIIIIII
HTML5/CSS3 : IIIIIIIIII



Citation
Je sais plus ce que j'écrivais ! :)
Qui que quoi dont où
Alors j'ai activé la prévisualisation automatique !
Citation : Moi
Eh oui

Et maintenant je suis tranquille !



Mon site : http://remontees.free.fr
 
Hors ligne alami2010 # Posté le 29/04/2012 à 00:50:37
Avatar

Avis : Très bon

merci bcp Argoneth j'ai bien compris
et j'ai bien profiter de ce mini cours
merci encore une fois bon courage bon continuation

Code : C
1
Printf('<code type="html"><code type="html">');
 

Voir tous les commentaires
Ce tutoriel a été corrigé par les zCorrecteurs.