Beyond SSTI

SSTI Nov 30, 2020

During our recent security gig, we were asked to perform a security assessment of a freshly added feature – a contact form. As per usual, contact forms don’t contain lots of features or vulnerabilities. We were, however, pretty excited having come across a Server-Side Template Injection (SSTI) vulnerability. The web-application had been written in PHP (based on Craft 3 CMS) and this issue had already been identified in two of its plugins: Sprout Forms (CVE-2020-11056) and SEOmatic (CVE-2020-12790).

We decided to share our experience and write about the identification of these vulnerabilities from both sides: blackbox and whitebox approach.

Brac{k}e{t} yourself for SSTI

The idea of the contact form was pretty simple: a user fills in multiple form-inputs (including their email address), then the input dataset is emailed back to that user (as a confirmation). The straightforward pentest approach  – type {{8*8}} and check what comes out– led to immediate results. The email sent back from the web application didn’t contain string {{8*8}} but a parsed result of the above template, i.e. number 64. We decided to dig into this issue deeper and check if more than just printed-back form-fields were vulnerable.

Smuggle the payload into email

The common PHP function:


validates an email address based on pretty crazy regex. You can check how it looks like in the PHP source code.

We weren’t frittering away our time by trying to analyse and understand that long line of code. Instead, we utilised a trial-and-error approach.

Bet you are familiar with so-called GMail aliases, e.g: [email protected] is a perfectly valid email address. It turned out that based on the FILTER_VALIDATE_EMAIL, an alias part (the one after the plus sign) may contain some special characters, including {{}}:

  • test+aaa{{8*8}}

We sent our contact form to the above email and received a message back to:

which proved that Server-Side Template Injection existed also in the email field.

One more SSTI

Above issue occurred in the Sprout Forms plugin and got CVE-2020-11056 assigned. It turned out that it was not the only place where SSTI vulnerability was identified. For better understanding of another payload, let’s take a glance at HTTP request/response pair:

As you can see, whenever a user provides an incorrect email address (form fields[email]), the server prints back the response containing the part of the URL:

<link href="" rel="alternate" hreflang="en-us">
<link href="" rel="alternate" hreflang="x-default">
<link href="" rel="alternate" hreflang="pl">

No sooner had we that noticed, we hoped to smuggle our payload via URL. Unfortunately, it wasn’t that simple.

Any additional GET parameters (e.g.: POST /foobar?aaa={{8*8}}) were stripped out and not printed back in the server’s response. We weren’t able to insert our payload via GET parameters, neither were we able to smuggle it via a Host header (due to VirtualHost settings). Luckily, there was one additional way to achieve what we had planned – the semicolon. Everything that was inserted after a semicolon was printed back in the server’s response (e.g.: POST /foobar;aaa). That was the perfect place to put our payload – and as it turned out – it worked:

Beyond SSTI. Our journey to RCE

The crux of the Server-Side Template Injection is the knowledge of what language constructs we are able to render. We didn’t want to send to our customer a pentest-report, which contained a measly {{4*4}} PoC. So, we dug deeper into the Craft CMS documentation to check what we can do with available Twig templates.

A pretty nice Twig template was found during examination of an old Craft documentation. In Craft 2, we’re able to access properties from configuration files via {{craft.get.config}}. Even though this method is deprecated in Craft 3, it was still working.

Based on documentation the following methods are available:

# get( name, file )
If you want to access config values from any config file besides general.php, you can use get():
{{ craft.config.get('someConfigSetting', 'someConfigFile') }}

Even though we couldn’t access properties from general.php, there were still a lot of juicy configuration files which we grabbed info from. The most important for us was db.php – which contains database credentials.


These two Twig templates returned both username and password for the database connection. As we found PHPMyAdmin on the Internet-facing web application server, the next steps would have been straightforward: log in to the PHPMyAdmin, add new user with administrative rights, log in to Craft Admin Panel, perform future security assessment with hope of getting RCE. However, we do enjoy taking the path of least resistance and performing all these steps wouldn't be time-efficient. We’d already found SSTI, why didn’t we just try to perform RCE directly from Twig? Indeed – we did!

As Craft 3 CMS is built on Yii, we switched over to read Yii documentation and found an interesting method:

evaluateDynamicContent() public method
Evaluates the given PHP statements.
This method is mainly used internally to implement dynamic content features.

A quick Google-search revealed that evaluateDynamicContent is accessible from Craft:

which led us to the below RCE PoC:

{{'system("uname -a");')}} ‌

in Sprout Forms and


in SEOMatic (as payload is inside the URL, we dealt with a whitespace-character by replacing it with \x20).

Although we were able to achieve remote code execution directly from Twig template, in our request for CVE assignment for these two vulnerabilities, we reported those issues as Server-Side Template Injections. The ability to call own PHP code through evaluateDynamicContent method within a Twig template is just a side-effect of rendering and evaluating user-controlled inputs. The crux of the problem is an SSTI and it had to be patched both for Sprout Forms and SEOmatic plugins.


Information disclosure PoC


Information disclosure PoC

Whitebox approach

As soon as we informed our customer about identified vulnerabilities, we decided to start a responsible disclosure process with plugins’ developers.

The fingerprinting process of the software identified on the server wasn’t sophisticated. The X-Powered-By header revealed that one of the plugins was SEOmatic, while Sprout Form was spotted by examining an HTTP POST request which contained sprout-forms/entries/save-entry string. Once we established which plugins we were dealing with, we started the whitebox approach.

Sprout Forms whitebox

We started with Sprout Forms code-review, as identification of code snippets responsible for email messages’ handling was pretty straightforward. The vulnerable code existed in the src/base/Mailer.php file (sprout-base-email package) and it is presented below:

	$htmlBody = Craft::$app->getView()->renderObjectTemplate($htmlBody, $object);

$htmlBody variable stores the HTML body of an email. The content of this variable is next rendered through renderObjectTemplate function, which leads to the SSTI vulnerability.

For better understanding of how template rendering stages work, let’s examine the content of $htmlBody variable. It’s presented as below:

<html lang="pl">
	<title>Test (pl)</title>


The interesting part here is value_from_users – as it’s name is self-explanatory – the user has control over this part of the code snippet. Then, the whole template is rendered through renderObjectTemplate (including the part of the template which is controlled by the user).

Let's take a look at the renderObjectTemplate documentation:

The template will be parsed for “property tags” (e.g. {foo} ), which will get replaced with full Twig output tags (e.g. {{|raw }}

It means that if we replace "value_from_user" with a Twig template (e.g.: {{4*4}}), the value between double-brackets will be evaluated and printed back as 16 (the result of 4*4).

As we presented in previous paragraphs, this rendering issue may be abused to perform malicious actions on the Craft 3 instance.

To fix the above issue, the vendor’s patch dismisses the logic of dynamically generated HTML body in favor of $defaultBody variable which is rendered through renderObjectTemplateSafely function.

$this->renderObjectTemplateSafely($email, 'defaultBody', $object);

You can find the vendor patch here and read the full changelog here.

SEOmatic whitebox

We started the code review process with running a simple grep command on a source code directory for SEOmatic plugin: /Craft-3.4.10/vendor/nystudio107/craft-seomatic/src.

As we already had a PoC for SSTI, we grabbed a server response:

<link href=";uid=33(www-data) gid=33(www-data) groups=33(www-data),999(vboxsf)" rel="alternate" hreflang="pl">

<link href=";uid=33(www-data) gid=33(www-data) groups=33(www-data),999(vboxsf)" rel="alternate" hreflang="x-default">

<link href=";uid=33(www-data) gid=33(www-data) groups=33(www-data),999(vboxsf)" rel="alternate" hreflang="en-us">

and noticed that ‘x-default’ string was unique among all the hreflang attributes rendered in the server’s response. It was our first shot to check where ‘x-default’ string existed in the source code:

grep -rnwa . -e 'x-default'  produced this output

./helpers/DynamicMeta.php:457:                    // Add the x-default hreflang

./helpers/DynamicMeta.php:459:                        $metaTag->hreflang[] = 'x-default';

The line 459 revealed that x-default string was assigned to the hreflang[] array (the name of the array matched the attribute’s name).

One line below we spotted that the “href” value of the <link> tag was assigned to the siteLocalizedUrl variable.

Further analysis showed that the above variable stored the output of getLocalizedUrls method.

During our code-review process, we focused only on the parts of getLocalizedUrls method that were important in the URL handling, so we skipped the codes’ lines responsible for SEOmatic logic as we were already operating in an environment that matched all the conditions.

That led us to the $url variable which turned out to be the place where our payload was stored.

One more observation brought us to the absoluteUrlWithProtocol method which returned the absolute URL of the request. That part of code explained why the server's response didn’t contain GET parameters but we were still able to smuggle our payload via the after-a-semicolon part of the URL.

Further code inspection led us to includeMetaData method from the MetaLinkContainer class. In this function there were two interesting lines of code:

Here the payload between brackets was evaluated in the later execution steps.

The $metaLinkModel variable was the object of the MetaLink class.

The prepForRender method contains call to MetaValue class parseArray method:

ParseArray method by default parses the items in an array as the Twig template:

The second interesting part of the includeMetaData was found below:

where the tags were added to the page via registerLinkTag function.

The vendor managed to create just a one-line fix which relayed on the URL sanitization.


It turns out that even a simple contact form may be vulnerable to attacks with severe consequences. During our penetration testing we identified two separate SSTI vulnerabilities and escalated them to a Remote Code Execution. In our blog post, we shared how we had found these issues during the blackbox approach and we also described how we had reviewed the source code to help our customer and developers to understand, fix and eventually mitigate the risk posed by exploitation of our findings.

Great! You've successfully subscribed.
Great! Next, complete checkout for full access.
Welcome back! You've successfully signed in.
Success! Your account is fully activated, you now have access to all content.