Skip to content

An Analysis of Magento RCEs CVE-2022-24086 and CVE-2022-24087

Posted on:June 5, 2023

Key Takeaways

Emails sent to customers are HTML files templates containing {{directive operators}}. Templates are parsed to replace template directives with entered customer data.

Attackers can trick the email template parser to inject their own template directives. Playing with directives leads to code execution (RCE) on the Magento server.

A guest user can fill the form to order an item with malicious {{directive operators}}. When the Magento server will send a confirmation email to him, he will trigger his malicious directives. The guest user will receive the result of his RCE in his confirmation email.

Catalin Filip made a super cool presentation at DefCamp 2022 on YouTube, with a working demo PoC for the vulnerability. Go watch it 😎

Table of contents

Open Table of contents

Two in One Vulnerabilities

Both CVE-2022-24086 and CVE-2022-24087 are RCE vulnerabilities that can be triggered when a marketing email is sent to a user by the Magento server.

No authentication is required as the vulnerability can be triggered as a guest user. This explains the 9.8 CVSS rating 🚀.

When CVE-2022-24086 came out, a first patch was disclosed by Adobe. However, the patch was found to be incomplete as it could be bypassed, which forced Adobe to quickly produce another patch. Both patches made by Adobe are available at the following URL.

From what I understand, CVE-2022-24087 is the same vulnerability as CVE-2022-24086. The difference is that you have to dance around the changes applied by the first Adobe patch 💃.

Analysis at DefCamp22

When CVE-2022-24086 first came out in February 2022, I could not figure out how to reproduce it. The patch contained 4 lines and had something to do with emails. Fake proof of concepts (PoCs) were flourishing everywhere with blurred screenshots. Sadly, I could not dig deeper into the vulnerability at the time 🥹.

By chance, YouTube recommended the super cool presentation of the researcher Catalin Filip at DefCamp 2022. His neat live-demo reignited my spark to have a go at it again 💖.

The key points of the presentation are:

  1. It is very frustrating when a vulnerability is out and there are only fake PoCs in sight.
  2. Always start from the patch when reproducing a vulnerability.
  3. The end goal is to try to reach PHP magic functions, in our case call_user_func and call_user_func_array. If you control all parameters, you can execute arbitrary code.
  4. By tweaking the presentation PoC for CVE-2022-24086, you could also modify the Magento database through malicious SQL statements.

The associated slides are available here.

The presentation served as a solid base to explore CVE-2022-24086 and CVE-2022-24087.

Magento Email Templates

When you place your order, an email will automatically be sent to you. That’s quite logic: as a customer, you want to be assured that everything is going well with a nice email.

When you order an item, you have to fill this form:

Filling Form Image

After placing your order, you will receive a confirmation email filled with the data you entered in the form:

Confirmation Email Image

All emails are templated and are HTML files. When a guest user places an order, the corresponding HTML template located at vendor/magento/module-sales/view/frontend/email/order_new_guest.html is parsed. This template looks like:

{{template config_path="design/email/header_template"}}

<tr class="email-intro">
    <p class="greeting">{{trans "%name," name=$order_data.customer_name}}</p>
    {{trans "Thank you for your order from %store_name." store_name=$store.frontend_name}}

We can see in the received email that the templated part {{trans "%name," name=$order_data.customer_name}} was replaced with the FirstName and LastName form data (Petite and Mais).

Template Directives

Directives are composed of 4 main parts.

In the directive {{trans "Hello %name," name=$order_data.customer_name|raw}}, we have:

  1. A name: trans
  2. A value: Hello %name
  3. Parameters: name=$order_data.customer_name
  4. Sometimes filters: raw

We will mainly play with the first three to create payloads later 😉.

Template Processing

Email templates are processed by the filter function of the module-email Filter class. This function will then directly delegate the processing to its parent Template class filter function.

Parsing an email template is accomplished with the help of directiveProcessors. For each template directive the function stumbles upon, it will try to find a matching kind of directiveProcessor to further process it.

Directive Processors Image

directiveProcessors are of different types:

Example: {{depend stuff}}test{{/depend}} matches

Example: {{if test}}no{{/if}} matches

Example: {{template test}} matches

Example: {{test test}} matches

The legacy directive is a bit special. If your directive does not match depend, if or template, the function directive_name + Directive will be called using reflection:

Legacy Directive Image

If the parsed directive is {{trans something}}, then the trans + Directive method available in the Filter class will be called:

Trans Directive Image

A lot of something + Directive functions exist in the email module code. It’s a good idea to explore all available directives!

Something Directive Image

Example Parsing Workflow

Back to the guest order email template.

What happens when parsing {{trans "%name," name=$order_data.customer_name}}?

  1. The parser will stumble upon a text between curly brackets {{trans blahblah}}, it’s a directive it has to parse!
  2. The directive does not match {{template stuff}}, {{if stuff}} or {{depend stuff}}… It will be sent to the LegacyDirective class to process.
  3. The directive starts with ”trans”. Through reflection, the transDirective function will be called.
  4. "%name," will be translated using the content of the name variable, which is $order_data.customer_name.
  5. $order_data.customer_name corresponds to your FirstName + LastName when you order.
  6. The line {{trans "%name," name=$order_data.customer_name}} is replaced with FirstName LastName,
  7. In the received email, you get:
    FirstName LastName,
    Thank you for your order on...

The Big Idea

The very smart idea of the vulnerability is:

What if… I use {{malicious_directive}} as my FirstName?

The Big Idea Image


If we go through the workflow above, on step 6:

  1. The line {{trans "%name," name=$order_data.customer_name}} is replaced with FirstName LastName,.

If our FirstName is equal to {{malicious_directive}}, it will be injected into the email template, from:

<p class="greeting">{{trans "%name," name=$order_data.customer_name}}</p>


<p class="greeting">{{malicious_directive}} LastName,</p>

If the template goes through parsing again (which it will), the parser will now stumble upon {{malicious_directive}} and say:

Hey, here is a {{malicious_directive}} directive I have to parse!

We can inject our own malicious directives into existing email directives to do nefarious things.

Playing With Directives

At this point, it is possible to play with:

Now is the time to test some of them!

The Invalid Directive

We set our FirstName to {{directivename "random text %param" $param=$this.afunction()}} when ordering a Rolex.

The parser will discover our directivename directive within the LegacyDirective parser. Sadly, there is no directivenameDirective function within the Filter class of the email module.

The LegacyDirective parser decides that it might be a SimpleDirective somehow and searches for a directivename within a processorPool which will never be found 🥹. It’s a complete failure.

The Translation Directive

We set FirstName to {{trans "string to %var" var="hey"}}.

The payload is injected within the email template but is then escaped as it is treated like malicious HTML!

Our payload will thus be injected back into the template as {{trans &quot;string to %var&quot; var=&quot;hey&quot;}}. The translation directive will consider that there is no text to translate starting with a quote (").

The same process happens with single ' and backslash-escaped quotes.

Experiment conclusion: we can’t use a directive with quotes like ' or ".

If we do, we need a function that somehow calls the html_entity_decode PHP function which will translate &quot; back to " before processing its parameters.

Praise the Media Directive

Here is the mediaDirective function:

Media Directive Image

As we can see, the mediaDirective calls html_entity_decode before parsing its arguments. If our payload is re-injected into the template looking like {{media &quot;something&quot;}} the html_entity_decode function will parse as if it looked like {{media "something"}}, which is very useful.

Now, we can use quotes in our payloads without fearing invalid parsing.

A line below getParameters, we can see that the media directive will return the content of the url directive parameter and append it to the store URL with an additional /media path.

If we use {{media url="ping"}} as our Firstname, we can expect to receive an email containing the URL https://app.cve-2022-24086.test/media/ping:

Email Processing Image

As we can see in the email, some processing took place. The payload was executed!

Parameter Parsing Flow

mediaDirective parameters go through the getParameters function. There is a check there to do more parsing with a getVariable function if a parameter contains a dollar $:

getParameters Function Image

The getVariable function calls a resolve function:

getVariable Function Image

From here, you can either go through a strict or a legacy resolver according to a isStrictMode check. For the vulnerability to trigger, we need to go through the legacy resolver.

Which Way Image

But how does Magento decide which resolver to use?

Strict or Legacy Resolver?

Since Magento 2.3.7-p3, all templates are parsed using the strictResolver.

However, all mail templates were not migrated to use strict-mode. Default Magento mail templates do not go through the strictResolver.

How does Magento determine if strict mode has to be used or not? Here is the associated code snippet within the getProcessedTemplate function:

$isLegacy = $this->getData('is_legacy');
$templateId = $this->getTemplateId();
$previousStrictMode = $processor->setStrictMode(
!$isLegacy && (is_numeric($templateId) || empty($templateId)));

For a default email template:

The result will be false, the default template will have strictMode parsing disabled.

If a Magento administrator decides to overwrite an existing template with his own, things are different:

In this case, strict mode will be enabled.

Database Example Image

Therefore, from what I understand, the vulnerability could only be triggered through Magento default mail templates (email header, footer and body).

However, this breaking change from legacy email templates to strict-mode templates may have broken the workflow of many shopping websites.

Magento administrators could have changed the is_legacy value of newly created templates back to “1”. They also could have patched the setStrictMode function to get back the old email functionalities.

Patching a Magento instance this way will also make administrator-created templates vulnerable to RCE.

Arbitrary Object Method Call

Searching for other email templates within Magento’s code, you will see that there are special variables that can be used in template directives such as $this, $store, $order, and so on:

Special Variables Image

Those variables correspond to templateVariables declared at the start of an email template, plus the $this and $store variables which are added at the start of template processing.

In the legacy resolver mode, you can chain a variable with a function such as getFrontendName(), getCustomerName(), and other more interesting functions 😉.

Variable function call is only possible thanks to the resolve function within the legacyResolver.

This function tokenizes a string into an array of different kinds, either variable, property and method. Then:

  1. If it finds a variable chained with property, it will call variable->getData(property).
  2. If it finds a variable with a method, it will call variable->method(all_other_params).

For example, a parameter url=$store.doThisFunction(param) is parsed. The resolve function will make $store a variable, and doThisFunction a method. It will therefore execute $store->doThisFunction(param).

But how is this arbitrary method executed in PHP? This is done in the handleObjectMethod function which contains a call to call_user_func_array:

Call User Func Array Image

From the PHP bible:

call_user_func_array(callable $callback, array $args): mixed

Calls the callback given by the first parameter with the parameters in args.

In our case we control all parameters given to the call_user_func_array function. Therefore, we can execute any function with any parameters when an email template is processed.

Not Really Arbitrary

There are two tiny exceptions to a method we can call:

Checks Image

Everything else is allowed 😉.

A Basic Method Call

With this knowledge, we can try to use the following payload as our FirstName: {{media url=$store.getFrontendName()}}

The $store.getFrontendName() value will be parsed with store as a variable and getFrontendName as a method: Store GetFrontendName Image

Therefore, within the handleObjectMethod, we will call: call_user_func_array([store, getFrontendName], null)

And in the received mail, we will have the store name TinyStore in our FirstName: Store GetFrontendName Email Image

Calling Dangerous Methods

SQL Statements

The $order template variable is of type Magento\Sales\Model\Order\Interceptor which extends \Magento\Sales\Model\Order.

What functions that do not start with set* can be accessed? For starters, we can access getResourceCollection that returns an AbstractCollection:

Function Access Image

Within AbstractCollection, we can call getConnection which returns an AdapterInterface:

Function GetConnection Image

Within AdapterInterface, we have a fetchOne function which will get back the first row of the executed SQL statement:

Function FetchOne Image

Chaining all of this together, we have the following payload:

{{media url=$order.getResourceCollection().getConnection().fetchOne("SELECT email FROM admin_user")}}

However, we can’t have spaces within our SQL statement, so the payload becomes:

{{media url=$order.getResourceCollection().getConnection().fetchOne("SELECT/**/email/**/FROM/**/admin_user;")}}

We receive an email with the result of the SQL statement, :

SQL Statement Result Image

Many functions impacting the database can be called this way to modify values or drop tables.

Server Code Execution

The server code execution path is well described in Catalin Filip’s video.

The idea is to use the $this template variable which is of type \Magento\Email\Model\Template\Interceptor which extends \Magento\Email\Model\Template.

From this variable, the function getTemplateFilter can be called, which returns Filter:

Function GetTemplateFilter Image

From a Filter object, the function addAfterFilterCallback can be called which returns a Template object:

Function AddAfterFilterCallback Image

This function does not look fascinating at a first glance. However, addAfterFilterCallback can be chained with a filter function which will parse the value it is given as a parameter.

What is interesting is that at the end of the parsing by filter, a function afterFilter is called.

afterFilter will then call call_user_func with the arbitrary callback we set using addAfterFilterCallback and the value given to filter:

Function call_user_func Image

Chaining everything together, this kind of payload could work:

{{media url=$this.getTemplateFilter().addAfterFilterCallback("system").filter("whoami")}}

However, when testing this payload I did not get the expected results as the callback list was polluted by an earlier directive. To fix this problem, you can use the following media directive that uses the payload twice:

{{media random=$this.getTemplateFilter().addAfterFilterCallback("system").filter("whoami") url=$this.getTemplateFilter().addAfterFilterCallback("system").filter("whoami")}}

The first payload call within the random parameter will fail, but the one within the url parameter will work.

We get the email with the result of system("whoami"), which is www-data:

Email Whoami Image

Patch 1

The first patch MDVA-43395_EE_2.4.3-p1_v1.patch (available here) fixed part of the problem.

Two functions were patched.

The first one was the transDirective function (at vendor/magento/module-email/Model/Template/Filter.php):

Patch1 Part1 Image

The second was the process method of the VarDirective class (at vendor/magento/framework/Filter/DirectiveProcessor/VarDirective.php): Patch1 Part2 Image

The patch seems to work at first. If we replay the same RCE payload as above and check our emails, we can see that we don’t have our precious www-data result in the received email:

The Patch Might Work Image

Our injected payload that was between {{ and }} characters was completely removed.

The patch matches everything between {{ and }} characters and removes it: we can’t inject ourselves into trans directives any more. From the patch we also understand that the same thing happens when trying to inject into var directives.

It looks like we can’t inject our malicious payloads any more… Or, can’t we?

When choosing which directive parser to pick, regexes are applied to the template content. The thing is, this is the regex you have to match to be considered a legacy directive:

const CONSTRUCTION_PATTERN = '/{{([a-z]{0,10})(.*?)}}(?:(.*?)(?:{{\/(?:\\1)}}))?/si';

What kind of sentence matches this pattern? This works: This Matches Image

Therefore, if the match/replace of {{ and }} characters is too aggressive and concatenations shenanigans happen, we can fall into the scenario of recreating new template directives.

Moreover, the replacement pattern used by the patch is {{.*?}}, so this kind of sentence won’t be replaced at first: This Won't Be Analyzed Image

Finally, only the process function of the VarDirective class was affected by the patch and not the varDirective function of the Filter class in the email-module. When parsing {{var directives}}, the code will often go through the varDirective function which does not use the above replacement regex.

Combining everything, we can observe that, for example, if we decide to use a FirstName with the value (missing the last }} characters):

{{media url=$order.getResourceCollection().getConnection().fetchOne("SELECT/**/email/**/FROM/**/admin_user;")

And our LastName with the value (missing the first {{):

media url=random2}}

The processing of the guest customer order template directives {{var formattedBillingAddress|raw}} and {{var formattedShippingAddress|raw}} will execute the provided SQL statement.

Patch Defeated Image

The first patch is defeated.

And, from what I understand, that is what CVE-2022-24087 is about.

Patch 2

Patch 2 (MDVA-43443_EE_2.4.2-p2_v1.patch) is wayyyyy more beefy 💪. All template directives were heavily modified and a total of 8 files were impacted by the patch.

Through heavy replace methods and sanitize functions all mentions to {{ and }} are now scrubbed from the surface of the universe.

Playing around the second patch, I did not find a bypass method, but there might be one 👼

Closing Thoughts

Working on both vulnerabilities was a pleasure. I feel like I gained some skills 🙈. I found the attack path original and was very smart.

Many thanks to researchers Eboda and Blaklis for the CVEs!

Thank you again Catalin Filip for your presentation!

Thank you for reading,

À bientôt ~ !

Do you have more info about both CVEs? Do not hesitate to reach me at petitemais <at> protonmail <dot> com ; 🥺🙏

Researchers To Follow