WordPress — самая популярная CMS в мире. На ней работает от четверти до трети сайтов, и уязвимости в ней находят частенько. На этот раз мы разберем логическую недоработку, которая позволяет проводить широкий спектр атак — от обхода авторизации и SQL-инъекций до выполнения произвольного кода. Добро пожаловать в удивительный мир инъекций в WordPress!
Уязвимость кроется в одном из методов ядра, который отвечает за формирование запросов к базе данных. Для нее имеется хиленький 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 использует класс wpdb
.
38: /** 39: * WordPress Database Access Abstraction Object ... 51: */ 52: class wpdb {
В качестве драйвера используется MySQLi или MySQL, если первая библиотека отсутствует в системе.
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).
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
.
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
.
1252: public function escape_by_ref( &$string ) { 1253: if ( ! is_float( $string ) ) 1254: $string = $this->_real_escape( $string ); 1255: }
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
, которая вставляет их в запрос, и он возвращается пользователю для дальнейших манипуляций.
1310: return @vsprintf( $query, $args );
Поэтому на выходе запрос с псевдоинъекцией будет корректно экранирован и выполнен.
С виду вроде бы все логично и аккуратно. Какие же тут могут быть подводные камни?
Cтатьи из последних выпусков журнала можно покупать отдельно только через два месяца после публикации. Чтобы читать эту статью, необходимо купить подписку.
Подписка позволит тебе в течение указанного срока читать ВСЕ платные материалы сайта, включая эту статью. Мы принимаем оплату банковскими картами, электронными деньгами и переводами со счетов мобильных операторов. Подробнее о подписке
1 год4690 р. Экономия 1400 рублей! |
1 месяц490 р. 25-30 статей в месяц |
Уже подписан?
Читайте также
Последние новости