Содержание статьи
Уязвимость кроется в одном из методов ядра, который отвечает за формирование запросов к базе данных. Для нее имеется хиленький PoC, и я его покажу, но главное — понять саму логику ошибки, почему она существует и каким образом ее можно проэксплуатировать. Этот материал будет особенно полезен тем, кто проводит аудиты исходников приложений, которые взаимодействуют с базами данных, так как в статье будут затронуты методы обнаружения ошибок в кастомных абстракциях для формирования и выполнения запросов к БД.
Стенд
Для начала, как водится, нам понадобится тестовый стенд. На момент написания статьи уязвимость была исправлена в последней версии WP — 4.8.3. Поэтому мы возьмем версию 4.7.4, там баг присутствует, и его можно потискать. Я рекомендую воспользоваться докер-контейнером, в котором уже есть все необходимое для запуска установки CMS и не нужно возиться с настройками веб-сервера. Благо в хабе «Докера» есть официальный репозиторий WordPress и можно развернуть рабочий сервер с нужной версией всего несколькими командами.
docker run -p3306:3306 -d --rm --name some-mysql -e MYSQL_ROOT_PASSWORD="toor" mysql
docker run -p80:80 -d --link some-mysql:mysql --rm -e WORDPRESS_DB_PASSWORD="toor" wordpress:4.7.4-php5.6-apache
После этого переходим в браузере по IP «Докера» и наблюдаем знаменитую пятисекундную установку.
Также мы создадим несколько скриптов на PHP, они понадобятся нам для понимания и отладки разных ступеней и вариантов эксплуатации. Все их будет объединять одно — загрузка ядра WordPress. Это можно сделать при помощи следующего кода:
<?php
# No need for the template engine
define( 'WP_USE_THEMES', false );
# Load WordPress Core
require_once( 'wp-load.php' );
На этом приготовления закончены, приступаем к серфингу по исходникам.
Работа с БД в WordPress
Для построения и выполнения запросов к базе данных WordPress использует класс wpdb
.
/wp-includes/wp-db.php
38: /**
39: * WordPress Database Access Abstraction Object
...
51: */
52: class wpdb {
В качестве драйвера используется MySQLi или MySQL, если первая библиотека отсутствует в системе.
/wp-includes/wp-db.php
632: /* Use ext/mysqli if it exists and:
...
638: if ( function_exists( 'mysqli_connect' ) ) {
639: if ( defined( 'WP_USE_EXT_MYSQL' ) ) {
640: $this->use_mysqli = ! WP_USE_EXT_MYSQL;
641: } elseif ( version_compare( phpversion(), '5.5', '>=' ) || ! function_exists( 'mysql_connect' ) ) {
642: $this->use_mysqli = true;
643: } elseif ( false !== strpos( $GLOBALS['wp_version'], '-' ) ) {
644: $this->use_mysqli = true;
645: }
646: }
Ядро CMS использует метод prepare для обработки всех запросов — это кастомная реализация так называемых подготовленных выражений или связываемых переменных (prepared statements).
/wp-includes/wp-db.php
1257: /**
1258: * Prepares a SQL query for safe execution. Uses sprintf()-like syntax.
1259: *
1260: * The following directives can be used in the query format string:
1261: * %d (integer)
1262: * %f (float)
1263: * %s (string)
1264: * %% (literal percentage sign - no argument needed)
...
1291: public function prepare( $query, $args ) {
1292: if ( is_null( $query ) )
1293: return;
...
1300: $args = func_get_args();
1301: array_shift( $args );
...
1309: array_walk( $args, array( $this, 'escape_by_ref' ) );
1310: return @vsprintf( $query, $args );
1311: }
Основная идея — в использовании шаблонов форматирования строк, аналогичных функции sprintf, для указания типа и местоположения переменных в запросе. Именно результат работы vsprintf и возвращает prepare. А эта функция — не что иное, как такой же sprintf
, только в качестве аргумента она принимает массив параметров.
Например, попробуем выполнить какой-нибудь запрос (тестовый файл, настало твое время!).
$wpdb->prepare("SELECT id, user_login FROM $wpdb->users WHERE ID = %d", 1);
В процессе работы %d
заменяется на параметр, который мы передали, в данном случае это единица. В результате мы получаем данные пользователя с ID=1
.
Но такие статичные запросы нас мало интересуют, давай рассмотрим запрос с пользовательскими данными.
$wpdb->prepare("SELECT id, user_login FROM $wpdb->users WHERE user_login = %s", $_GET['login']);
Попробуем передать в качестве логина что-то в духе стандартной SQL-инъекции — admin' or 1=1
. Разумеется, такой вектор не отработает. Пробежимся по пути обработки запроса. Как запрос и параметр попадают в метод prepare
?
Все переданные параметры сваливаются в переменную args
, а запрос находится в query
.
/wp-includes/wp-db.php
1300: $args = func_get_args();
1301: array_shift( $args );
Если в качестве аргументов уже был передан массив, то используем его.
1302: // If args were passed as an array ( in vsprintf), move them up
1303: if ( isset( $args[0] ) && is_array($args[0]) )
1304: $args = $args[0];
Теперь работа с запросом. Если в нем строковые параметры уже обрамлены одинарными или двойными кавычками, убираем их.
1305: $query = str_replace( "'%s'", '%s', $query ); // in case someone mistakenly already singlequoted it
1306: $query = str_replace( '"%s"', '%s', $query ); // doublequote unquoting
Затем идет проверка значений типа float, которая не сильно нас интересует.
1307: $query = preg_replace( '|(?<!%)%f|' , '%F', $query ); // Force floats to be locale unaware
Следующее регулярное выражение добавляет одинарные кавычки к переменным, которые должны быть строками. При этом не учитываются экранированные плейсхолдеры типа %%s
.
1308: $query = preg_replace( '|(?<!%)%s|', "'%s'", $query ); // quote the strings, avoiding escaped strings like %%s
Далее пробегаемся по переданным аргументам функцией escape_by_ref
.
1309: array_walk( $args, array( $this, 'escape_by_ref' ) );
Функция, как ты можешь понять из названия, выполняет экранирование переданных в args
значений. На самом деле это лишь обертка с небольшим условием для _real_escape
, которая прогоняет все отправленные переменные через функцию mysqli_real_escape_string
.
/wp-includes/wp-db.php
1252: public function escape_by_ref( &$string ) {
1253: if ( ! is_float( $string ) )
1254: $string = $this->_real_escape( $string );
1255: }
/wp-includes/wp-db.php
1168: function _real_escape( $string ) {
1169: if ( $this->dbh ) {
1170: if ( $this->use_mysqli ) {
1171: return mysqli_real_escape_string( $this->dbh, $string );
1172: } else {
1173: return mysql_real_escape_string( $string, $this->dbh );
1174: }
1175: }
Затем данные улетают в функцию vsprintf
, которая вставляет их в запрос, и он возвращается пользователю для дальнейших манипуляций.
/wp-includes/wp-db.php
1310: return @vsprintf( $query, $args );
Поэтому на выходе запрос с псевдоинъекцией будет корректно экранирован и выполнен.
С виду вроде бы все логично и аккуратно. Какие же тут могут быть подводные камни?
Продолжение доступно только участникам
Вариант 1. Присоединись к сообществу «Xakep.ru», чтобы читать все материалы на сайте
Членство в сообществе в течение указанного срока откроет тебе доступ ко ВСЕМ материалам «Хакера», позволит скачивать выпуски в PDF, отключит рекламу на сайте и увеличит личную накопительную скидку! Подробнее
Вариант 2. Открой один материал
Заинтересовала статья, но нет возможности стать членом клуба «Xakep.ru»? Тогда этот вариант для тебя! Обрати внимание: этот способ подходит только для статей, опубликованных более двух месяцев назад.
Я уже участник «Xakep.ru»