Reversing a Magento RCE (CVE-2022–24086)

Daniel Santos
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:

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.

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.

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

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.

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!

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.

My first payload looked something like the following.

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

I tried using it in the guest checkout process.

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

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.

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.

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

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

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 😊.

--

--