Finding a POP chain on a common Symfony bundle: part 1
The Symfony doctrine/doctrine-bundle
package is one of the most common bundles installed along Symfony applications. At the time we are releasing this blogpost, it has been downloaded 144 million times, making it an interesting target for unserialize exploitation. If you want to improve your knowledge about PHP unserialize exploitation and see why weak typed languages are considered less secure, this blogpost is for you.
The first part of this article aims to show a full methodology of POP chain research, it details the full code analysis methodology used to identify a valid vulnerable path. The second part of it will be focused on the full build of a valid POP chain via a basic trial and error logic based on the code analyzed on this section.
Targeting Symfony indirectly
As stated in our blogpost, finding POP chain in the main Symfony framework seems quite difficult due to its minimalism. Many basic features only come with extra dependencies such as Doctrine which is used as its ORM (Object Relation Mapping). This ORM is also one of the most commonly used along many other PHP projects : Drupal, Laravel, PrestaShop, etc. It is used to manage and abstract database access from the application.
To make Doctrine and Symfony compatible, the doctrine-bundle
was created since Symfony version 2.1 if we believe the first paragraph of the first README released on the project.
Because Symfony 2 does not want to force or suggest a specific persistence solutions on the users this bundle was removed from the core of the Symfony 2 framework. Doctrine2 will still be a major player in the Symfony world and the bundle is maintained by developers in the Doctrine and Symfony communities.
IMPORTANT: This bundle is developed for Symfony 2.1 and up. For Symfony 2.0 applications the DoctrineBundle
is still shipped with the core Symfony repository.
Methodology: identifying interesting entrypoints
Finding POP chains can be time-consuming, because the scope on which they are based is huge when digging in PHP dependencies. This is why it is important to keep in mind where to start, and see how to jump from one object to another, step by step.
The full methodology and logic followed to find them and make them work together is described in the following sections.
PHP unserialization, finding what you want via static analysis
First, it is mandatory to find a __wakeup
, __unserialize
or a __destruct
method implemented in the project dependencies. The __toString
method can also be called, but the object unserialized has to be called inside a function such as print
or echo
after its unserialization, which is unlikely to happen.
Without going too deep in the details (more details on use cases and tricks can be found on payload all the things), when a serialized string is put through unserialization, the __wakeup
method will be called first (or __unserialize
instead). The object will then eventually get destroyed, therefore calling its __destruct
method if it is defined.
Sorting __wakeup, __unserialize and __destruct
To make the assumptions, and the flow of this blogpost easier to follow, each step of the logic followed in order to identify the full chain will be presented with a figure. So the first target during this research is to sort __wakeup
, __unserialize
or __destruct
functions from the code base analyzed.
Grepping your classes
doctrine-bundle
dependencies can be installed from composer
. After that, a simple grep can do the trick : the doctrine/doctrine-bundle
dependencies contain many possible entrypoints.
$ composer require doctrine/doctrine-bundle
./composer.json has been created
Running composer update doctrine/doctrine-bundle
Loading composer repositories with package information
Updating dependencies
Lock file operations: 35 installs, 0 updates, 0 removals
- Locking doctrine/cache (2.2.0)
- Locking doctrine/dbal (3.5.3)
- Locking doctrine/deprecations (v1.0.0)
- Locking doctrine/doctrine-bundle (2.8.2
[...]
$ cd vendor
$ grep -Ri 'function __destruct'
doctrine/cache/lib/Doctrine/Common/Cache/Psr6/CacheAdapter.php: public function __destruct()
doctrine/dbal/src/Logging/Connection.php: public function __destruct()
symfony/framework-bundle/Tests/Kernel/flex-style/src/FlexStyleMicroKernel.php: public function __destruct()
symfony/dependency-injection/Loader/Configurator/ServiceConfigurator.php: public function __destruct()
symfony/dependency-injection/Loader/Configurator/AbstractServiceConfigurator.php: public function __destruct()
symfony/dependency-injection/Loader/Configurator/ServicesConfigurator.php: public function __destruct()
symfony/dependency-injection/Loader/Configurator/PrototypeConfigurator.php: public function __destruct()
symfony/cache/Adapter/TagAwareAdapter.php: public function __destruct()
symfony/cache/Traits/AbstractAdapterTrait.php: public function __destruct()
symfony/cache/Traits/FilesystemCommonTrait.php: public function __destruct()
symfony/error-handler/BufferingLogger.php: public function __destruct()
symfony/routing/Loader/Configurator/ImportConfigurator.php: public function __destruct()
symfony/routing/Loader/Configurator/CollectionConfigurator.php: public function __destruct()
symfony/http-kernel/DataCollector/DumpDataCollector.php: public function __destruct()
Sorting the possible entrypoints
On deeply hardened projects such as Symfony, a protection against unserialization is often set by throwing an error when the __wakeup
function is called, since it is called before the __destruct
function. As shown in the following result, this in depth hardening is set on many Symfony classes.
$ grep -hri 'function __wakeup' -A4 .
public function __wakeup()
{
throw new \BadMethodCallException('Cannot unserialize '.__CLASS__);
}
--
public function __wakeup()
{
throw new \BadMethodCallException('Cannot unserialize '.__CLASS__);
}
--
[...]
Keep in mind that it is possible to sort the classes implementing __destruct
but not containing the BadMethodCallException
keyword:
$ grep -rl '__destruct' | xargs grep -L BadMethodCallException
doctrine/cache/lib/Doctrine/Common/Cache/Psr6/CacheAdapter.php
doctrine/dbal/src/Logging/Connection.php
symfony/dependency-injection/Loader/Configurator/ServiceConfigurator.php
symfony/dependency-injection/Loader/Configurator/AbstractServiceConfigurator.php
symfony/dependency-injection/Loader/Configurator/ServicesConfigurator.php
symfony/dependency-injection/Loader/Configurator/PrototypeConfigurator.php
symfony/var-dumper/Caster/ExceptionCaster.php
symfony/http-kernel/Tests/DataCollector/DumpDataCollectorTest.php
Luckily for us, the Doctrine\Common\Cache\Psr6\CacheAdapter
class seems pretty promising! It was used as the default doctrine cache adapter along the doctrine/cache
package until the Doctrine version 1.11.x (maintained since 2019). It was then deprecated, however, it was kept for backward compatibility, and even if the code should not be used anymore, doctrine/cache
was kept for backward compatibility.
<?php
namespace Doctrine\Common\Cache\Psr6;
final class CacheAdapter implements CacheItemPoolInterface
{
[...]
public function commit(): bool
{
[...]
}
public function __destruct() {
$this->commit();
}
}
As we can see, the __destruct
function is reachable and directly calls the commit
function of the object, let's see what can be done from this point.
What about __call function definitions?
Another path explored during this research was to analyze classes defining a __call
function.
The PHP documentation explains that:
__call()
is triggered when invoking inaccessible methods in an object context
$ grep -Ri 'function __call' .
./doctrine/dbal/src/Schema/Comparator.php: public function __call(string $method, array $args): SchemaDiff
./doctrine/dbal/src/Schema/Comparator.php: public static function __callStatic(string $method, array $args): SchemaDiff
./symfony/event-dispatcher/Debug/TraceableEventDispatcher.php: public function __call(string $method, array $arguments): mixed
./symfony/dependency-injection/Loader/Configurator/EnvConfigurator.php: public function __call(string $name, array $arguments): static
./symfony/dependency-injection/Loader/Configurator/AbstractConfigurator.php: public function __call(string $method, array $args)
./symfony/cache/Traits/RedisClusterNodeProxy.php: public function __call(string $method, array $args)
While it will not be described in this blogpost since it did not pay out here, keep in mind that it should also be covered if you look for POP chains since it can be reached in most cases.
Jumping from controlled functions
The commit
function we reached is normally used to update the deferred items cached by the Doctrine cache. Basically, it deletes items which reached expiry and saves all the others in the cache definition.
The phpdoc (@var lines) on the class attributes suggests that $cache
should implement the Cache
interface, and $deferredItems
should be an array of either CacheItem
or TypedCacheItem
. This is only for documentation purposes and does not enforce strong typing, meaning that any call to their methods can be hijacked because we can control which class will be implemented thanks to unserialization.
From this point, 4 functions can be called from $this->cache
or $this->deferredItems
objects. Let's have a look to each object implementing them to see if interesting code can be reached.
<?php
namespace Doctrine\Common\Cache\Psr6;
final class CacheAdapter implements CacheItemPoolInterface
{
/** @var Cache */
private $cache;
/** @var array<CacheItem|TypedCacheItem> */
private $deferredItems = [];
[...]
public function commit(): bool
{
if (! $this->deferredItems) {
return true;
}
$now = microtime(true);
$itemsCount = 0;
$byLifetime = [];
$expiredKeys = [];
foreach ($this->deferredItems as $key => $item) {
$lifetime = ($item->getExpiry() ?? $now) - $now; // [1]
if ($lifetime < 0) {
$expiredKeys[] = $key;
continue;
}
++$itemsCount;
$byLifetime[(int) $lifetime][$key] = $item->get(); // [2]
}
$this->deferredItems = [];
switch (count($expiredKeys)) {
case 0:
break;
case 1:
$this->cache->delete(current($expiredKeys)); // [4]
break;
default:
$this->doDeleteMultiple($expiredKeys);
break;
}
if ($itemsCount === 1) {
return $this->cache->save($key, $item->get(), (int) $lifetime); // [3]
}
$success = true;
foreach ($byLifetime as $lifetime => $values) {
$success = $this->doSaveMultiple($values, $lifetime) && $success;
}
return $success;
}
public function __destruct() {
$this->commit();
}
}
- [1] The
getExpiry()
function from any object
This function is not a good match, it does not reach interesting code and is only defined in 2 classes:
$ grep -ri 'function getexpiry' -A 3
doctrine/cache/lib/Doctrine/Common/Cache/Psr6/TypedCacheItem.php: public function getExpiry(): ?float
doctrine/cache/lib/Doctrine/Common/Cache/Psr6/TypedCacheItem.php- {
doctrine/cache/lib/Doctrine/Common/Cache/Psr6/TypedCacheItem.php- return $this->expiry;
doctrine/cache/lib/Doctrine/Common/Cache/Psr6/TypedCacheItem.php- }
--
doctrine/cache/lib/Doctrine/Common/Cache/Psr6/CacheItem.php: public function getExpiry(): ?float
doctrine/cache/lib/Doctrine/Common/Cache/Psr6/CacheItem.php- {
doctrine/cache/lib/Doctrine/Common/Cache/Psr6/CacheItem.php- return $this->expiry;
doctrine/cache/lib/Doctrine/Common/Cache/Psr6/CacheItem.php- }
- [2] The
get()
function from any object
This function definition seems promising. It is defined at least in 53 files in the doctrine/doctrine-bundle
dependencies.
$ grep -ri 'function get(' | wc -l
53
However, the $item->getExpiry()
code is reached before, and we saw that only 2 objects implements a getExpiry
function. Both of them only return a value, which makes get
calls unreachable as shown on the following code snippet.
<?php
final class CacheAdapter implements CacheItemPoolInterface
{
[...]
public function commit(): bool
{
[...]
foreach ($this->deferredItems as $key => $item) {
$lifetime = ($item->getExpiry() ?? $now) - $now; // [1]
if ($lifetime < 0) {
$expiredKeys[] = $key;
continue;
}
++$itemsCount;
$byLifetime[(int) $lifetime][$key] = $item->get(); // [2]
}
}
}
- [3] The
save($param1, $param2, int $param3)
function from any object
save
is a common function name and without surprise, many classes or traits are defining it.
$ grep -ri 'function save(' .
./psr/cache/src/CacheItemPoolInterface.php: public function save(CacheItemInterface $item): bool;
./doctrine/cache/lib/Doctrine/Common/Cache/Psr6/CacheAdapter.php: public function save(CacheItemInterface $item): bool
./doctrine/cache/lib/Doctrine/Common/Cache/Cache.php: public function save($id, $data, $lifeTime = 0);
./doctrine/cache/lib/Doctrine/Common/Cache/CacheProvider.php: public function save($id, $data, $lifeTime = 0)
./symfony/http-foundation/Session/Storage/MockArraySessionStorage.php: public function save()
./symfony/http-foundation/Session/Storage/SessionStorageInterface.php: public function save();
./symfony/http-foundation/Session/Storage/NativeSessionStorage.php: public function save()
./symfony/http-foundation/Session/Storage/MockFileSessionStorage.php: public function save()
./symfony/http-foundation/Session/Session.php: public function save()
./symfony/http-foundation/Session/SessionInterface.php: public function save();
./symfony/cache/Adapter/ProxyAdapter.php: public function save(CacheItemInterface $item): bool
./symfony/cache/Adapter/PhpArrayAdapter.php: public function save(CacheItemInterface $item): bool
./symfony/cache/Adapter/TraceableAdapter.php: public function save(CacheItemInterface $item): bool
./symfony/cache/Adapter/ChainAdapter.php: public function save(CacheItemInterface $item): bool
./symfony/cache/Adapter/ArrayAdapter.php: public function save(CacheItemInterface $item): bool
./symfony/cache/Adapter/NullAdapter.php: public function save(CacheItemInterface $item): bool
./symfony/cache/Adapter/TagAwareAdapter.php: public function save(CacheItemInterface $item): bool
./symfony/cache/Traits/RedisCluster6Proxy.php: public function save($key_or_address): \RedisCluster|bool
./symfony/cache/Traits/AbstractAdapterTrait.php: public function save(CacheItemInterface $item): bool
./symfony/cache/Traits/RedisCluster5Proxy.php: public function save($key_or_address)
./symfony/cache/Traits/Redis6Proxy.php: public function save(): \Redis|bool
./symfony/cache/Traits/Redis5Proxy.php: public function save()
./symfony/http-kernel/HttpCache/Store.php: private function save(string $key, string $data, bool $overwrite = true): bool
Many of these classes are useless for our purpose, but Symfony\Component\HttpFoundation\Session\Storage\MockFileSessionStorage
can be used to write a file. This is one of the main targets for this POP chain. To reach its code, it is necessary to define one $item
which has an expiration
inferior to the current time. $key
will be its first parameter.
<?php
final class CacheAdapter implements CacheItemPoolInterface
{
[...]
public function commit(): bool
{
[...]
foreach ($this->deferredItems as $key => $item) {
$lifetime = ($item->getExpiry() ?? $now) - $now; // [1]
if ($lifetime < 0) {
$expiredKeys[] = $key;
continue;
}
++$itemsCount;
$byLifetime[(int) $lifetime][$key] = $item->get(); // [2]
}
[...]
if ($itemsCount === 1) {
return $this->cache->save($key, $item->get(), (int) $lifetime); // [3]
}
}
}
- [4] The
delete($param1)
function from any object
Unlike the save
function, delete
definitions are less common in PHP projects. However, it only makes it easier to look for them in all the files.
$ grep -ri 'function delete(' .
./doctrine/cache/lib/Doctrine/Common/Cache/Cache.php: public function delete($id);
./doctrine/cache/lib/Doctrine/Common/Cache/CacheProvider.php: public function delete($id)
./doctrine/dbal/src/Query/QueryBuilder.php: public function delete($delete = null, $alias = null)
./doctrine/dbal/src/Connection.php: public function delete($table, array $criteria, array $types = [])
./symfony/cache-contracts/CacheTrait.php: public function delete(string $key): bool
./symfony/cache-contracts/CacheInterface.php: public function delete(string $key): bool;
./symfony/cache/Psr16Cache.php: public function delete($key): bool
./symfony/cache/Adapter/TraceableAdapter.php: public function delete(string $key): bool
./symfony/cache/Adapter/ArrayAdapter.php: public function delete(string $key): bool
./symfony/cache/Adapter/NullAdapter.php: public function delete(string $key): bool
./symfony/cache/Traits/Redis6Proxy.php: public function delete($key, ...$other_keys): \Redis|false|int
./symfony/cache/Traits/Redis5Proxy.php: public function delete($key, ...$other_keys)
From these classes, Symfony\Component\Cache\Adapter\PhpArrayAdapter
can be used to arbitrary include
a file. This is the final target of this POP chain.
To reach it, it is necessary to define one $item
which has an expiration
higher than the current time. $item
will be its first parameter.
<?php
final class CacheAdapter implements CacheItemPoolInterface
{
[...]
public function commit(): bool
{
[...]
foreach ($this->deferredItems as $key => $item) {
$lifetime = ($item->getExpiry() ?? $now) - $now; // [1]
if ($lifetime < 0) {
$expiredKeys[] = $key;
continue;
}
++$itemsCount;
$byLifetime[(int) $lifetime][$key] = $item->get(); // [2]
}
switch (count($expiredKeys)) {
case 0:
break;
case 1:
$this->cache->delete(current($expiredKeys)); // [4]
break;
default:
$this->doDeleteMultiple($expiredKeys);
break;
}
[...]
}
}
This chain is used to include
a file from an arbitrary path.
First step, MockFileSessionStorage to get file write
Now that we know better where we can search, let's dig more!
The first step while looking for vulnerable code in PHP code would be to see if user supplied data is passed to dangerous functions such as system
, eval
, include
, require
, exec
, popen
, call_user_func
, file_put_contents
. There are many others, however, the main idea here is that since the potential vulnerable scope was refined to save()
functions, it is now necessary to audit each reachable save functions from the analyzed dependencies to identify the POP chain.
As we can see, the only reachable and interesting function seems to be file_put_contents
in the save
function of the Symfony\Component\HttpFoundation\Session\Storage\MockFileSessionStorage
class.
$ grep -hri 'function save(' -A50 . | grep system
$ grep -hri 'function save(' -A50 . | grep eval
$ grep -hri 'function save(' -A50 . | grep include
$ grep -hri 'function save(' -A50 . | grep require
* When versioning is enabled, clearing the cache is atomic and does not require listing existing keys to proceed,
* but old keys may need garbage collection and extra round-trips to the back-end are required.
$ grep -hri 'function save(' -A50 . | grep exec
$ grep -hri 'function save(' -A50 . | grep popen
$ grep -hri 'function save(' -A50 . | grep call_user_func
[...]
$ grep -hri 'function save(' -A50 . | grep file_put_content
file_put_contents($tmp, serialize($data));
$ grep -ri 'file_put_contents($tmp, serialize($data))' .
./symfony/http-foundation/Session/Storage/MockFileSessionStorage.php: file_put_contents($tmp, serialize($data));
$ grep -i 'file_put_contents($tmp, serialize($data))' -B 21 -A 12 ./symfony/http-foundation/Session/Storage/MockFileSessionStorage.php
public function save()
{
if (!$this->started) {
throw new \RuntimeException('Trying to save a session that was not started yet or was already closed.');
}
$data = $this->data;
foreach ($this->bags as $bag) {
if (empty($data[$key = $bag->getStorageKey()])) {
unset($data[$key]);
}
}
if ([$key = $this->metadataBag->getStorageKey()] === array_keys($data)) {
unset($data[$key]);
}
try {
if ($data) {
$path = $this->getFilePath();
$tmp = $path.bin2hex(random_bytes(6));
file_put_contents($tmp, serialize($data));
rename($tmp, $path);
} else {
$this->destroy();
}
} finally {
$this->data = $data;
}
// this is needed when the session object is re-used across multiple requests
// in functional tests.
$this->started = false;
}
While looking promising at first, the extension of the generated file cannot be defined, which makes it less interesting.
<?php
namespace Symfony\Component\HttpFoundation\Session\Storage;
class MockFileSessionStorage extends MockArraySessionStorage
{
private string $savePath;
public function save()
{
[...]
try {
if ($data) {
$path = $this->getFilePath();
$tmp = $path.bin2hex(random_bytes(6));
file_put_contents($tmp, serialize($data));
rename($tmp, $path);
} else {
$this->destroy();
}
} finally {
$this->data = $data;
}
$this->started = false;
}
private function getFilePath(): string
{
return $this->savePath.'/'.$this->id.'.mocksess';
}
}
However, as we can see, we can control a serialized data injected in a file, which can be executed as PHP code if executed.
$ php -r "echo serialize('<?php phpinfo(); ?>');" > /tmp/test_serialize
$ php /tmp/test_serialize
s:19:"phpinfo()
PHP Version => 8.1.22
[...]
questions about PHP licensing, please contact license@php.net.
The $path = $this->getFilePath()
code is used to define the path of the file written in the file_put_contents
method.
<?php
namespace Symfony\Component\HttpFoundation\Session\Storage;
class MockFileSessionStorage extends MockArraySessionStorage
{
[...]
private function getFilePath(): string
{
return $this->savePath.'/'.$this->id.'.mocksess';
}
}
The .mocksess
is suffixed to the file, preventing us from getting code execution by creating a .php
file in the folder exposing sources to the user. However, getting an arbitrary file write is the only prerequisite needed before proceeding to the second step. The following schema wraps up this first element of the POP chain.
Second step, finding the path to file inclusion
The same methodology can be applied to look for delete
function calls.
$ grep -hri 'function delete(' -A50 . | grep file_put_content | grep system
$ grep -hri 'function delete(' -A50 . | grep eval
[...]
$ grep -hri 'function delete(' -A50 . | grep include
$ grep -hri 'function delete(' -A50 . | grep require
$ grep -hri 'function delete(' -A50 . | grep exec
[...]
$ grep -hri 'function delete(' -A50 . | grep popen
$ grep -hri 'function delete(' -A50 . | grep call_user_func
[...]
After searching for many common dangerous functions, it was clear that there was no quick win in those. This then means we need to dive into all of them individually. Let's start by looking for the good old weak typing trick used at the start of this POP chain, allowing us to call delete
functions from other objects.
$ grep -hri 'function delete(' -A3 .
public function delete($id);
--
public function delete(string $key): bool
{
return $this->deleteItem($key);
}
--
public function delete(string $key): bool
{
return $this->deleteItem($key);
}
--
public function delete($id)
{
return $this->doDelete($this->getNamespacedId($id));
}
--
public function delete($table, array $criteria, array $types = [])
{
if (count($criteria) === 0) {
throw InvalidArgumentException::fromEmptyCriteria();
--
public function delete($key): bool
{
try {
return $this->pool->deleteItem($key);
[...]
$ grep -Ri 'return $this->pool->deleteItem($key);' .
./symfony/cache/Psr16Cache.php: return $this->pool->deleteItem($key);
As we can see, the deleteItem
function seems promising since it is called from many delete
functions, let's see what can be reached from it.
From a call to the PhpArrayAdapter
function deleteItem
, it is possible to reach its initialize
method which includes an arbitrary file. Since we already have a file write, we would be able to include it in order to get code execution.
$ grep -hri 'function deleteItem(' -A6 .
public function deleteItem(mixed $key): bool
{
if (!\is_string($key)) {
throw new InvalidArgumentException(sprintf('Cache key must be string, "%s" given.', get_debug_type($key)));
}
if (!isset($this->values)) {
$this->initialize();
[...]
$ grep -Ri ' $this->initialize();' .
./symfony/cache/Adapter/PhpArrayAdapter.php: $this->initialize();
[...]
$ grep 'function initialize' -A10 ./symfony/cache/Adapter/PhpArrayAdapter.php
private function initialize()
{
if (isset(self::$valuesCache[$this->file])) {
$values = self::$valuesCache[$this->file];
} elseif (!is_file($this->file)) {
$this->keys = $this->values = [];
return;
} else {
$values = self::$valuesCache[$this->file] = (include $this->file) ?: [[], []];
}
Unfortunately for us, it is not possible to use a PHP filter chain to get command execution from this path alone. This is due to the elseif (!is_file($this->file)
condition, which verifies that the file is present on the file system, preventing any call to the php://
wrapper.
To sum up, the goal is now to find a way to reach PhpArrayAdapter
in order to reach its deleteItem
function to put together the puzzle pieces.
Getting rekt by PHP strong typing
Now that our plan is well-defined, let's see how to reach PhpArrayAdapter
from our previously discovered delete
function.
$ grep -Ri 'return $this->pool->deleteItem($key);' .
./symfony/cache/Psr16Cache.php: return $this->pool->deleteItem($key);
At first sight, the Psr16Cache
class seems perfect to reach our target as we might be able to reach any other deleteItem
function by defining its pool
attribute as a PhpArrayAdapter
object. However, as we said, while PHP is a weakly typed language, it is also possible to harden it by enforcing strong typing. Unfortunately for us, this is the case in the Psr16Cache
class.
cat ./symfony/cache/Psr16Cache.php
<?php
[...]
class Psr16Cache implements CacheInterface, PruneableInterface, ResettableInterface
{
use ProxyTrait;
private ?\Closure $createCacheItem = null;
private ?CacheItem $cacheItemPrototype = null;
private static \Closure $packCacheItem;
public function __construct(CacheItemPoolInterface $pool)
{
$this->pool = $pool;
}
The check that the parameter $pool
is a CacheItemPoolInterface
interface prevents us to use a PhpArrayAdapter
class instead.
PHP traits analysis
Now that the most straight forward path has been invalidated, let's see what options are left to us.
$ grep -Ri 'function delete(' .
./doctrine/cache/lib/Doctrine/Common/Cache/Cache.php: public function delete($id);
./doctrine/cache/lib/Doctrine/Common/Cache/CacheProvider.php: public function delete($id)
./doctrine/dbal/src/Query/QueryBuilder.php: public function delete($delete = null, $alias = null)
./doctrine/dbal/src/Connection.php: public function delete($table, array $criteria, array $types = [])
./symfony/cache-contracts/CacheTrait.php: public function delete(string $key): bool
[...]
In the objects defining the delete
function, the CacheTrait
trait seems promising. The PHP documentation defines a trait as a way to reuse code, which is basically a way to write a function or an attribute and to define them inside another class. All there is to do is to add it in the class via the use
keyword.
$ cat ./symfony/cache-contracts/CacheTrait.php | grep 'function delete(' -A 3
public function delete(string $key): bool
{
return $this->deleteItem($key);
}
A CacheTrait
calls the deleteItem
function of the object using it. If by chance our target, the PhpArrayAdapter
class, uses the CacheTrait
, we would then be able to call its deleteItem
function and therefore reach the require
function needed to get code execution.
$ grep -Ri 'use CacheTrait' .
./symfony/cache/Traits/ContractsTrait.php: use CacheTrait {
$ grep -Ri 'use ContractsTrait' .
./symfony/cache/Adapter/ProxyAdapter.php: use ContractsTrait;
./symfony/cache/Adapter/PhpArrayAdapter.php: use ContractsTrait;
[...]
Even if the PhpArrayAdapter
class does not directly use the CacheTrait
, it uses the ContractsTrait
which uses it because traits can be nested.
Finally, reaching PhpArrayAdapter for the win
After a deeper investigation we finally discovered that the PhpArrayAdapter
had already everything needed to reach its vulnerable initialize
function. The CacheTrait
deleteItem
function is defined, allowing to call the initialize
function to finally reach the include
function to execute the PHP code we put in a file at the beginning.
$ cat ./symfony/cache-contracts/CacheTrait.php | grep 'function delete(' -A 3
public function delete(string $key): bool
{
return $this->deleteItem($key);
}
$ cat ./symfony/cache/Adapter/PhpArrayAdapter.php | grep 'function deleteItem(' -A6
public function deleteItem(mixed $key): bool
{
if (!\is_string($key)) {
throw new InvalidArgumentException(sprintf('Cache key must be string, "%s" given.', get_debug_type($key)));
}
if (!isset($this->values)) {
$this->initialize();
$ cat ./symfony/cache/Adapter/PhpArrayAdapter.php | grep 'function initialize(' -A9
private function initialize()
{
if (isset(self::$valuesCache[$this->file])) {
$values = self::$valuesCache[$this->file];
} elseif (!is_file($this->file)) {
$this->keys = $this->values = [];
return;
} else {
$values = self::$valuesCache[$this->file] = (include $this->file) ?: [[], []];
Recap all the vulnerable code
This POP chain started from the __destruct
function of the CacheAdapter
object which calls its commit
function. After digging its code, we identified that the MockFileSessionStorage
can be reached via a weak typing trick on the save
function, this allowed us to get file write. Finally, PhpArrayAdapter
could be reached from a weak typing trick on the delete
function, leading to an arbitrary file inclusion after a few more steps.
The following diagram recaps every piece of code covered by the POP chain.
Conclusion
Looking for POP chain in huge dependencies is time-consuming, but playing with so much sources is a good way to understand PHP's mechanism deeply.
In the first part of this research, we saw that weak typing can be used as a tool to reach unexpected functions.
As we could see, it is not always required to use fancy tools to find interesting code path. Understanding an exploitation path and knowing what we are looking for is most of the time sufficient to get the work done! That being said, the methodology used in this article is really time-consuming and could be greatly optimized combined with the usage of a debugger such as Xdebug
for example.
In the next part, we will build the full POP chain based on the sources we already dissected and show a full exploit of a vulnerable Symfony application containing the doctrine/doctrine-bundle
package. Since this chain is in fact based on 2 different PHP objects and on a code base evolving through PHP versions, some interesting tricks are involved so stay tuned!