Sitemap

Reversing a Magento RCE (CVE-2022–24086)

6 min readJun 27, 2024

NOTE: This post has been sitting on my drafts forever and I don’t remember why I never published it before 🤣. The vulnerability addressed here has had its patch released for a while now and other write-ups were made available. I still think documenting my process may help someone learn something new, so I am making the post public.

This article documents the methodology I used to reverse CVE-2022–24086. It is worth mentioning that the method described here is just the one that eventually worked. Reversing this CVE was a joint effort with @fqdn, and there was a lot of sweat and tears until we got to the point of having a working payload. @_q5ca also had to withstand much of my winning and frustration as I worked on reversing this vulnerability, so thank you for all your help and patience!

Well … here you go.

Hypothesis

Executing arbitrary code by injecting one or more variable directives into a checkout e-mail message is possible.

Methodology

  • Analyze the vendor patch
  • Analyze the call stack when the patched functions are called during the checkout process
  • Identify execution sinks
  • Search for paths to reach execution sinks

Analysis

I started my process by configuring a Magento 2 unpatched environment with XDEBUG. For the XDEBUG client and code editor, I used VSCode. Once the environment was set up, I added one dummy product to the store to emulate the checkout process. By analyzing Adobe’s MDVA-43395_EE_2.4.3-p1_v1 patch, it is clear their objective was to prevent strings matching the regular expression pattern /{{.*}}/ from reaching the Filter class applyModifiers method, both defined in the /app/code/Magento/Email/Model/Template/Filter.php b/app/code/Magento/Email/Model/Template/Filter.php file.

Therefore, I started my reversing process by setting up a breakpoint right before applyModifiers is called during the checkout process.

Adobe’s patch description identifies the vulnerability as unauthenticated, so I simulated the purchase as a store guest. Once the breakpoint was hit, the call stack looked like this after removing Interceptors.

I used a simple bash oneliner to look for execution sinks (e.g., system, passthru, call_user_func, etc.)in the files present in the call stack and came up with a result similar to the following:

Press enter or click to view image in full size

We have a couple of interesting calls to the call_user_func* family functions. Exploiting call_user_func_array is always a little trickier than the simpler call_user_func as you need also to control the object whose method is being called. Thus, I further investigated the call to call_user_func in the framework/Filter/Template.php file.

Press enter or click to view image in full size

It’s easy to see that if we can somehow control the values of both $callback and $value when call_user_func is called, we have our RCE. The next step is to trace the method call chain and understand if and how we can reach this sink.

The afterFilter method is protected, meaning we can’t call it directly from an object from another class. Investigating the Template class, I found the public method filter calls the afterFilter method once it’s done. This means that if one can call filter, there might be a way to reach the sink.

Press enter or click to view image in full size

Other than reaching the sink, we need a way to include our callbacks, and that’s where the addAfterFilterCallback comes in.

Press enter or click to view image in full size

With all the method calls I needed, it was time to find a way to call them. In both patches, Adobe released modified directive classes. Including the DependDirective, whose process method is called in our call stack above. Reviewing the process method call, one can identify the filter method of a Filter class instance being called. This is precisely the method we need to reach.

Press enter or click to view image in full size

Now one has to find a way to call addAfterFilterCallback to include a function-like system in the callback chain. Maybe that variableResolver>resolve call is the key? Indeed it is.

This part took me forever to figure out. Still, eventually, I discovered that if Magento’s legacy variable resolver is used to parse a template’s trans directive content, a user can access the properties and call methods of every object available in the $templateVariables method argument when resolve is called. This includes the this variable, which is a reference for the LegacyResolver instance itself!

Press enter or click to view image in full size

By abusing that “feature,” a user calls the getTemplateFilter method to get a reference to a framework/Filter instance, and chain method calls to finally call the methods one needs to achieve RCE.

Press enter or click to view image in full size

My first payload looked something like the following.

{{var this.getTemplateFilter().addAfterFilterCallback(system).filter(id)}}

I tried using it in the guest checkout process.

Press enter or click to view image in full size

Eventually, the execution sink was triggered, however …
My $value was right, but the $callback wasn’t the one I expected.

Press enter or click to view image in full size

The Filter instance afterFilterCallbacks is already populated when the exploit calls addAfterFilterCallback, so the injected callback function is not the first to be called. Thus, our $value is tampered with before executing our system callback. That’s where the resetAfterFilterCallbacks comes in. This method clears the afterFilterCallbacks property and provides a clean slate for future addAfterFilterCallback calls.

Press enter or click to view image in full size

The resetAfterFilterCallbacks method is called every time the afterFilter method is called, as seen above. As one can make arbitrary calls to the filter method and, therefore, to the afterFilter method, two calls should be enough to clear the callbacks list and include the system callback.

My second payload looked something like the following:

{{var this.getTemplateFilter().filter(foobar)}}{{var this.getTemplateFilter().addAfterFilterCallback(system).filter(id)}}

This time afterFilter is called a first time for $value foobar, the resetAfterFilterCallbacks method is called, and the afterFilterCallbacks property is cleared.

Press enter or click to view image in full size

Then, afterFilter is called a second time, and now both the $callback and the $value values are according to the payload ones.

Press enter or click to view image in full size

The result can be seen in the HTTP request’s response as the system function outputs the command's return.

Press enter or click to view image in full size

I worked on this bug around March 2022 and at the time, there were a lot of fake proof-of-concepts published. My peers and I were able to reverse the patch and have a working exploit before any public write-ups were available, which is always a great feeling 😊.

--

--

Daniel Santos
Daniel Santos

Written by Daniel Santos

Security researcher and penetration tester

No responses yet