Authenticated Magento RCE with deserialized PHAR files
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.
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-datatar
: 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 bytesphar
: any amount of data can be prefixed to the beginning of the stub.
You can then embed a PHAR archive inside a JPEG:
Start the archive with the JPEG start marker
0xFFD8
, followed by a comment start0xFFFE
and the length of the comment.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.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...]
} catch (Exception) {
}
}
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:
Upload a new image with the name
phar.jpg
to our Magento webpage.Add the image as:
<img src="{{media url=”phar.jpg”}}" alt="" >
Press the button to display the WYSIWYG editor. In the backend,
Directive.php
will receive{{media url=”phar.jpg”}}
.This will be passed to the filtering function, resulting in
imagePath
.Use the image path you get in the call to
$image->open($imagePath);
in this case,$imagePath
will bepub/media/phar.jpg
, wherepub/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
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:
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, say0
.Set
$engine
to1
, so the switch will go to the last case.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