Home Security research Authenticated Magento RCE with deserialized PHAR files

Authenticated Magento RCE with deserialized PHAR files

by Alexandru Postolache

Reading time

7 minutes

Reading Time: 7 minutes

Back in August 2019, I reported a security vulnerability in Magento affecting versions 2.3.2, 2.3.3, and 2.3.4 using the HackerOne bug bounty platform.

The bug impacted some installations of Magento and it allowed us to gain Remote Code Execution based on the way PHAR files are deserialized and by abusing Magento’s Protocol Directives.

The vulnerability was patched by Adobe on April 28, 2020. At the time of writing this article, the vulnerability report has not been made public yet.

Given this vulnerability can be exploited by users with any privilege level, it has a High Business Risk impact on unpatched deployments.

authenticated magento rce with deserialized phar files

1. Vulnerability analysis

Let’s dig into specifics! Below we’ll go over the affected Magento versions and instances, what caused the vulnerability, and how you can exploit it to achieve Remote Code Execution.

TL;DR

To better follow the next steps, here’s a summary of the attack sequence:

1. Not all Magento 2.3.2, 2.3.3, and 2.3.4 instances are affected. As of 2.3.1, PHAR support is disabled by default. In a default Magento installation, you can check if PHAR support is disabled under app/bootstrap.php. If you find the following snippet, your Magento installation is not affected:

if (in_array('phar', \stream_get_wrappers())) {

                                                        stream_wrapper_unregister('phar');

}

2. The vulnerability resides in the Admin Interface and you can trigger it by adding a new image to a page. PHAR archives can be embedded inside JPEGs – keep reading to see how. This image will be passed to a getimagesize() which will trigger deserialization if the filename begins with ”phar://”

3. In this case, it doesn’t and we needed to find a way to bypass this. In the front-end, the image src is set to {{media url=filename}}. This will determine the image to be processed by Magento’s Media Directive. The bug we found allowed us to change the src to access another directive. So we used the Protocol Directive which allowed us to have full control over the filename.

4. The only thing left was to find classes that can be used in a POP Chain to achieve RCE. We used GuzzleHttp/Stream/FnStream and phpseclib\Crypt\Hash.

Background: Embedding PHAR in JPEGs

The attack requires the ability to place a PHAR archive on the server, as the wrapper is only taken into consideration if the resource it references is local.

As you probably know, file uploads are subject to all sorts of limitations. When trying to exploit this kind of vulnerability, it’s useful to be able to craft files that are valid both as PHAR archives and another file type, like JPEG.

You can store PHARs in three file formats: phar, tar, and zip. For this example, let’s examine the TAR format, as it provides some flexibility you can use.

Let’s use the following properties of phar, tar, and jpeg to craft valid TAR/JPEG polyglot files:

  • jpeg: you can insert comments of arbitrary length in the meta-data
  • tar: the first 100 bytes of the archive contain the name of the first file of the archive; the end of the archive is marked by 1024 consecutive 0 bytes
  • phar: any amount of data can be prefixed to the beginning of the stub.

You can then embed a PHAR archive inside a JPEG:

  1. Start the archive with the JPEG start marker 0xFFD8, followed by a comment start 0xFFFE and the length of the comment.
  2. Then follow the actual comment, which will be our phar archive, plus 1024 zeros to mark the archive end. As a result, the length of the comment is equal to the length of the archive’s content + 1024.
  3. The image data comes after that.

A hypothetical JPG/PHAR polyglot would have this structure:

0xFFD8 | 0xFFFE|comment_length | PHAR_data | archive_end | image_data

Practical analysis

The vulnerability resides in the component responsible for rendering images in Magento’s WYSIWYG editor.

The editor has two states, shown and hidden. Whenever you switch from hidden to shown, a GET request is made towards an endpoint for each image on the page. It looks like this:

magento.url/admin/cms/wysiwyg/directive/___directive/base64(image.src)/…

In the backend, this reaches vendor/magento/module-cms/Controller/

Adminhtml/wysiwyg/Directive.php.

public function execute()

{

      $directive = $this->getRequest()->getParam('___directive');

      $directive = $this->urlDecoder->decode($directive);

[...snip...]

try   {

                      $filter = $this->_objectManager->create(Filter::class);

                      $imagePath = $filter->filter($directive);

                      $image = …

                       [...snip...]

                       $image->open($imagePath);

                       [...snip...]

      }

}

The base64 encoded image src mentioned before is seen as the value of $directive variable.

By default, this src will have the form {{media url=image-name}}. Here, media is what we’ll call the directive.

This is important because the next step in Directive.php is to apply some filtering to __directive: $imagePath = $filter->filter($directive);

The result of this filtering will be an image path whose value depends on the directive used in the src. We’ll come back to this in a moment.

After getting the image path, a new Image object is created. At the same time, the image is opened. The function that handles the opening makes a call to getimagesize(imagePath) which will deserialize a PHAR archive.

Let’s go through an example to see how this plays out:

  1. Upload a new image with the name phar.jpg to our Magento webpage.
  2. Add the image as: <img src="{{media url=”phar.jpg”}}" alt="" >
  3. Press the button to display the WYSIWYG editor. In the backend, Directive.php will receive {{media url=”phar.jpg”}}.
  4. This will be passed to the filtering function, resulting in imagePath.
  5. Use the image path you get in the call to $image->open($imagePath); in this case, $imagePath will be pub/media/phar.jpg, where pub/media is the default media location in Magento.

To deserialize the PHAR, you need to have control over the beginning of the name passed to getimagesize(). As stated in our technical guide on PHAR Deserialization, deserialization works only if the name of the file looks like “phar://…”.

So you need to find a way to get full control of the resulting imagePath.

Protocol Directive

Going back to Directive.php, we know the image path is the result of the call to $imagePath = $filter->filter($directive).

The $filter object is an instance of the class Magento\\Cms\\Model\\Template\\Filter, which – in turn – inherits the class Magento\\Email\\Model\\Template\\Filter. Keep these details because they will be useful later.

The filter method will try to match $directive to a specific regular expression. Based on the match, it will make a callback to a method based on the directive present in $directive.

In the default scenario, with {{media url=”phar.jpg”}} as input, a call will be made to mediaDirective. This method belongs to Magento\\Cms\\Model\\Template\\Filter. As mentioned before, this class inherits Magento\\Email\\Model\\Template\\Filter, which luckily contains more directive methods.

This means you can change the input to access another directive. For this deep-dive, we used protocolDirective which you can use to control the resulting name of the image. To access this directive, you must supply an input with the following format {{protocol …}}

Let’s look at a code snippet for this method:

public function protocolDirective($construction)

{

        $params = $this->getParameters($construction[2]);

        $isSecure = 



$this->_storeManager->getStore($store)->isCurrentlySecure();

      $protocol = $isSecure ? 'https' : 'http';

      if  (isset($params['url']))   {

               return $protocol . '://' . $params['url'];

      } elseif (isset($params['http']) && isset($params['https'])) {

            if ($isSecure)    {

                return $params['https'];

            }

             return $params['http'];

      }

      return $protocol;

}

Starting with the first line, $params will be an associative array. If you look below, you can see that it includes 3 relevant keys: url, http, and https.

If $params[‘url’] is not set AND both $params[‘http’] and $params[‘https’] are set, the method will return whatever was passed as the value of http or https depending on whether the store uses HTTPS or not.

Your resulting payload is then: {{protocol http=”phar://phar.jpg” https=”phar://phar.jpg”}}.

Using this as the src of the image in the frontend will result in $imagePath = “phar://phar.jpg” which will trigger deserialization.

Profit

We now need a POP chain to include in our PHAR to exploit the application.

The classes used are GuzzleHttp/Stream/FnStream and phpseclib\Crypt\Hash.

The destructor of GuzzleHttp/Stream/FnStream is used to start the exploit chain:

public function __destruct()

{

      if (isset($this->_fn_close)) {

          call_user_func($this->_fn_close);

      }

}

You can use this to make a callback to any method of any class currently in scope. A candidate for this would be a method that – again – uses call_user_func(), but with multiple parameters. This allows you to use functions like passthru() or exec() to execute commands on the server.

The method _computeKey of phpseclib\Crypt\Hash makes such a call:

function _computeKey()

{

          if ($this->key === false) {

               $this->computedKey = false;

               return;

      }

           if (strlen($this->key) <= $this->b) {

$this->computedKey = $this->key;

                return;

      }

           switch ($this->engine) {

              //modified the cases to ease understanding

              case 2:

                     $this->computedKey = mhash($this->hash, $this->key);

                     break;

             case 3:

                     $this->computedKey = hash($this->hash, $this->key, true);

                     break;

             case 1:

                     $this->computedKey = call_user_func($this->hash, $this->key);

      }

}

To execute the code using this function, you have to meet three conditions:

  1. Set $key to a value that is longer than $b. Given that $key will be our server command, you can set $b to a small number, say 0
  2. Set $engine to 1, so the switch will go to the last case.
  3. Set $hash to something like “passthru”.

2. Impact

By abusing Magento’s Protocol Directives, any authenticated user can upload a crafted image file that’s able to perform a Remote Code Execution in the context of the webserver’s user.

3. Proof of Concept

Responsible disclosure timeline

  • August 29, 2019 – Vulnerability submitted to HackerOne
  • September 12, 2019 – Bug Triaged by HackerOne
  • October 8, 2019 – Magento (Adobe) Security Team Response
  • November 27, 2019 – Bounty Awarded by HackerOne
  • April 28, 2020 – Fix Released by Adobe

Related Posts

COVID-19 Free Pentesting Tests Report

Find out why lower-severity vulns are the bigger pain

rce windows dns sigred vulnerability

The 17-year-old DNS vulnerability that leads to RCE in Windows

0 comments

Comments